workers/gate.ts
51 lines and 3 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| withPrivateHeaders | 22 to 30 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| refuse | 33 to 38 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| gate | 40 to 58 | 6 | 91% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/lib/access.server.ts, app/lib/people.server.ts. Imported by workers/app.ts.
Source
// The browser door. Nothing renders until Access has vouched for the request and the email it
// carries belongs to an active person in Carrel. Kept apart from app.ts so tests can drive it without
// the React Router build.
//
// This is the only door on carrel.dustinedwards.info. The AI door is a different hostname,
// carrel-mcp.dustinedwards.info, served by app/lib/mcp/door.ts and chosen in app.ts by the host
// alone (design decision 7, corrected 2026-09-27). So a browser session is never treated as AI, and
// an AI session never reaches the buttons: /mcp here is just a page that does not exist, behind
// Access like every other.
import type { KeySource } from "@dustinedwards/site-runtime/access-login";
import { verifyAccess } from "~/lib/access.server";
import { findViewer, type Viewer } from "~/lib/people.server";
export type Render = (request: Request, viewer: Viewer) => Promise<Response>;
// Caching only. The security headers (HSTS, framing, nosniff, Referrer-Policy and the rest) are
// workers/security-headers.ts's, applied by app.ts to everything this returns, refusals included.
const PRIVATE_CACHE = "private, no-store";
function withPrivateHeaders(response: Response): Response {
const out = new Response(response.body, response);
// An HTML page is left as it is: Cloudflare does not touch a response that says no-transform, which
// keeps the Web Analytics beacon out of private pages (a dashboard exclusion needs the Pro plan).
// Scripts, styles and fonts stay compressible. One Cache-Control header, never two.
const html = out.headers.get("Content-Type")?.toLowerCase().startsWith("text/html");
out.headers.set("Cache-Control", html ? `${PRIVATE_CACHE}, no-transform` : PRIVATE_CACHE);
return out;
}
/** The same bare 403 for every refusal, so a caller learns nothing about why. The reason is logged. */
function refuse(reason: string): Response {
console.warn(JSON.stringify({ gate: "refused", reason }));
return withPrivateHeaders(
new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }),
);
}
export async function gate(
request: Request,
env: Env,
render: Render,
keys?: KeySource,
): Promise<Response> {
let viewer: Viewer | null;
try {
const access = await verifyAccess(request, { teamDomain: env.ACCESS_TEAM_DOMAIN, audience: env.ACCESS_AUD }, keys);
if (!access.ok) return refuse(access.reason);
viewer = await findViewer(env.DB, access.email);
} catch (error) {
return refuse(`error: ${error instanceof Error ? error.message : String(error)}`);
}
if (!viewer) return refuse("unknown-person");
// A reviewer is an AI agent, not a person: it has no business at the browser door.
if (viewer.isReviewer) return refuse("reviewer-at-browser-door");
return withPrivateHeaders(await render(request, viewer));
}