Ctrl K
ComunidadGuías…

src/features/auth.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Authentication and session utilities
 */

import { unwrapKeyWithToken } from "#crypto/keys.ts";
import type { WrappedKey } from "#crypto/sealed.ts";
import { apiKeyLimiter } from "#db/api-key-attempts.ts";
import { getApiKeyByToken, touchApiKeyLastUsed } from "#db/api-keys.ts";
import { deleteSession, getSessionWithUser } from "#db/sessions.ts";
import {
  decryptAdminLevel,
  getUserAuthFieldsById,
  type UserAuthFields,
} from "#db/users.ts";
import { apiErrorResponse } from "#routes/api/cors.ts";
import type { JsonBodyReader } from "#routes/api/json-body.ts";
import { type AuthChannel, authFailure } from "#routes/auth-failures.ts";
import { applyFlash, parseFormData } from "#routes/csrf.ts";
import { lowerContentType } from "#routes/middleware.ts";
import { readJsonBody } from "#routes/read-json-body.ts";
import { htmlResponse } from "#routes/response.ts";
import { parseCookies } from "#routes/url.ts";
import {
  type AdminDestinationDef,
  ownerOnlyAudience,
} from "#shared/admin-surface/definitions.ts";
import { adminPageAudience } from "#shared/admin-surface.ts";
import { getSessionCookieName } from "#shared/cookies.ts";
import { signCsrfToken, verifySignedCsrfToken } from "#shared/csrf.ts";
import type { Flash } from "#shared/flash-context.ts";
import type { FormParams } from "#shared/form-data.ts";
import { setSavedFormData } from "#shared/forms/saved-data.ts";
import { SCANNER_CSRF_MAX_AGE_S } from "#shared/limits.ts";
import { ErrorCode, logError } from "#shared/logger.ts";
import { nowMs } from "#shared/now.ts";
import { addPendingWork } from "#shared/pending-work.ts";
import { getRequestClientIp } from "#shared/request-context.ts";
import type { RequestRoute, ResponseHandler } from "#shared/response-steps.ts";
import { getCachedSession, setCachedSession } from "#shared/session-context.ts";
import { getSettingsNagItemsForOwner } from "#shared/settings-nags.ts";
import {
  type AdminLevel,
  ALL_ADMIN_LEVELS,
  CONTENT_ADMIN_LEVELS,
  DELIVERY_ADMIN_LEVELS,
  DOOR_ADMIN_LEVELS,
  isRecord,
  isStaffRole,
  type NagItem,
  SITE_ADMIN_LEVELS,
} from "#types";

/** Session with wrapped data key for private key derivation, and user role */
export type AuthSession = {
  token: string;
  wrappedDataKey: WrappedKey | null;
  userId: number;
  adminLevel: AdminLevel;
  settingsNagItems?: readonly NagItem[];
};

/** Report a session or API key whose user has since been deleted. Always gives
 * back null so a caller can `return reportMissingAuthUser(...)`. */
const reportMissingAuthUser = (missingDetail: string): null => {
  logError({ code: ErrorCode.AUTH_INVALID_SESSION, detail: missingDetail });
  return null;
};

/** Load the user's auth fields for an API key. When the id points at no user,
 * log an invalid-session error and give back null so the caller can bail. */
const loadAuthUserFields = async (
  userId: number,
  missingDetail: string,
): Promise<UserAuthFields | null> =>
  (await getUserAuthFieldsById(userId)) ?? reportMissingAuthUser(missingDetail);

/** Drop the cached session (and, when the caller has a token, the stored
 *  session row) — the one "this session is no longer valid" step every bail-out
 *  below shares. Always resolves to null so callers can `return invalidateSession(...)`. */
const invalidateSession = async (token?: string): Promise<null> => {
  if (token) await deleteSession(token);
  setCachedSession(null);
  return null;
};

/**
 * Get authenticated session if valid
 * Returns null if not authenticated
 * Includes wrapped_data_key. Callers derive the private key from it when needed.
 * Loads user info and decrypts admin_level for role checking
 *
 * Validates that wrapped_data_key can be unwrapped with current DB_ENCRYPTION_KEY.
 * If unwrapping fails (e.g., after key rotation), the session is invalidated.
 */
export const getAuthenticatedSession = async (
  request: Request,
): Promise<AuthSession | null> => {
  const cached = getCachedSession();
  if (cached !== undefined) return cached;

  const cookies = parseCookies(request);
  const token = cookies.get(getSessionCookieName());
  if (!token) return invalidateSession();

  const found = await getSessionWithUser(token);
  if (!found) return invalidateSession();
  const { session, user } = found;

  if (session.expires < nowMs()) return invalidateSession(token);

  if (!user) {
    reportMissingAuthUser("Session references non-existent user, invalidating");
    return invalidateSession(token);
  }

  const adminLevel = await decryptAdminLevel(user);
  await signCsrfToken();

  const result: AuthSession = {
    adminLevel,
    token,
    userId: session.user_id,
    wrappedDataKey: session.wrapped_data_key,
  };

  if (adminLevel === "owner") {
    result.settingsNagItems = await getSettingsNagItemsForOwner();
  }

  setCachedSession(result);
  return result;
};

/**
 * Extract Bearer token from Authorization header.
 */
const getBearerToken = (request: Request): string | null => {
  const auth = request.headers.get("authorization");
  if (!auth?.startsWith("Bearer ")) return null;
  return auth.slice(7);
};

/**
 * Authenticate via API key (Bearer token).
 * Returns an AuthSession (compatible with session-based auth) or null.
 * API key auth bypasses CSRF since the key itself is the secret.
 */
export const getAuthenticatedApiKey = async (
  request: Request,
): Promise<AuthSession | null> => {
  const token = getBearerToken(request);
  if (!token) return null;

  // Throttle brute-force guessing per IP. Only failed lookups are counted, so a
  // client with a valid key is never locked out; once an IP is locked, even a
  // correct token is rejected until the lockout expires.
  const ip = getRequestClientIp();
  if (await apiKeyLimiter.isLimited(ip)) {
    logError({
      code: ErrorCode.AUTH_INVALID_SESSION,
      detail: "API key authentication rate limited",
    });
    return null;
  }

  const apiKeyRow = await getApiKeyByToken(token);
  if (!apiKeyRow) {
    await apiKeyLimiter.record(ip);
    logError({
      code: ErrorCode.AUTH_INVALID_SESSION,
      detail: "Bearer token does not match any API key",
    });
    return null;
  }

  const user = await loadAuthUserFields(
    apiKeyRow.user_id,
    "API key references non-existent user",
  );
  if (!user) return null;

  try {
    await unwrapKeyWithToken(apiKeyRow.wrapped_data_key, token);
  } catch {
    logError({
      code: ErrorCode.AUTH_INVALID_SESSION,
      detail: "API key wrapped data key corrupted",
    });
    return null;
  }

  // getPrivateKey() expects token + wrappedDataKey in the session's format.
  const wrappedDataKey = apiKeyRow.wrapped_data_key;

  const adminLevel = await decryptAdminLevel(user);

  // Best-effort last_used update, queued as pending work so it settles.
  addPendingWork(
    touchApiKeyLastUsed(apiKeyRow.id).catch(() => {
      // A stats write must never fail the request.
    }),
  );

  const result: AuthSession = {
    adminLevel,
    token,
    userId: apiKeyRow.user_id,
    wrappedDataKey,
  };
  setCachedSession(result);
  return result;
};

/** How the request was authenticated */
type AuthKind = "cookie" | "apiKey";

/** Body parsing mode for authenticated requests */
type BodyMode = "form" | "multipart" | "json";

/** Maps body mode to the parsed body type */
type ParsedBody<T extends BodyMode> = T extends "form"
  ? FormParams
  : T extends "multipart"
    ? FormData
    : Record<string, unknown>;

/** Policy controlling authentication, CSRF, role, and body parsing */
export type AuthPolicy<T extends BodyMode = BodyMode> = {
  body: T;
  /** Require exactly this role. */
  role?: AdminLevel;
  /** Allow any of these roles (use instead of `role` for multi-role gates). */
  roles?: readonly AdminLevel[];
  allowApiKey?: boolean;
  /** Override the CSRF token max-age (seconds). Defaults to the standard 1 hour. */
  csrfMaxAge?: number;
};

/**
 * Whether a session's role satisfies a gate.
 * - `roles` given: membership test.
 * - single `role` given: exact match (owner-only or agent-only gates).
 * - neither: any back-office staff (owner|manager). Delivery agents are
 *   excluded by default so introducing the agent class can never widen access
 *   to an existing staff page that didn't explicitly opt them in.
 */
const sessionRoleAllowed = (
  level: AdminLevel,
  role: AdminLevel | undefined,
  roles: readonly AdminLevel[] | undefined,
): boolean => {
  if (roles) return roles.includes(level);
  if (role) return level === role;
  return isStaffRole(level);
};

/** Auth policy presets — use with withAuth to avoid repeating policy objects */
export const OWNER_FORM: AuthPolicy<"form"> = { body: "form", role: "owner" };
/** Agent-only form gate (delivery run-sheet actions). */
export const AGENT_FORM: AuthPolicy<"form"> = { body: "form", role: "agent" };
/** Content-editing form gate: staff plus the content-only `editor`. Used for the
 * listing/group create-edit actions and the public-site content saves editors
 * are opted into. */
export const CONTENT_FORM: AuthPolicy<"form"> = {
  body: "form",
  roles: CONTENT_ADMIN_LEVELS,
};
/** Content-editing multipart gate (listing create/edit with image uploads). */
export const CONTENT_MULTIPART: AuthPolicy<"multipart"> = {
  body: "multipart",
  roles: CONTENT_ADMIN_LEVELS,
};
/** Public-site content form gate: owner + editor only (managers stay excluded
 * from site editing — see {@link SITE_ADMIN_LEVELS}). */
export const SITE_FORM: AuthPolicy<"form"> = {
  body: "form",
  roles: SITE_ADMIN_LEVELS,
};
/** Public-site content multipart gate (news-post image uploads). */
export const SITE_MULTIPART: AuthPolicy<"multipart"> = {
  body: "multipart",
  roles: SITE_ADMIN_LEVELS,
};
/** Delivery run-sheet form gate: staff + agent, but NOT editor. */
export const DELIVERY_FORM: AuthPolicy<"form"> = {
  body: "form",
  roles: DELIVERY_ADMIN_LEVELS,
};
/** Door check-in form gate: staff + the door-only `scanner`. The ticket
 * check-in page's check-in/out toggle is the one write a scanner login makes. */
export const DOOR_FORM: AuthPolicy<"form"> = {
  body: "form",
  roles: DOOR_ADMIN_LEVELS,
};
/** Form gate that admits any authenticated user, agents and editors included —
 * used for actions every logged-in user must reach, like logout. */
export const ANY_USER_FORM: AuthPolicy<"form"> = {
  body: "form",
  roles: ALL_ADMIN_LEVELS,
};
export const OWNER_MULTIPART: AuthPolicy<"multipart"> = {
  body: "multipart",
  role: "owner",
};
export const ADMIN_API: AuthPolicy<"json"> = {
  allowApiKey: true,
  body: "json",
};
/**
 * Content-admin JSON API: the audience the groups and listings pages declare,
 * so an editor does through the API what the dashboard allows, and no more.
 */
export const CONTENT_API: AuthPolicy<"json"> = {
  allowApiKey: true,
  body: "json",
  roles: CONTENT_ADMIN_LEVELS,
};
/**
 * Owner-only JSON API: like ADMIN_API but restricted to the owner role, for
 * resources whose web management is owner-only (e.g. holidays). Keeps the JSON
 * API authorization aligned with the UI so a manager cannot perform via the API
 * what the dashboard denies them.
 */
export const OWNER_API: AuthPolicy<"json"> = {
  allowApiKey: true,
  body: "json",
  role: "owner",
};
/**
 * Scanner check-in API: cookie-authenticated JSON with a CSRF max-age matching
 * the session lifetime, so a logged-in admin can keep the scanner page open for
 * a whole listing without check-ins failing on CSRF expiry. Door roles only —
 * the same audience the scanner pages declare.
 */
export const SCANNER_JSON: AuthPolicy<"json"> = {
  body: "json",
  csrfMaxAge: SCANNER_CSRF_MAX_AGE_S,
  roles: DOOR_ADMIN_LEVELS,
};

/** Get the current cookie session, or the channel's not-authenticated failure. */
const cookieSessionOrFailure = async (
  request: Request,
  channel: AuthChannel,
): Promise<AuthSession | Response> => {
  const session = await getAuthenticatedSession(request);
  return session ?? authFailure(channel, "not-authenticated");
};

/**
 * Core session + role gate. Returns the session on success, or a
 * channel-appropriate failure Response.
 */
const requireSessionFor = async (
  request: Request,
  channel: AuthChannel,
  role?: AdminLevel,
  roles?: readonly AdminLevel[],
): Promise<AuthSession | Response> => {
  const session = await cookieSessionOrFailure(request, channel);
  if (isResponse(session)) return session;
  if (!sessionRoleAllowed(session.adminLevel, role, roles)) {
    return authFailure(
      channel,
      "forbidden",
      ownerOnlyAudience({ role, roles }) ? "owner-only" : undefined,
    );
  }
  return session;
};

const isResponse = (v: unknown): v is Response => v instanceof Response;

/** A response callback that receives the signed-in session. */
export type AuthSessionHandler = ResponseHandler<[session: AuthSession]>;

/**
 * Low-level session gate with custom no-session fallback (used by dashboard login page).
 * Only checks cookie-based auth. API key auth is intentionally excluded —
 * Bearer tokens should only authenticate /api/* endpoints via withAuth + ADMIN_API.
 */
export const withSession = async (
  request: Request,
  handler: AuthSessionHandler,
  onNoSession: ResponseHandler,
): Promise<Response> => {
  const session = await getAuthenticatedSession(request);
  return session ? handler(session) : onNoSession();
};

/** Build a session guard that admits any of the given roles. */
const requireRolesOr =
  (roles: readonly AdminLevel[]) =>
  async (request: Request, handler: AuthSessionHandler): Promise<Response> => {
    const result = await requireSessionFor(request, "html", undefined, roles);
    return isResponse(result) ? result : handler(result);
  };

/** Require a content-editing session (staff or editor) — 403 outside
 * {@link CONTENT_ADMIN_LEVELS}. Used for the listing/group create-edit pages. */
export const requireContentOr = requireRolesOr(CONTENT_ADMIN_LEVELS);

/** Require a site-editing session (owner or editor) — 403 outside
 * {@link SITE_ADMIN_LEVELS}. Managers stay excluded from site editing. */
export const requireSiteOr = requireRolesOr(SITE_ADMIN_LEVELS);

/** Require a delivery-run-sheet session (staff or agent, NOT editor) — 403
 * outside {@link DELIVERY_ADMIN_LEVELS}. */
export const requireDeliveryOr = requireRolesOr(DELIVERY_ADMIN_LEVELS);

/**
 * The gates below take the roles they admit from the route the handler serves,
 * so `admin-surface/areas.ts` is the one place a role is written down.
 * `test/integration/admin-role-matrix.test.ts` asks every page as every role
 * and proves the gate and the declaration still agree.
 */

/** A GET page's gate, admitting exactly the roles its route declares. */
export const pageGuardFor = (
  route: AdminDestinationDef,
): SessionGuard<AuthSession> => requireRolesOr(route.audience);

/** One record page's gate: every role that can reach any route beneath it.
 * A tab open to a wider role sits under this same path and needs to get in;
 * the tab's own `visible` is what keeps the narrower tabs shut. A page whose
 * beneath-routes include a standalone tool rather than a tab names its own tab
 * audience instead — the listing and group scanner doors are such routes, and
 * the folded floor would otherwise admit the door-only `scanner` login to a
 * page whose every tab hides from them. The role matrix still catches a page
 * that freezes out a role one of its tab destinations declares. */
export const recordPageGuardFor = (
  route: AdminDestinationDef,
  audience?: readonly AdminLevel[],
): SessionGuard<AuthSession> =>
  requireRolesOr(audience ?? adminPageAudience(route));

/** A form POST's policy, admitting exactly the roles its route declares. */
export const formPolicyFor = (
  route: AdminDestinationDef,
): AuthPolicy<"form"> => ({ body: "form", roles: route.audience });

/** A gate a route runs before its handler: it authenticates/loads whatever
 * the handler needs, then calls it with that data — or answers the request
 * itself (a redirect, a 403) without ever calling it. */
export type Guard<TArgs extends unknown[]> = (
  request: Request,
  handler: ResponseHandler<TArgs>,
) => Promise<Response>;

/** Session guard: require auth and call handler with session */
export type SessionGuard<TSession> = Guard<[TSession]>;

/** Turn a form auth policy into a route gate that yields its session and form. */
export const formGuard =
  (
    policy: AuthPolicy<"form">,
  ): Guard<[session: AuthSession, form: FormParams]> =>
  (request, handler) =>
    withAuth(request, policy, (session, form) => handler(session, form));

/** Factory for authenticated GET routes whose builder returns the full
 * Response — for pages that may 404, redirect, or set headers. Applies any
 * flashed message first and hands its values to the builder. */
export const authResponsePage =
  <TSession>(requireSession: SessionGuard<TSession>) =>
  (
    build: ResponseHandler<[session: TSession, request: Request, flash: Flash]>,
  ): RequestRoute =>
  (request) =>
    requireSession(request, (session) =>
      build(session, request, applyFlash(request)),
    );

/** Factory for creating authenticated page handlers. The render callback is
 * given the request too, for pages that read query params (e.g. the deliveries
 * run sheet's date picker), and the flashed message values, for pages that
 * thread them into the template; callers that need neither simply ignore
 * them. */
export const authPage =
  <TSession>(requireSession: SessionGuard<TSession>) =>
  (
    render: (
      session: TSession,
      request: Request,
      flash: Flash,
    ) => string | Promise<string>,
  ): RequestRoute =>
    authResponsePage(requireSession)(async (session, request, flash) =>
      htmlResponse(await render(session, request, flash)),
    );

/** What a role's response-page helper is: it takes the builder for the full
 * Response and returns the guarded route. */
type ResponsePageFactory = (
  build: ResponseHandler<
    [session: AuthSession, request: Request, flash: Flash]
  >,
) => RequestRoute;

/** Content-editing GET route (staff or editor) whose builder returns the full
 * Response (may 404 or redirect instead of rendering). */
export const contentResponsePage: ResponsePageFactory =
  authResponsePage(requireContentOr);

/** Content-editing GET page (staff or editor): authenticate, apply flash,
 * render HTML. */
export const contentPage = authPage(requireContentOr);

/** Site-editing GET page (owner or editor): authenticate, apply flash,
 * render HTML. */
export const sitePage = authPage(requireSiteOr);

/** Delivery-run-sheet GET page (staff or agent): authenticate, apply flash,
 * render HTML. */
export const deliveryPage = authPage(requireDeliveryOr);

/** Look up the current session (or null) and hand it to `handler`. The single
 * place a request's session is fetched for a branch on present/absent — callers
 * decide what each case means (fail, redirect, render). */
export const withOptionalSession = async (
  request: Request,
  handler: ResponseHandler<[session: AuthSession | null]>,
): Promise<Response> => handler(await getAuthenticatedSession(request));

/** Require any authenticated user (owner, manager or agent). Used for pages
 * that staff and delivery agents alike must reach, like the deliveries run
 * sheet — agents are sent here, staff opt in via the Calendar submenu. Every
 * valid session already holds one of the three roles, so authentication is the
 * only gate. */
const requireAnyUserOr = (
  request: Request,
  handler: AuthSessionHandler,
): Promise<Response> =>
  withOptionalSession(request, (session) =>
    session ? handler(session) : authFailure("html", "not-authenticated"),
  );

/** Any-authenticated-user GET page: authenticate, apply flash, render HTML */
export const anyUserPage = authPage(requireAnyUserOr);

/**
 * Safe HTTP methods (RFC 7231 §4.2.1): read-only, so a request using one cannot
 * mutate state and carries no body. Such requests need no CSRF token — CSRF
 * defends state-changing submissions, and cross-origin reads of the response are
 * already blocked by the Same-Origin Policy. This lets cookie-authenticated
 * calendar clients fetch GET /caldav/events.ics without an x-csrf-token header
 * they have no way to attach.
 */
const isSafeMethod = (request: Request): boolean =>
  request.method === "GET" || request.method === "HEAD";

/** Parse JSON body, returning empty object for non-JSON GET/HEAD requests */
const parseJsonBody: JsonBodyReader = async (request) => {
  const contentType = lowerContentType(request);
  const bodyRequired = !isSafeMethod(request);

  if (!contentType.includes("application/json")) {
    if (bodyRequired) {
      return apiErrorResponse("Invalid request body");
    }
    return {};
  }
  const body = await readJsonBody(request);
  if (!body.ok) {
    logError({
      code: ErrorCode.VALIDATION_FORM,
      detail: "Malformed JSON body",
    });
    return apiErrorResponse("Invalid request body");
  }
  const parsed = body.value;
  if (!isRecord(parsed)) {
    return apiErrorResponse("Invalid request body");
  }
  return parsed;
};

/** Verify a CSRF token, returning a channel-appropriate failure or null */
const verifyCsrf = async (
  token: string,
  channel: AuthChannel,
  maxAge?: number,
): Promise<Response | null> => {
  if (await verifySignedCsrfToken(token, maxAge)) return null;
  if (channel === "json") {
    logError({ code: ErrorCode.AUTH_CSRF_MISMATCH, detail: "JSON API" });
  }
  return authFailure(channel, "invalid-csrf");
};

/** Validate CSRF and parse body for the given mode.
 *
 * `skipCsrf` only applies to JSON bodies (used for API key auth). Form and
 * multipart bodies are always CSRF-checked because API key clients use JSON.
 * Safe-method (GET/HEAD) JSON requests also skip CSRF: they cannot mutate state,
 * so the token is moot — this keeps read-only JSON routes (e.g. the calendar
 * feed) reachable by cookie sessions that can't send an x-csrf-token header.
 * `maxAge` overrides the CSRF token expiry window (seconds). */
const parseCsrfBody = async (
  request: Request,
  mode: BodyMode,
  skipCsrf: boolean,
  maxAge?: number,
): Promise<FormParams | FormData | Record<string, unknown> | Response> => {
  const channel = channelFor(mode);
  if (mode === "json") {
    if (!skipCsrf && !isSafeMethod(request)) {
      const err = await verifyCsrf(
        request.headers.get("x-csrf-token") ?? "",
        channel,
        maxAge,
      );
      if (err) return err;
    }
    return parseJsonBody(request);
  }
  if (mode === "form") {
    const form = await parseFormData(request);
    // Capture the submission so a later validation-failure redirect can stash
    // it for re-filling, mirroring requireCsrfForm for public forms.
    setSavedFormData(form);
    const err = await verifyCsrf(form.getString("csrf_token"), channel, maxAge);
    return err ?? form;
  }
  const fd = await request.formData();
  const err = await verifyCsrf(
    String(fd.get("csrf_token") ?? "").trim(),
    channel,
    maxAge,
  );
  return err ?? fd;
};

/** Derive the auth channel (html vs json) from the body mode */
const channelFor = (mode: BodyMode): AuthChannel =>
  mode === "json" ? "json" : "html";

export const authenticateFor = async <T extends BodyMode>(
  request: Request,
  policy: AuthPolicy<T>,
): Promise<{ session: AuthSession; authKind: AuthKind } | Response> => {
  const channel = channelFor(policy.body);
  let auth: { session: AuthSession; authKind: AuthKind } | Response | null =
    null;
  if (policy.allowApiKey) {
    const session = await getAuthenticatedApiKey(request);
    if (session) auth = { authKind: "apiKey", session };
    else if (getBearerToken(request)) {
      auth = authFailure(channel, "invalid-api-key");
    }
  }
  if (auth === null) {
    const session = await cookieSessionOrFailure(request, channel);
    auth = isResponse(session) ? session : { authKind: "cookie", session };
  }
  if (isResponse(auth)) return auth;
  return sessionRoleAllowed(auth.session.adminLevel, policy.role, policy.roles)
    ? auth
    : authFailure(
        channel,
        "forbidden",
        ownerOnlyAudience({ role: policy.role, roles: policy.roles })
          ? "owner-only"
          : undefined,
      );
};

/** Unified auth pipeline: authenticate, enforce role, validate CSRF, parse body. */
export async function withAuth<T extends BodyMode>(
  request: Request,
  policy: AuthPolicy<T>,
  handler: ResponseHandler<
    [session: AuthSession, body: ParsedBody<T>, authKind: AuthKind]
  >,
): Promise<Response> {
  const auth = await authenticateFor(request, policy);
  if (isResponse(auth)) return auth;
  const body = await parseCsrfBody(
    request,
    policy.body,
    auth.authKind === "apiKey",
    policy.csrfMaxAge,
  );
  if (isResponse(body)) return body;
  return handler(auth.session, body as ParsedBody<T>, auth.authKind);
}

/** A curried POST route: authenticate under `policy`, then hand the session and
 * parsed body to `handle`. The shared building block for the per-gate route
 * wrappers (multipart listing saves, Site-tab content saves, and similar), so
 * the authenticate-then-delegate shape lives in one place. */
export const gatedPost =
  <T extends BodyMode>(policy: AuthPolicy<T>) =>
  (handle: ResponseHandler<[session: AuthSession, body: ParsedBody<T>]>) =>
  (request: Request): Promise<Response> =>
    withAuth(request, policy, handle);

/** A content-editor POST whose body may include uploaded files. */
export const contentMultipartRoute: (
  handle: ResponseHandler<[session: AuthSession, body: FormData]>,
) => RequestRoute = gatedPost(CONTENT_MULTIPART);

/** A {@link gatedPost} whose handler only needs the parsed form — for POST
 * routes that act on the form alone and never read the session. */
export const formPost =
  (policy: AuthPolicy<"form">) =>
  (handle: ResponseHandler<[form: FormParams]>): RequestRoute =>
    gatedPost(policy)((_session, form) => handle(form));