Skip to the content

Capsid menu

src/auth.ts

86 lines and 7 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of auth.ts
FunctionLinesComplexityCoveredCRAPMutation
sha256Hex4 to 61100%OkCRAP 1No data: mutation testing has not run on this repo yet
hmacHex9 to 191100%OkCRAP 1No data: mutation testing has not run on this repo yet
getCookie22 to 315100%OkCRAP 5No data: mutation testing has not run on this repo yet
bearerToken48 to 513100%OkCRAP 3No data: mutation testing has not run on this repo yet
operatorIdentity53 to 698100%WatchCRAP 8No data: mutation testing has not run on this repo yet
isAdminEmail78 to 887100%WatchCRAP 7No data: mutation testing has not run on this repo yet
adminGrantEmail93 to 965100%OkCRAP 5No data: mutation testing has not run on this repo yet

Imports

It imports src/encoding.ts. Imported by src/access-login.ts, src/agents-admin.ts, src/agents.ts, src/approval.ts, src/bench.ts, src/canon.ts, src/controls-token.ts, src/doc-statements.ts, src/index.ts, src/job-signing.ts, src/namespace-delete-token.ts, src/namespace-delete.ts, src/operator-key.ts, src/ops-feed.ts, src/policy-sign.ts, src/portal-actions.ts, src/portal-auth.ts, src/routes.ts, src/signed-docs.ts, src/signed-requests.ts, src/store-guards.ts, src/tools/docs-lifecycle.ts, src/tools/docs-write.ts.

Source

import { safeEqual } from "@dustinedwards/site-runtime/compare";
import { bytesToHex } from "./encoding";

export async function sha256Hex(input: string): Promise<string> {
  return bytesToHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)));
}

// HMAC-SHA256, hex. The one definition.
export async function hmacHex(secret: string, payload: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
  return bytesToHex(sig);
}

// Cookie lookup, once, for the consent flow and the Portal session.
export function getCookie(request: Request, name: string): string | null {
  const header = request.headers.get("Cookie");
  if (!header) return null;
  for (const part of header.split(";")) {
    const eq = part.indexOf("=");
    if (eq === -1) continue;
    if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
  }
  return null;
}

// OPERATOR_KEY_HASH: comma-separated sha256 hex. Plain = write, "ro:" = read.
// Revoke by removing a hash. A single unprefixed hash still parses as write.
type OperatorGrant = "write" | "read" | null;

// Fingerprint is 12 hex of the presented key's sha256, not the stored verifier.
export interface OperatorIdentity {
  grant: OperatorGrant;
  fingerprint: string | null;
}

/**
 * The token of an `Authorization: Bearer` header, or null. The scheme is case-insensitive
 * (RFC 6750 via RFC 9110), and a token is one run without whitespace, as in Carrel and
 * site-api; a header with anything after the token is refused.
 */
export function bearerToken(request: Request): string | null {
  const match = /^Bearer +(\S+) *$/i.exec(request.headers.get("Authorization") ?? "");
  return match ? match[1] : null;
}

export async function operatorIdentity(
  request: Request,
  env: { OPERATOR_KEY_HASH?: string }
): Promise<OperatorIdentity> {
  const token = bearerToken(request);
  if (!token || !env.OPERATOR_KEY_HASH) return { grant: null, fingerprint: null };
  const hash = await sha256Hex(token);
  for (const raw of env.OPERATOR_KEY_HASH.split(",")) {
    const entry = raw.trim().toLowerCase();
    if (!entry) continue;
    const readonly = entry.startsWith("ro:");
    if (await safeEqual(readonly ? entry.slice(3) : entry, hash)) {
      return { grant: readonly ? "read" : "write", fingerprint: hash.slice(0, 12) };
    }
  }
  return { grant: null, fingerprint: null };
}

type AdminEnv = { ADMIN_EMAIL?: string; CO_OWNER_EMAILS?: string };

// The admin, by the email Access verified: ADMIN_EMAIL, the primary owner, or an entry
// of CO_OWNER_EMAILS (comma separated). Each entry is trimmed and compared exactly, with
// no case folding; empty entries are skipped. An unset ADMIN_EMAIL admits nobody, a
// co-owner included, so losing the primary owner's secret closes both logins. Both
// secrets are set only in Cloudflare: nothing inside Capsid can change who is admin.
export async function isAdminEmail(env: AdminEnv, email: string): Promise<boolean> {
  const admin = (env.ADMIN_EMAIL ?? "").trim();
  if (!admin) return false;
  const candidate = email.trim();
  let admitted = false;
  for (const raw of [admin, ...(env.CO_OWNER_EMAILS ?? "").split(",")]) {
    const entry = raw.trim();
    if (entry && (await safeEqual(candidate, entry))) admitted = true;
  }
  return admitted;
}

// A grant's props on /mcp, checked on every request: only a grant whose email Access
// verified as an admin (isAdminEmail) passes. A grant from before the move to Access
// carries a GitHub login and no email, and fails here, so its client signs in again once.
export async function adminGrantEmail(env: AdminEnv, props: unknown): Promise<string | null> {
  const email = props && typeof props === "object" ? (props as { email?: unknown }).email : undefined;
  return typeof email === "string" && (await isAdminEmail(env, email)) ? email : null;
}