refactor(auth): move the request guards out of api-handler into lib/auth/guards

api-handler.ts held two modules: the route wrapper and thirteen guards. The
guards now live in lib/auth/guards/, one module per family (the caller,
the acting-account carrier, the record modes, the cookie-only helpers, the
fresh-factor and recent-proof gates, the Bearer elevation, MFA management).
api-handler.ts keeps the wrapper and re-exports every guard, so it stays the
one public entry and no route changes its import.

The bodies move verbatim; only the version prefixes of moved comments are
dropped. The structural guards that read the auth layer now find the module
that declares a guard by name (helpers/auth-guards.ts) instead of a fixed
path, and a new guard holds the entry: nothing outside the folder but
api-handler.ts imports a guard module.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 10:54:23 +02:00
1 parent 9de64cae3e
commit ee1561496b
40 files changed
+2153 -1668

No files matched your search

@@ -1,7 +1,7 @@
/**
* Structural guard: the three read routes that used to write must stay reads.
*
* `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` —
* `src/lib/auth/guards/context.ts` states the invariant next to `READ_HTTP_METHODS` —
* an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface
* because those methods are assumed side-effect-free, and adding a
* side-effecting GET silently widens that token's reach. These three routes
@@ -11,6 +11,8 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { guardSourceOf } from "@/__tests__/helpers/auth-guards";
const SRC = join(process.cwd(), "src");
const read = (rel: string) => readFileSync(join(SRC, rel), "utf8");
@@ -94,18 +96,15 @@ describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => {
});
it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => {
const handler = read("lib/api-handler.ts");
// Both resolve via getSession() (cookie-only) — restated as a tripwire so a
// future edit that threads a Bearer into either trips this test.
const requireAdmin = handler.slice(
handler.indexOf("export async function requireAdmin"),
handler.indexOf("export async function requireAdmin") + 400,
);
expect(requireAdmin).toMatch(/getSession\(\)/);
const requireFresh = handler.slice(
handler.indexOf("export async function requireFreshMfa"),
handler.indexOf("export async function requireFreshMfa") + 400,
);
expect(requireFresh).toMatch(/getSession\(\)/);
// future edit that threads a Bearer into either trips this test. Read from
// the guard module that declares each, which throws if it is gone.
const opening = (name: string) => {
const src = guardSourceOf(name, [`export async function ${name}(`]);
const start = src.indexOf(`export async function ${name}(`);
return src.slice(start, start + 600);
};
expect(opening("requireAdmin")).toMatch(/getSession\(\)/);
expect(opening("requireFreshMfa")).toMatch(/getSession\(\)/);
});
});
@@ -2,7 +2,7 @@
* `GET /api/gamification/achievements` is a read.
*
* It used to INSERT a `UserAchievement` row for every unlock it noticed,
* which is the side-effecting GET the MCP-audience note in `api-handler.ts`
* which is the side-effecting GET the MCP-audience note in `lib/auth/guards/context.ts`
* warns against. The evaluation still runs here; only the persistence moved
* to the `achievement-unlock-sweep` job.
*
@@ -97,7 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
// v1.35.3 — the unlock rows are NOT written here. A GET must not have side
// effects (`api-handler.ts`, the MCP-audience note above
// effects (`lib/auth/guards/context.ts`, the MCP-audience note above
// `READ_HTTP_METHODS`), and this INSERT was one. The badge grid does not
// need the rows: `unlocked` comes from the live metrics and the completion
// date is derived from the account's own history, so the payload below is
@@ -2,7 +2,7 @@
* `GET /api/sync/state` is a read.
*
* It used to bump `User.lastSyncedAt` on every call, which is the exact
* side-effecting GET the MCP-audience note in `api-handler.ts` warns against,
* side-effecting GET the MCP-audience note in `lib/auth/guards/context.ts` warns against,
* and it advanced the checkpoint past a window the client had not drained.
* The checkpoint is now owned by the ingest boundaries; this route reports it.
*/