mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(02-12.1): decide record-session context in one place
- add an import-free contract module both bundles can name, holding the header names, the parse and the verdict - add the server fence: header read, wide-event stamp, and the refusal - add RecordSessionChangedError as a third sibling on the existing sharing-error arm, so the 409 needs no new serialisation branch
This commit is contained in:
1 parent
5c9befc0ae
commit
59359738c8
7 files changed
+895
No files matched your search
@@ -94,6 +94,20 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
// whole auth context for exactly this reason: had it taken the id, the
|
||||
// route would name the column too, and the list would grow per surface.
|
||||
"lib/sharing/account-access.ts",
|
||||
// v1.37.0 — the record-session fence COMPARES it and never writes it. It
|
||||
// reads the value off the `AuthContext` the resolver already holds rather
|
||||
// than from the row, which is the property the fence rests on: the scope
|
||||
// the handler serves under and the epoch the fence validated are one row
|
||||
// read, so they cannot straddle a switch. A version of this file that
|
||||
// resolved its own session would be a second answer to "whose record is
|
||||
// this" and would also be wrong more often.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
// v1.37.0 — names the column in prose only. The matcher does not exempt
|
||||
// comments and is left that way on purpose (see the note above), so the
|
||||
// module that explains the fence has to be listed. It imports nothing at
|
||||
// all, which the fence's own unit test asserts, so it can read no column
|
||||
// even in principle.
|
||||
"lib/sharing/record-session-fence-contract.ts",
|
||||
].sort();
|
||||
|
||||
it("no other file reads or writes the carrier column", () => {
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
type GrantNeed,
|
||||
} from "./sharing/grants";
|
||||
import type { ShareScope } from "./sharing/scope";
|
||||
import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract";
|
||||
import { assertRecordSessionFence } from "./sharing/record-session-fence";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
@@ -139,6 +141,38 @@ export class SharingNotPermittedError extends SharingAuthError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — "the record this session is on is not the record you asserted."
|
||||
*
|
||||
* A third sibling rather than a branch, so it rides the existing
|
||||
* `SharingAuthError` arm below with no new serialisation code. The status is
|
||||
* the one thing that differs and it differs on purpose: 409, not 403, because
|
||||
* this is not a refusal of access. The caller may well be entitled to both
|
||||
* records. What failed is agreement about WHICH one, and the instruction the
|
||||
* code carries is "reconcile through `/api/auth/me` and try again", not "you
|
||||
* have lost this record".
|
||||
*
|
||||
* That distinction is load-bearing for deploy compatibility: the currently
|
||||
* shipped bundle reacts to `sharing.access.denied` by leaving the record and
|
||||
* hard-navigating, which is exactly the wrong response to a transient
|
||||
* disagreement and exactly the right one for a bundle too old to reconcile. See
|
||||
* `src/lib/sharing/record-session-fence-contract.ts` for why the fence hands
|
||||
* each bundle the code it can act on.
|
||||
*
|
||||
* The body carries `meta.errorCode` and nothing else — no current epoch, no
|
||||
* current scope. A client may learn its context from exactly two responses
|
||||
* (`GET /api/auth/me` and `POST /api/account/switch`), and putting the truth in
|
||||
* this refusal would open a third path, one that arrives on the very requests
|
||||
* whose context is in doubt.
|
||||
*/
|
||||
export class RecordSessionChangedError extends SharingAuthError {
|
||||
readonly errorCode = RECORD_FENCE_ERROR_CODE;
|
||||
constructor() {
|
||||
super(409, "The record this session is on has changed");
|
||||
this.name = "RecordSessionChangedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an API route handler with Wide Event logging.
|
||||
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
|
||||
@@ -393,6 +427,16 @@ export type AuthContext = {
|
||||
* below reads it.
|
||||
*/
|
||||
readonly actingAsUserId?: string | null;
|
||||
/**
|
||||
* v1.37.0 — how many times this session's record selector has moved. The
|
||||
* record-session fence compares it against what the request asserted.
|
||||
*
|
||||
* Optional for the same reason as `actingAsUserId` above, and `undefined`
|
||||
* reads as `0` — which is the fence's exemption, so a hand-built test
|
||||
* context keeps meaning "a session that never switched". Read only by the
|
||||
* fence.
|
||||
*/
|
||||
readonly recordEpoch?: number;
|
||||
};
|
||||
user: User;
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,25 @@ export class WideEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — record the record context this request resolved.
|
||||
*
|
||||
* Merges for the same reason `setActingAs` does. Written by exactly one
|
||||
* caller, the record-session fence, and read back by `apiHandler` to attach
|
||||
* the response echo. `apiHandler` deliberately does NOT derive the context
|
||||
* itself: doing so would put a session read on every public route and make
|
||||
* the echo a second derivation rather than a report of the one that was used.
|
||||
*/
|
||||
setRecordContext(context: { epoch: number; scope: string | null }): this {
|
||||
this.event.auth = { ...(this.event.auth ?? {}), record_context: context };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The record context the fence stamped, or undefined if it never ran. */
|
||||
getRecordContext(): { epoch: number; scope: string | null } | undefined {
|
||||
return this.event.auth?.record_context;
|
||||
}
|
||||
|
||||
setProviderWorkAuthority(authority: ProviderWorkAuthority): this {
|
||||
this.event.auth = {
|
||||
...(this.event.auth ?? {}),
|
||||
|
||||
@@ -80,6 +80,18 @@ export interface WideEvent {
|
||||
delegated_generation?: "suppressed";
|
||||
/** Durable authority copied into provider queue payloads. */
|
||||
provider_work_authority?: ProviderWorkAuthority;
|
||||
/**
|
||||
* v1.37.0 — the record context this request was actually served under, as
|
||||
* the record-session fence read it off the session row.
|
||||
*
|
||||
* Stamped by the fence on every call it makes, pass or refuse, and by
|
||||
* nothing else. `apiHandler` copies it onto two response headers so the
|
||||
* browser can discard a response that resolved a different record than the
|
||||
* one it is now showing. Present exactly on the responses that resolved a
|
||||
* record scope; absent everywhere else, which is what makes "no echo" mean
|
||||
* "nothing about a record was decided here" rather than "unknown".
|
||||
*/
|
||||
record_context?: { epoch: number; scope: string | null };
|
||||
};
|
||||
|
||||
// Business-Daten (was wurde getan)
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* The record-session fence: every arm of the verdict, and the refusals it
|
||||
* raises.
|
||||
*
|
||||
* The verdict is a pure function, so this file can be exhaustive about it in a
|
||||
* way the integration proofs cannot. It also holds two structural assertions
|
||||
* that belong nowhere else:
|
||||
*
|
||||
* * the contract module has no `import` statement at all — the property that
|
||||
* lets the browser bundle name the same header constants without dragging
|
||||
* `next/headers` or Prisma in behind them. It typechecks either way, so
|
||||
* only a check like this one or a full `pnpm build` catches a regression;
|
||||
* * the asserted-scope length bound equals `MAX_SELECTOR_LENGTH`. The
|
||||
* contract module deliberately does not import that constant (importing
|
||||
* `acting-carrier.ts` is the exact poisoning above), so nothing but an
|
||||
* assertion keeps the two numbers together.
|
||||
*
|
||||
* Break this file by changing the `unfenced-client` arm of
|
||||
* `recordSessionFenceVerdict` to return `"pass"`: every absent-header case on a
|
||||
* fenced session fails.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { MAX_SELECTOR_LENGTH } from "@/lib/auth/acting-carrier";
|
||||
import {
|
||||
MAX_ASSERTED_SCOPE_LENGTH,
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_FENCE_BOOTSTRAP,
|
||||
RECORD_FENCE_ERROR_CODE,
|
||||
RECORD_SCOPE_HEADER,
|
||||
RECORD_SCOPE_SELF,
|
||||
parseAssertedContext,
|
||||
recordScopeHeaderValue,
|
||||
recordSessionFenceVerdict,
|
||||
type AssertedRecordContext,
|
||||
type RecordFenceVerdict,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
|
||||
const headerJar = new Map<string, string>();
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
}));
|
||||
|
||||
const OWNER = "owner-account-id";
|
||||
const OTHER = "other-account-id";
|
||||
|
||||
function asserted(epoch: number, scope: string | null): AssertedRecordContext {
|
||||
return { kind: "asserted", epoch, scope };
|
||||
}
|
||||
|
||||
describe("recordSessionFenceVerdict", () => {
|
||||
type Case = {
|
||||
name: string;
|
||||
input: Parameters<typeof recordSessionFenceVerdict>[0];
|
||||
want: RecordFenceVerdict;
|
||||
};
|
||||
|
||||
const CASES: Case[] = [
|
||||
{
|
||||
name: "bearer passes with no assertion at all",
|
||||
input: {
|
||||
transport: "bearer",
|
||||
sessionEpoch: 0,
|
||||
sessionScope: null,
|
||||
asserted: { kind: "absent" },
|
||||
},
|
||||
want: "pass",
|
||||
},
|
||||
{
|
||||
name: "bearer passes even against a nonsense assertion",
|
||||
input: {
|
||||
transport: "bearer",
|
||||
sessionEpoch: 7,
|
||||
sessionScope: OWNER,
|
||||
asserted: asserted(1, OTHER),
|
||||
},
|
||||
want: "pass",
|
||||
},
|
||||
{
|
||||
name: "a session that never switched passes with no header",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 0,
|
||||
sessionScope: null,
|
||||
asserted: { kind: "absent" },
|
||||
},
|
||||
want: "pass",
|
||||
},
|
||||
{
|
||||
name: "a session that never switched passes on the bootstrap sentinel",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 0,
|
||||
sessionScope: null,
|
||||
asserted: { kind: "unparseable" },
|
||||
},
|
||||
want: "pass",
|
||||
},
|
||||
{
|
||||
name: "a fenced session with a matching epoch and scope passes",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 3,
|
||||
sessionScope: OWNER,
|
||||
asserted: asserted(3, OWNER),
|
||||
},
|
||||
want: "pass",
|
||||
},
|
||||
{
|
||||
name: "a fenced session back on its own record passes on a matching self assertion",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 4,
|
||||
sessionScope: null,
|
||||
asserted: asserted(4, null),
|
||||
},
|
||||
want: "pass",
|
||||
},
|
||||
{
|
||||
name: "a stale epoch is stale",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 3,
|
||||
sessionScope: OWNER,
|
||||
asserted: asserted(2, OWNER),
|
||||
},
|
||||
want: "stale",
|
||||
},
|
||||
{
|
||||
name: "an epoch from the future is stale",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 3,
|
||||
sessionScope: OWNER,
|
||||
asserted: asserted(4, OWNER),
|
||||
},
|
||||
want: "stale",
|
||||
},
|
||||
{
|
||||
name: "a matching epoch with a mismatched scope is stale",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 3,
|
||||
sessionScope: OWNER,
|
||||
asserted: asserted(3, OTHER),
|
||||
},
|
||||
want: "stale",
|
||||
},
|
||||
{
|
||||
name: "asserting a record while the session is on its own is stale",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 5,
|
||||
sessionScope: null,
|
||||
asserted: asserted(5, OWNER),
|
||||
},
|
||||
want: "stale",
|
||||
},
|
||||
{
|
||||
name: "asserting self while the session is inside a record is stale",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 5,
|
||||
sessionScope: OWNER,
|
||||
asserted: asserted(5, null),
|
||||
},
|
||||
want: "stale",
|
||||
},
|
||||
{
|
||||
name: "an unparseable assertion on a fenced session is stale, not unfenced",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 1,
|
||||
sessionScope: OWNER,
|
||||
asserted: { kind: "unparseable" },
|
||||
},
|
||||
want: "stale",
|
||||
},
|
||||
{
|
||||
name: "an absent assertion on a fenced session is an unfenced client",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 1,
|
||||
sessionScope: OWNER,
|
||||
asserted: { kind: "absent" },
|
||||
},
|
||||
want: "unfenced-client",
|
||||
},
|
||||
{
|
||||
name: "a session that has LEFT a record stays fenced",
|
||||
input: {
|
||||
transport: "cookie",
|
||||
sessionEpoch: 2,
|
||||
sessionScope: null,
|
||||
asserted: { kind: "absent" },
|
||||
},
|
||||
want: "unfenced-client",
|
||||
},
|
||||
];
|
||||
|
||||
it("covers a non-zero number of cases", () => {
|
||||
// A table-driven test whose table emptied would report success.
|
||||
expect(CASES.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
for (const c of CASES) {
|
||||
it(c.name, () => {
|
||||
expect(recordSessionFenceVerdict(c.input)).toBe(c.want);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps the exemption's second clause honest", () => {
|
||||
// Migration 0302 makes `scope !== null` imply `epoch >= 1`, so the second
|
||||
// conjunct of the exemption is redundant against real data. It is kept
|
||||
// because a redundant clause over an authorization boundary is the right
|
||||
// direction to be wrong in — and this asserts it actually does something
|
||||
// if the data ever disagrees with the migration.
|
||||
expect(
|
||||
recordSessionFenceVerdict({
|
||||
transport: "cookie",
|
||||
sessionEpoch: 0,
|
||||
sessionScope: OWNER,
|
||||
asserted: { kind: "absent" },
|
||||
}),
|
||||
).toBe("unfenced-client");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAssertedContext", () => {
|
||||
it("reads no headers as absent", () => {
|
||||
expect(parseAssertedContext(null, null)).toEqual({ kind: "absent" });
|
||||
});
|
||||
|
||||
it("reads half an assertion as unparseable, never as absent", () => {
|
||||
// Half an assertion comes from a client that knows about the fence, so it
|
||||
// must take the reconcile arm and not the leave-the-record arm.
|
||||
expect(parseAssertedContext("3", null)).toEqual({ kind: "unparseable" });
|
||||
expect(parseAssertedContext(null, RECORD_SCOPE_SELF)).toEqual({
|
||||
kind: "unparseable",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the bootstrap sentinel as unparseable", () => {
|
||||
expect(
|
||||
parseAssertedContext(RECORD_FENCE_BOOTSTRAP, RECORD_FENCE_BOOTSTRAP),
|
||||
).toEqual({ kind: "unparseable" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["not-a-number", "nonsense"],
|
||||
["-1", "negative"],
|
||||
["3.5", "fractional"],
|
||||
["1e3", "exponential"],
|
||||
[" 3", "padded"],
|
||||
["", "empty"],
|
||||
["9".repeat(40), "over-long"],
|
||||
])("reads %s as unparseable (%s)", (value) => {
|
||||
expect(parseAssertedContext(value, RECORD_SCOPE_SELF)).toEqual({
|
||||
kind: "unparseable",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads an over-long scope as unparseable before comparing it", () => {
|
||||
expect(
|
||||
parseAssertedContext("3", "x".repeat(MAX_ASSERTED_SCOPE_LENGTH + 1)),
|
||||
).toEqual({ kind: "unparseable" });
|
||||
});
|
||||
|
||||
it("reads an empty scope as unparseable", () => {
|
||||
expect(parseAssertedContext("3", "")).toEqual({ kind: "unparseable" });
|
||||
});
|
||||
|
||||
it("reads the self sentinel as the null scope", () => {
|
||||
expect(parseAssertedContext("0", RECORD_SCOPE_SELF)).toEqual({
|
||||
kind: "asserted",
|
||||
epoch: 0,
|
||||
scope: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads an account id as itself", () => {
|
||||
expect(parseAssertedContext("12", OWNER)).toEqual({
|
||||
kind: "asserted",
|
||||
epoch: 12,
|
||||
scope: OWNER,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a scope through the header value helper", () => {
|
||||
for (const scope of [null, OWNER]) {
|
||||
expect(parseAssertedContext("1", recordScopeHeaderValue(scope))).toEqual({
|
||||
kind: "asserted",
|
||||
epoch: 1,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the contract module is safe in the browser bundle", () => {
|
||||
const CONTRACT = join(
|
||||
process.cwd(),
|
||||
"src/lib/sharing/record-session-fence-contract.ts",
|
||||
);
|
||||
|
||||
it("has no import statement at all", () => {
|
||||
const src = readFileSync(CONTRACT, "utf8");
|
||||
// Non-zero proof first: if the file were empty or misspelled, an
|
||||
// "it contains no imports" assertion would pass for the wrong reason.
|
||||
expect(src.length).toBeGreaterThan(1000);
|
||||
expect(src).toContain("RECORD_EPOCH_HEADER");
|
||||
expect(src).not.toMatch(/^\s*import[\s{*]/m);
|
||||
expect(src).not.toMatch(/\bfrom\s+["']/);
|
||||
expect(src).not.toMatch(/\brequire\s*\(/);
|
||||
});
|
||||
|
||||
it("bounds an asserted scope at the same length as an account selector", () => {
|
||||
expect(MAX_ASSERTED_SCOPE_LENGTH).toBe(MAX_SELECTOR_LENGTH);
|
||||
});
|
||||
|
||||
it("names the two headers on the cookie transport's own namespace", () => {
|
||||
expect(RECORD_EPOCH_HEADER).toBe("x-healthlog-record-epoch");
|
||||
expect(RECORD_SCOPE_HEADER).toBe("x-healthlog-record-scope");
|
||||
expect(RECORD_FENCE_ERROR_CODE).toBe("sharing.session.changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertRecordSessionFence", () => {
|
||||
beforeEach(() => {
|
||||
headerJar.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function run(input: {
|
||||
authMethod: "cookie" | "bearer";
|
||||
recordEpoch?: number;
|
||||
actingAsUserId?: string | null;
|
||||
}): Promise<{ error: unknown; stamped: unknown }> {
|
||||
const { assertRecordSessionFence } =
|
||||
await import("@/lib/sharing/record-session-fence");
|
||||
let stamped: unknown;
|
||||
const { eventStorage } = await import("@/lib/logging/context");
|
||||
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
|
||||
const evt = new WideEventBuilder();
|
||||
let error: unknown = null;
|
||||
await eventStorage.run(evt, async () => {
|
||||
try {
|
||||
await assertRecordSessionFence({
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
actingAsUserId: input.actingAsUserId ?? null,
|
||||
recordEpoch: input.recordEpoch,
|
||||
},
|
||||
user: { id: "delegate" } as never,
|
||||
authMethod: input.authMethod,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
stamped = evt.getRecordContext();
|
||||
});
|
||||
return { error, stamped };
|
||||
}
|
||||
|
||||
it("serves a never-switched cookie session and stamps its context", async () => {
|
||||
const { error, stamped } = await run({ authMethod: "cookie" });
|
||||
expect(error).toBeNull();
|
||||
expect(stamped).toEqual({ epoch: 0, scope: null });
|
||||
});
|
||||
|
||||
it("serves a matching assertion and stamps the context it served under", async () => {
|
||||
headerJar.set(RECORD_EPOCH_HEADER, "4");
|
||||
headerJar.set(RECORD_SCOPE_HEADER, OWNER);
|
||||
const { error, stamped } = await run({
|
||||
authMethod: "cookie",
|
||||
recordEpoch: 4,
|
||||
actingAsUserId: OWNER,
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(stamped).toEqual({ epoch: 4, scope: OWNER });
|
||||
});
|
||||
|
||||
it("throws the 409 on a stale assertion, and still stamps the truth", async () => {
|
||||
headerJar.set(RECORD_EPOCH_HEADER, "3");
|
||||
headerJar.set(RECORD_SCOPE_HEADER, OWNER);
|
||||
const { error, stamped } = await run({
|
||||
authMethod: "cookie",
|
||||
recordEpoch: 4,
|
||||
actingAsUserId: OWNER,
|
||||
});
|
||||
expect((error as { statusCode?: number }).statusCode).toBe(409);
|
||||
expect((error as { errorCode?: string }).errorCode).toBe(
|
||||
RECORD_FENCE_ERROR_CODE,
|
||||
);
|
||||
// The refusal echoes the real context too, so a client that reconciles on
|
||||
// the discard path and one that reconciles on the 409 path see the same
|
||||
// thing on the wire.
|
||||
expect(stamped).toEqual({ epoch: 4, scope: OWNER });
|
||||
});
|
||||
|
||||
it("throws the 403 a pre-fence bundle recovers from when no header arrives", async () => {
|
||||
const { error } = await run({
|
||||
authMethod: "cookie",
|
||||
recordEpoch: 1,
|
||||
actingAsUserId: OWNER,
|
||||
});
|
||||
expect((error as { statusCode?: number }).statusCode).toBe(403);
|
||||
expect((error as { errorCode?: string }).errorCode).toBe(
|
||||
"sharing.access.denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the Bearer transport alone and echoes nothing on it", async () => {
|
||||
headerJar.set(RECORD_EPOCH_HEADER, "1");
|
||||
headerJar.set(RECORD_SCOPE_HEADER, OTHER);
|
||||
const { error, stamped } = await run({
|
||||
authMethod: "bearer",
|
||||
recordEpoch: 9,
|
||||
actingAsUserId: OWNER,
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(stamped).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats an absent recordEpoch as zero, so a hand-built context is unfenced", async () => {
|
||||
const { error } = await run({
|
||||
authMethod: "cookie",
|
||||
recordEpoch: undefined,
|
||||
actingAsUserId: null,
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* v1.37.0 — what record a request believes it is addressing, as a contract both
|
||||
* bundles can name.
|
||||
*
|
||||
* The problem is agreement, not authorization. `acting_as_user_id` plus the
|
||||
* grant join already decides whether a caller MAY read a record; nothing here
|
||||
* touches that. What this decides is whether the record the server is about to
|
||||
* serve is the record the client formed its intent against. A browser holds
|
||||
* several tabs on one session; a request that left a suspended tab before a
|
||||
* switch landed arrives after it, perfectly authorized and asking the wrong
|
||||
* question. So every cookie request that resolves a record asserts the context
|
||||
* it believes it is in, and the server refuses when the belief no longer
|
||||
* matches the session row.
|
||||
*
|
||||
* ─── Why this module has no imports ───────────────────────────────────────
|
||||
*
|
||||
* The client has to name the header constants, and both obvious homes poison
|
||||
* the client bundle: `src/lib/api-handler.ts` imports Prisma at module scope
|
||||
* and `src/lib/auth/acting-carrier.ts` imports `next/headers`. Either import
|
||||
* would typecheck cleanly and fail `pnpm build`. So the constants, the parse
|
||||
* and the verdict live here with ZERO imports — asserted by the unit test, not
|
||||
* by convention — and the server half lives next door in
|
||||
* `record-session-fence.ts`.
|
||||
*
|
||||
* ─── What the fence does NOT cover ────────────────────────────────────────
|
||||
*
|
||||
* Only the two record resolvers (`requireRecordAuth`, `requireGuardianAuth`)
|
||||
* and the idempotency wrapper consult it. `requireActorAuth`, bare
|
||||
* `requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa`,
|
||||
* `GET /api/auth/me` and `POST /api/account/switch` deliberately do not, and a
|
||||
* guard test freezes that list:
|
||||
*
|
||||
* * `requireActorAuth` is the way OUT of a switch. Fencing it would make an
|
||||
* unprovable context unrecoverable. It serves the caller's own rows by
|
||||
* definition, so the selector cannot change what it returns.
|
||||
* * bare `requireAuth` already refuses outright under an active switch.
|
||||
* * `/api/auth/me` is the reconciliation bootstrap and has to answer while
|
||||
* the client's context is unknown — it is where the client LEARNS the
|
||||
* context it would otherwise have to assert.
|
||||
*
|
||||
* That list is about the auth HELPERS, not about routes, and it is not
|
||||
* absolute: `POST /api/admin/backups/[id]/restore` composes the idempotency
|
||||
* wrapper OUTSIDE `apiHandler`, so it is fence-visible through the idempotency
|
||||
* arm even though `requireAdmin` itself is unfenced. That is wanted. An
|
||||
* idempotent admin write under an unprovable context should be refused like any
|
||||
* other.
|
||||
*
|
||||
* ─── Why there are two refusals and not one ───────────────────────────────
|
||||
*
|
||||
* A stale assertion gets 409 `sharing.session.changed`. An ABSENT assertion on
|
||||
* a fenced session gets 403 `sharing.access.denied` instead, and the difference
|
||||
* is a deploy-compatibility decision rather than a taxonomy:
|
||||
*
|
||||
* Header-absent means exactly one thing on the wire — a bundle that predates
|
||||
* the fence. A fence-aware client attaches the headers to every same-origin
|
||||
* request including the ones issued before `/api/auth/me` has resolved, using
|
||||
* the `bootstrap` sentinel, so it can never reach the absent arm. And a
|
||||
* pre-fence bundle cannot act on a 409: `src/lib/api/api-fetch.ts` special-cases
|
||||
* no status at all, so the tab would render an error card and stay broken until
|
||||
* a human reloaded. It CAN act on 403 `sharing.access.denied`, because the
|
||||
* deployed bundle already mounts `SharedRecordGrantLossBridge`
|
||||
* (`src/components/providers.tsx`), which on the first errored query carrying
|
||||
* that code leaves the record and hard-navigates to `/` via `landOnRecord` in
|
||||
* `src/hooks/use-account-switch.ts` — a full document navigation, which serves
|
||||
* the fence-aware bundle. One reload, self-healing, no new code in the old
|
||||
* bundle.
|
||||
*
|
||||
* So do not "simplify" the two arms into one. The 409 is a *reconcile, do not
|
||||
* leave* instruction that only a fence-aware bundle can follow; handing it to a
|
||||
* bundle that cannot follow it is the cliff this split exists to avoid.
|
||||
*/
|
||||
|
||||
/** The asserted epoch. Cookie transport only; Bearer neither sends nor honours it. */
|
||||
export const RECORD_EPOCH_HEADER = "x-healthlog-record-epoch";
|
||||
|
||||
/** The asserted canonical scope. Cookie transport only. */
|
||||
export const RECORD_SCOPE_HEADER = "x-healthlog-record-scope";
|
||||
|
||||
/**
|
||||
* The scope value naming the caller's own record.
|
||||
*
|
||||
* A sentinel rather than an empty header, so "I assert I am on my own record"
|
||||
* and "I sent no scope at all" are different bytes on the wire. The fence's two
|
||||
* refusal arms turn on exactly that distinction.
|
||||
*/
|
||||
export const RECORD_SCOPE_SELF = "self";
|
||||
|
||||
/**
|
||||
* What a fence-aware client asserts before `/api/auth/me` has told it anything.
|
||||
*
|
||||
* It is a PRESENT header, which is the point: it can never be mistaken for a
|
||||
* pre-fence bundle, so it takes the 409 (reconcile) arm and not the 403 (leave
|
||||
* the record) arm. On a session that never entered a shared record the
|
||||
* exemption below serves it normally, so an ordinary sign-in never sees a 409
|
||||
* during boot.
|
||||
*/
|
||||
export const RECORD_FENCE_BOOTSTRAP = "bootstrap";
|
||||
|
||||
/** The stable code on the 409. The client branches on this, never on prose. */
|
||||
export const RECORD_FENCE_ERROR_CODE = "sharing.session.changed";
|
||||
|
||||
/**
|
||||
* Longest asserted scope worth comparing.
|
||||
*
|
||||
* Same posture and the same number as `MAX_SELECTOR_LENGTH` in
|
||||
* `src/lib/auth/acting-carrier.ts` — a bound applied before any parse, so an
|
||||
* over-long value is refused as such rather than measured. Deliberately NOT
|
||||
* imported from there: that module pulls `next/headers`, and this one is in the
|
||||
* client bundle. The unit test asserts the two numbers are equal so they cannot
|
||||
* drift apart unnoticed.
|
||||
*/
|
||||
export const MAX_ASSERTED_SCOPE_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Longest asserted epoch worth parsing. A session's selector moves a handful of
|
||||
* times a day at most; fifteen digits is far past any number this counter can
|
||||
* reach and short enough that nothing large is ever coerced.
|
||||
*/
|
||||
const MAX_ASSERTED_EPOCH_LENGTH = 15;
|
||||
|
||||
/**
|
||||
* What the two headers said.
|
||||
*
|
||||
* `absent` and `unparseable` are separate cases because the fence answers them
|
||||
* with different status codes — see the two-refusals note above. Collapsing
|
||||
* them would hand a pre-fence bundle a status it cannot recover from.
|
||||
*/
|
||||
export type AssertedRecordContext =
|
||||
| { kind: "absent" }
|
||||
| { kind: "unparseable" }
|
||||
| { kind: "asserted"; epoch: number; scope: string | null };
|
||||
|
||||
/** What the fence decided. */
|
||||
export type RecordFenceVerdict = "pass" | "stale" | "unfenced-client";
|
||||
|
||||
/**
|
||||
* Turn the two raw header values into a closed union.
|
||||
*
|
||||
* No account-shape validation happens here and none is needed: the scope is
|
||||
* compared by string equality against the session's real scope, so a bogus
|
||||
* asserted account simply fails to match and yields `stale`. Adding a validator
|
||||
* would be a second, weaker statement of what an account id is, and the weaker
|
||||
* copy is the one that drifts.
|
||||
*/
|
||||
export function parseAssertedContext(
|
||||
epochHeader: string | null,
|
||||
scopeHeader: string | null,
|
||||
): AssertedRecordContext {
|
||||
if (epochHeader === null && scopeHeader === null) return { kind: "absent" };
|
||||
|
||||
// Half an assertion is not an assertion. A fence-aware client sends both on
|
||||
// every request, so one-of-two is a client that knows about the fence and got
|
||||
// it wrong — which is the `stale` arm, not the pre-fence arm.
|
||||
if (epochHeader === null || scopeHeader === null)
|
||||
return { kind: "unparseable" };
|
||||
|
||||
if (
|
||||
epochHeader.length > MAX_ASSERTED_EPOCH_LENGTH ||
|
||||
scopeHeader.length > MAX_ASSERTED_SCOPE_LENGTH ||
|
||||
scopeHeader.length === 0
|
||||
) {
|
||||
return { kind: "unparseable" };
|
||||
}
|
||||
|
||||
// The bootstrap sentinel and every other non-digit value land here together,
|
||||
// which is correct: both are "a fence-aware client that cannot yet prove its
|
||||
// context", and both must reconcile rather than be served.
|
||||
if (!/^\d+$/.test(epochHeader)) return { kind: "unparseable" };
|
||||
|
||||
const epoch = Number(epochHeader);
|
||||
if (!Number.isSafeInteger(epoch) || epoch < 0) return { kind: "unparseable" };
|
||||
|
||||
return {
|
||||
kind: "asserted",
|
||||
epoch,
|
||||
scope: scopeHeader === RECORD_SCOPE_SELF ? null : scopeHeader,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict itself, as a pure function of the transport, the session row and
|
||||
* what the request asserted.
|
||||
*
|
||||
* Pure on purpose, and for the reason `decideActingCarrier` is pure: it is the
|
||||
* one statement of the rule, and a rule that needs a request context to be read
|
||||
* is a rule that gets re-implemented somewhere it can drift.
|
||||
*/
|
||||
export function recordSessionFenceVerdict(input: {
|
||||
transport: "cookie" | "bearer";
|
||||
/** The session row's counter. Sessions have none on the Bearer transport. */
|
||||
sessionEpoch: number;
|
||||
/** The account the session is pointed at, or null for its own record. */
|
||||
sessionScope: string | null;
|
||||
asserted: AssertedRecordContext;
|
||||
}): RecordFenceVerdict {
|
||||
// Bearer carries its selector per request and accumulates no switch state, so
|
||||
// there is no context for a token to be stale about. The frozen native
|
||||
// contract is untouched: no header is read, required, or honoured here.
|
||||
if (input.transport === "bearer") return "pass";
|
||||
|
||||
// The exemption, written as a conjunction even though migration 0302 makes
|
||||
// the second clause redundant (it normalised every already-pointed row to 1,
|
||||
// and the trigger keeps it there). A redundant clause over an authorization
|
||||
// boundary is the right direction to be wrong in, and the redundancy is
|
||||
// asserted by the unit test rather than assumed.
|
||||
//
|
||||
// What it buys: every browser tab open at deploy time that was never inside
|
||||
// a shared record keeps working with no header at all, byte for byte. What it
|
||||
// costs: a session at epoch 0 is served even if it asserts something odd —
|
||||
// which cannot arise, because a client's adopted epoch comes from this very
|
||||
// session and a session at 0 has never moved, and which in any case fails
|
||||
// open onto the caller's OWN record.
|
||||
if (input.sessionEpoch === 0 && input.sessionScope === null) return "pass";
|
||||
|
||||
// From here the session HAS been inside somebody's record, now or before, and
|
||||
// stays fenced in both directions permanently. A stale tab believing it is
|
||||
// inside the owner's record while the session is back on its own is the same
|
||||
// defect as the reverse.
|
||||
if (input.asserted.kind === "absent") return "unfenced-client";
|
||||
if (input.asserted.kind === "unparseable") return "stale";
|
||||
if (input.asserted.epoch !== input.sessionEpoch) return "stale";
|
||||
// Belt and braces, deliberately: the epoch alone distinguishes transitions,
|
||||
// and the scope additionally catches a selector that moved to a value the
|
||||
// client did not expect. On the referential-action path both move at once.
|
||||
if (input.asserted.scope !== input.sessionScope) return "stale";
|
||||
|
||||
return "pass";
|
||||
}
|
||||
|
||||
/** The scope header value for a canonical scope. The inverse of the parse above. */
|
||||
export function recordScopeHeaderValue(scope: string | null): string {
|
||||
return scope === null ? RECORD_SCOPE_SELF : scope;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.37.0 — the server half of the record-session fence.
|
||||
*
|
||||
* The rule itself lives next door in `record-session-fence-contract.ts`, which
|
||||
* imports nothing so the browser bundle can name the same headers. This file is
|
||||
* the part that cannot be in a browser: it reads `next/headers`, it holds the
|
||||
* refusals, and it stamps the wide event that becomes the response echo.
|
||||
*
|
||||
* Three things happen on every call, and the order matters:
|
||||
*
|
||||
* 1. Read the two asserted headers.
|
||||
* 2. Stamp the context the request is ACTUALLY being served under onto the
|
||||
* request-scoped wide event — on every call, pass or refuse. `apiHandler`
|
||||
* turns that stamp into the response echo, and this is the only thing that
|
||||
* ever stamps it. The alternative that looks equivalent is not: the wide
|
||||
* event's `setActingAs` runs in exactly one place, inside
|
||||
* `resolveSwitchedRecord`, so it never fires on the carrier-none path, on
|
||||
* an actor surface, or on a public route — and a client rule of "2xx with
|
||||
* no echo means discard" would then throw away every actor and public
|
||||
* response, including the `/api/version` poll that runs on a timer.
|
||||
* 3. Refuse, or return.
|
||||
*
|
||||
* ─── The atomicity property, stated once ──────────────────────────────────
|
||||
*
|
||||
* The epoch compared here and the `actingAsUserId` the resolver is about to
|
||||
* serve under come from ONE `getSession()` row read — `findSessionByCookie`
|
||||
* loads the whole row and both fields are projected off it. So "the scope the
|
||||
* handler serves under" and "the epoch the fence validated" are the same fact
|
||||
* read at the same instant, not two reads a switch can land between. That turns
|
||||
* what would be a race window into a tautology, and it is why this function
|
||||
* takes an `AuthContext` instead of resolving a session of its own. Do not add
|
||||
* a second session read here for any reason.
|
||||
*
|
||||
* What that does NOT close, and nothing can: a request that has already passed
|
||||
* the fence when a switch commits still completes under the context it was
|
||||
* admitted with. Holding a lock across the handler's I/O would close it and was
|
||||
* examined and rejected during v1.37.0 planning — it buys a disclosure window
|
||||
* measured in milliseconds at the price of lock, pool, timeout and revocation
|
||||
* availability defects. What covers it instead is the response echo: the client
|
||||
* discards a response whose echoed context contradicts the one it is now on.
|
||||
*/
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_SCOPE_HEADER,
|
||||
parseAssertedContext,
|
||||
recordSessionFenceVerdict,
|
||||
type AssertedRecordContext,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
import {
|
||||
RecordSessionChangedError,
|
||||
SharingAccessDeniedError,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* What this request asserted about its record context.
|
||||
*
|
||||
* `try`/`catch`-wrapped the way `readSelectorHeader` is: outside a Next.js
|
||||
* request scope there is no header to read, and a direct unit invocation of a
|
||||
* legacy route must not crash on the attempt. An unreadable header store is
|
||||
* indistinguishable from a request that sent nothing, which is the honest
|
||||
* answer — and on a fenced session that answer is refused, not served.
|
||||
*/
|
||||
export async function readAssertedRecordContext(): Promise<AssertedRecordContext> {
|
||||
try {
|
||||
const headerList = await headers();
|
||||
return parseAssertedContext(
|
||||
headerList.get(RECORD_EPOCH_HEADER),
|
||||
headerList.get(RECORD_SCOPE_HEADER),
|
||||
);
|
||||
} catch {
|
||||
return { kind: "absent" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse this request if the record context it asserted is not the one its
|
||||
* session is on.
|
||||
*
|
||||
* Called by `requireRecordAuth` and `requireGuardianAuth` immediately after
|
||||
* `authenticateCaller()` and before `readActingCarrier()`, and independently by
|
||||
* `withIdempotency` above the replay cache. Two independent evaluations against
|
||||
* the same client assertion, with nothing carried between them: carrying a
|
||||
* verdict across the wrapper/handler boundary would need request-scoped mutable
|
||||
* state, which is a second source of truth for the same question. A switch
|
||||
* landing between the two evaluations fails the second, so no handler body runs
|
||||
* under a scope the asserted epoch does not imply.
|
||||
*/
|
||||
export async function assertRecordSessionFence(
|
||||
auth: AuthContext,
|
||||
): Promise<void> {
|
||||
const transport = auth.authMethod === "bearer" ? "bearer" : "cookie";
|
||||
const sessionEpoch = auth.session.recordEpoch ?? 0;
|
||||
const sessionScope = auth.session.actingAsUserId ?? null;
|
||||
|
||||
// Stamped before the verdict, so a refusal echoes the truth too. The Bearer
|
||||
// transport carries no session context to echo and gets none: the frozen
|
||||
// native contract sends no fence header and receives none back.
|
||||
if (transport === "cookie") {
|
||||
getEvent()?.setRecordContext({ epoch: sessionEpoch, scope: sessionScope });
|
||||
}
|
||||
|
||||
const asserted = await readAssertedRecordContext();
|
||||
const verdict = recordSessionFenceVerdict({
|
||||
transport,
|
||||
sessionEpoch,
|
||||
sessionScope,
|
||||
asserted,
|
||||
});
|
||||
|
||||
if (verdict === "pass") return;
|
||||
|
||||
if (verdict === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "record_context_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
|
||||
// `unfenced-client`: a bundle that predates the fence, inside or after a
|
||||
// shared-record context. It gets the code it already recovers from — see the
|
||||
// two-refusals note in the contract module.
|
||||
//
|
||||
// No audit row, and that follows the rule already written for the session
|
||||
// carrier in `api-handler.ts`: a HEADER refusal is a per-request assertion by
|
||||
// a client and earns a durable record, a SESSION-state refusal is ambient
|
||||
// browser state and would bury the rows that matter under noise from the
|
||||
// feature working as designed. This arm is the second kind — every
|
||||
// navigation, prefetch and poll from one stale tab lands here until it
|
||||
// reloads, and the reload is the designed outcome.
|
||||
annotate({ meta: { sharing_refusal: "unfenced_client" } });
|
||||
throw new SharingAccessDeniedError();
|
||||
}
|
||||
Reference in new issue
Block a user