Skip to the content

Capsid menu

workers/gate.ts

51 lines and 3 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of gate.ts
FunctionLinesComplexityCoveredCRAPMutation
withPrivateHeaders22 to 302100%OkCRAP 2No data: mutation testing has not run on this repo yet
refuse33 to 381100%OkCRAP 1No data: mutation testing has not run on this repo yet
gate40 to 58691%WatchCRAP 6No 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));
}