fix(02-12.1): check record context before an idempotent replay

- evaluate the fence in the wrapper above findCached, so a stale request
  neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
  wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 18:54:20 +02:00
1 parent 45fec0fc1b
commit 3a99de312e
18 files changed
+731 -128

No files matched your search

@@ -139,7 +139,7 @@ describe("the acting-account carrier is read in one place", () => {
* Who may ask "which record does this request claim" without an auth
* context in hand.
*
* `readClaimedActingAccount()` answers before any grant check has run, so a
* `readClaimedRecordContext()` answers before any grant check has run, so a
* caller that treats its answer as permission has skipped the only thing
* that grants any. Exactly one caller exists, beside the file that declares
* it: the idempotency wrapper, which uses the claim to pick a cache cell and
@@ -147,9 +147,17 @@ describe("the acting-account carrier is read in one place", () => {
* acting account has an auth context by then and goes through
* `requireRecordAuth`, which checks a live grant before returning. A second
* caller here is a design question, not a patch.
*
* The matcher names the v1.36.0 spelling (`readClaimedActingAccount`) as well
* as today's. That function was removed in v1.37.0 when the wrapper started
* needing the session's record context on the same read; matching both means
* a reintroduced narrow copy trips this leg instead of quietly becoming a
* second answer.
*/
it("the unchecked claim has exactly two consumers", () => {
const consumers = filesMatching(/readClaimedActingAccount/);
const consumers = filesMatching(
/readClaimedRecordContext|readClaimedActingAccount/,
);
expect(consumers.length).toBeGreaterThan(0);
expect(consumers).toEqual([
"lib/auth/acting-carrier.ts",
@@ -215,10 +215,32 @@ describe("the epoch has one writer and a closed reader set", () => {
"lib/api-handler.ts",
// Compares it. The one place that decides anything from it.
"lib/sharing/record-session-fence.ts",
// Reads it off the same session lookup the idempotency wrapper already
// makes, so the fence can be evaluated above the replay cache without a
// second round trip.
"lib/auth/acting-carrier.ts",
].sort(),
);
});
it("is consulted by the idempotency wrapper, above the cache", () => {
const src = read("lib/idempotency.ts");
const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)");
const cache = src.indexOf("findCached(ctx)");
const claimRow = src.indexOf("claimKey(ctx)");
expect(fence).toBeGreaterThan(-1);
expect(cache).toBeGreaterThan(-1);
expect(claimRow).toBeGreaterThan(-1);
// A stale request must not learn whether a cell exists, replay a body out
// of one, or insert a claim row. The runtime proof is the zero-call
// instrument in `src/lib/__tests__/idempotency.test.ts`; this is the
// tripwire that says the line moved.
expect(fence).toBeLessThan(cache);
expect(fence).toBeLessThan(claimRow);
// And it comes from the fence module, not from a second copy of the rule.
expect(src).toContain('from "@/lib/sharing/record-session-fence"');
});
it("does not leak the raw counter into the browser's vocabulary", () => {
// The client never names the epoch as a column. It receives
// `recordSession: { epoch, scope }` and asserts it back as a header, which
+160
View File
@@ -26,6 +26,11 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
// v1.37.0 — the record-session fence stamps the response echo on the
// request-scoped wide event. There is no event outside a request scope and
// the fence handles that with an optional call, so `undefined` is the honest
// stub rather than a fake builder.
getEvent: vi.fn(() => undefined),
}));
vi.mock("@/lib/auth/session", () => ({
@@ -674,3 +679,158 @@ describe("withIdempotency — record-scoped cells", () => {
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
});
/**
* v1.37.0 — the record-session fence, above the replay cache.
*
* The property is a PLACEMENT, and a status-code assertion cannot see it: a
* wrapper that read the cell, replayed nothing, released its claim and then
* returned 409 would look identical from outside. `findCached` and `claimKey`
* are module-private, so the instrument is one layer lower and falsifiable: the
* two Prisma calls they make must receive zero calls for a refused request and
* non-zero for a passing one.
*
* The first draft of this plan proposed asserting "no row exists for the target
* cell" instead. That assertion cannot fail: `releaseClaim` deletes the pending
* row whenever the handler throws, so it would pass with the fence placed after
* `claimKey` too.
*
* Break it by moving the fence call below `findCached` in `withIdempotency`:
* the `findUnique` zero-call assertion fails.
*/
describe("the record-session fence sits above the replay cache", () => {
function fencedSession(recordEpoch: number, actingAsUserId: string | null) {
vi.mocked(getSession).mockResolvedValue({
session: {
id: "s-1",
expiresAt: new Date(Date.now() + 60_000),
actingAsUserId,
recordEpoch,
},
user: { id: "u-1" },
} as never);
}
function asserts(epoch: string | null, scope: string | null) {
vi.mocked(headers).mockResolvedValue({
get: vi.fn((name: string) => {
if (name.toLowerCase() === "x-healthlog-record-epoch") return epoch;
if (name.toLowerCase() === "x-healthlog-record-scope") return scope;
return null;
}),
} as never);
}
it("refuses a stale claim without reading or claiming a cell", async () => {
fencedSession(4, "owner-1");
asserts("3", "owner-1");
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
// The placement, stated as the only thing that can prove it.
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
// And the side effect the whole wrapper exists to run once never ran.
expect(handler).not.toHaveBeenCalled();
});
it("POSITIVE CONTROL: a matching claim reads the cell and claims it", async () => {
// Without this the assertion above would pass for a wrapper that never
// touched the cache at all.
fencedSession(4, "owner-1");
asserts("4", "owner-1");
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(201);
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledTimes(1);
});
it("lets an unfenced client through to the route's own refusal", async () => {
// `unfenced-client` is deliberately NOT refused here: the route owns the
// 403 a pre-fence bundle recovers from, together with its annotation and
// audit posture. The wrapper still files the cell, because the handler is
// about to answer.
fencedSession(4, "owner-1");
asserts(null, null);
const handler = vi.fn(async () =>
NextResponse.json(
{ data: null, error: "Account access denied" },
{ status: 403 },
),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(403);
expect(handler).toHaveBeenCalledTimes(1);
});
it("leaves a never-switched session byte-identical", async () => {
fencedSession(0, null);
asserts(null, null);
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(201);
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
});
it("leaves the Bearer transport byte-identical", async () => {
// No session row at all, so no context to be stale about — and a fence
// header on this transport is neither read nor honoured.
vi.mocked(getSession).mockResolvedValue(null);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
userId: "u-bearer",
revoked: false,
expiresAt: null,
} as never);
vi.mocked(headers).mockResolvedValue({
get: vi.fn((name: string) => {
if (name.toLowerCase() === "authorization")
return `Bearer hlk_${"a".repeat(64)}`;
if (name.toLowerCase() === "x-healthlog-record-epoch") return "1";
if (name.toLowerCase() === "x-healthlog-record-scope") return "owner-1";
return null;
}),
} as never);
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(201);
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledTimes(1);
});
});
+36 -6
View File
@@ -191,9 +191,33 @@ export async function getUnswitchedSession(): Promise<Awaited<
* unswitched own-record request. The grant check happens later, inside the
* handler, on every request.
*/
export async function readClaimedActingAccount(): Promise<
string | null | undefined
> {
/**
* v1.37.0 — and, on the same read, what that request's session actually holds.
*
* This replaced a narrower `readClaimedActingAccount()` that returned only the
* claim. The idempotency wrapper — still the one caller — now needs a second
* fact: the session's own record context, so the record-session fence can be
* evaluated above the replay cache. Widening the existing function rather than
* adding a second one is what keeps that from costing a second `getSession()`
* on every idempotent write, and keeps "what does this request say it is acting
* as" to one implementation, which is the thing this file exists to ensure.
*
* The narrow function is gone rather than kept as a shim: it had no caller left,
* and a wrapper nobody calls is a second answer waiting for somebody to reach
* for it.
*/
export interface ClaimedRecordContext {
/** The claimed record, or `undefined` when the carrier was unsafe to resolve. */
claimedRecord: string | null | undefined;
/** Which credential the request authenticated with. */
transport: "cookie" | "bearer";
/** The session's selector-move counter. Always 0 on the Bearer transport. */
sessionEpoch: number;
/** The account the session is pointed at, or null. Always null on Bearer. */
sessionScope: string | null;
}
export async function readClaimedRecordContext(): Promise<ClaimedRecordContext> {
const header = await readSelectorHeader();
let session: Awaited<ReturnType<typeof getSession>> = null;
try {
@@ -204,11 +228,17 @@ export async function readClaimedActingAccount(): Promise<
// unauthenticated request claims nothing.
session = null;
}
const transport: "cookie" | "bearer" = session ? "cookie" : "bearer";
const carrier = decideActingCarrier({
transport: session ? "cookie" : "bearer",
transport,
stamped: session?.session.actingAsUserId ?? null,
header,
});
if (carrier.kind === "misplaced-header") return undefined;
return carrierTarget(carrier);
return {
claimedRecord:
carrier.kind === "misplaced-header" ? undefined : carrierTarget(carrier),
transport,
sessionEpoch: session?.session.recordEpoch ?? 0,
sessionScope: session?.session.actingAsUserId ?? null,
};
}
+25 -3
View File
@@ -18,9 +18,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import {
readClaimedActingAccount,
readClaimedRecordContext,
selectorNamesAnAccount,
} from "@/lib/auth/acting-carrier";
import { refuseStaleRecordSessionClaim } from "@/lib/sharing/record-session-fence";
import { hashToken } from "@/lib/auth/hmac";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
@@ -327,7 +328,7 @@ export function isCachableStatus(status: number): boolean {
* the handler again and creating duplicate measurements (audit C-4).
*
* It resolves the CALLER and only the caller — which record they are aiming at
* is a separate question, answered by `readClaimedActingAccount()` above and
* is a separate question, answered by `readClaimedRecordContext()` above and
* folded into the key. Neither answer authorises anything: the handler runs
* its own `requireAuth()` / `requireRecordAuth()` either way.
*
@@ -456,7 +457,28 @@ export function withIdempotency<
// A claim only chooses the cache cell. It never carries authority: a
// completed delegated cell is checked against the current grant again
// immediately before its body can be returned below.
const claimedRecord = await readClaimedActingAccount();
const claim = await readClaimedRecordContext();
// v1.37.0 — the record-session fence, evaluated HERE and not one line
// lower. A stale request must not learn whether a cell exists, must not
// replay a body out of one, and must not insert a claim row — so the
// verdict sits above `findCached` and above the two carrier arms below,
// which choose the cell. It is the second, independent evaluation of the
// same client assertion (the handler's own `requireRecordAuth` is the
// other) and nothing is carried between them; see the note on
// `refuseStaleRecordSessionClaim`.
//
// Only `stale` refuses here. An `unfenced-client` verdict falls through so
// the route issues the 403 a pre-fence bundle already recovers from, with
// the annotation and audit posture that belong to the route.
//
// A never-switched session and every Bearer request are untouched: both
// pass the verdict without a header, which is what keeps every existing
// own-record and delegated-cell contract byte-identical.
const staleContext = await refuseStaleRecordSessionClaim(claim);
if (staleContext) return staleContext;
const claimedRecord = claim.claimedRecord;
if (claimedRecord === undefined) {
// The carrier cannot safely name a record. In particular, a selector on
// a cookie request is a misplaced claim, not an own-record request.
+71 -17
View File
@@ -40,9 +40,11 @@
* discards a response whose echoed context contradicts the one it is now on.
*/
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import {
RECORD_EPOCH_HEADER,
RECORD_FENCE_ERROR_CODE,
RECORD_SCOPE_HEADER,
parseAssertedContext,
recordScopeHeaderValue,
@@ -143,23 +145,10 @@ export function recordSessionForPayload(
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,
const verdict = await decide({
transport: auth.authMethod === "bearer" ? "bearer" : "cookie",
sessionEpoch: auth.session.recordEpoch ?? 0,
sessionScope: auth.session.actingAsUserId ?? null,
});
if (verdict === "pass") return;
@@ -183,3 +172,68 @@ export async function assertRecordSessionFence(
annotate({ meta: { sharing_refusal: "unfenced_client" } });
throw new SharingAccessDeniedError();
}
/**
* The same verdict, for the idempotency wrapper, returning bytes instead of
* throwing.
*
* `withIdempotency` is composed OUTSIDE `apiHandler` on at least one route
* (`POST /api/admin/backups/[id]/restore`), so a throw from the wrapper would
* escape the envelope and become a 500 rather than the documented refusal. It
* therefore returns a response, hand-built to the same
* `{ data, error, meta.errorCode }` shape `apiHandler`'s `SharingAuthError` arm
* emits, so a client cannot tell which layer refused it.
*
* Only the `stale` arm produces a response. `unfenced-client` deliberately
* falls through to the handler, whose `requireRecordAuth` issues the 403 a
* pre-fence bundle recovers from — along with the wide-event annotation and the
* audit posture that belong to the route, not to a cache wrapper.
*
* This is the SECOND, INDEPENDENT evaluation of the fence on such a request,
* and its result is deliberately NOT carried into the handler's own. Carrying
* it would need request-scoped mutable state shared across the wrapper/handler
* boundary, which is a second source of truth for the same question. Two
* independent evaluations against the same client assertion are sufficient: a
* switch landing between the two fails the second, so no handler body runs
* under a scope the asserted epoch does not imply.
*/
export async function refuseStaleRecordSessionClaim(
claim: RecordSessionInputs,
): Promise<Response | null> {
const verdict = await decide(claim);
if (verdict !== "stale") return null;
annotate({ meta: { sharing_refusal: "record_context_stale_idempotent" } });
return NextResponse.json(
{
data: null,
error: "The record this session is on has changed",
meta: { errorCode: RECORD_FENCE_ERROR_CODE },
},
{ status: 409 },
);
}
/** What the fence needs to know about the session, from either caller. */
interface RecordSessionInputs {
transport: "cookie" | "bearer";
sessionEpoch: number;
sessionScope: string | null;
}
/** Stamp the echo and read the verdict. The one place either happens. */
async function decide(input: RecordSessionInputs) {
// 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 (input.transport === "cookie") {
getEvent()?.setRecordContext({
epoch: input.sessionEpoch,
scope: input.sessionScope,
});
}
return recordSessionFenceVerdict({
...input,
asserted: await readAssertedRecordContext(),
});
}
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import {
acceptGrant,
@@ -324,10 +324,7 @@ describe("managed profile lifecycle (real Postgres)", () => {
where: { id: invitation.id },
}),
).toMatchObject({ acceptedAt: expect.any(Date), expiresAt: null });
await getPrismaClient().session.update({
where: { id: secondGuardian.sessionId },
data: { actingAsUserId: profile.id },
});
await switchSessionTo(secondGuardian.sessionId, profile.id);
cookieJar.set("healthlog_session", creator.sessionId);
const { DELETE: revokeGuardian } =
@@ -505,10 +502,7 @@ describe("managed profile lifecycle (real Postgres)", () => {
const { creator, profile, secondGuardian, secondGrant } =
await createTwoGuardians();
cookieJar.set("healthlog_session", creator.sessionId);
await getPrismaClient().session.update({
where: { id: secondGuardian.sessionId },
data: { actingAsUserId: profile.id },
});
await switchSessionTo(secondGuardian.sessionId, profile.id);
const prisma = getPrismaClient();
await prisma.$executeRawUnsafe(
"CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.revoked' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$",
+28
View File
@@ -24,3 +24,31 @@ export const headerJar = new Map<string, string>();
* A request consumes one entry when its mocked cookie store is created.
*/
export const queuedSessionIds: string[] = [];
/**
* v1.37.0 — attach the record-session assertion the shipped browser client
* attaches to every same-origin request.
*
* The record-session fence refuses a cookie request whose asserted context does
* not match its session row, and it refuses a request that asserts nothing at
* all once the session has been inside somebody's record. A test that moves
* `acting_as_user_id` directly and then calls a route is therefore exercising
* the fence's pre-fence-bundle arm, not whatever the test was written for — so
* every such fixture calls this immediately after the switch.
*
* Truthful by construction: the values are read back off the row rather than
* computed, so this can only ever say what the session actually holds. The
* cases that want a STALE or ABSENT assertion set the headers themselves, and
* they live in `tests/integration/record-session-fence*.test.ts`.
*
* Takes the epoch and scope rather than reading the database, so this module
* keeps its single responsibility (shared mock state) and no test file has to
* import a Prisma client it does not otherwise use.
*/
export function assertRecordContext(
recordEpoch: number,
actingAsUserId: string | null,
): void {
headerJar.set("x-healthlog-record-epoch", String(recordEpoch));
headerJar.set("x-healthlog-record-scope", actingAsUserId ?? "self");
}
@@ -0,0 +1,283 @@
/**
* FENCE-AC-05 — an idempotent write whose record context moved between the
* client forming its intent and the server receiving it.
*
* The dangerous shape is specific. A tab posts a mutation to the owner's record
* with an `Idempotency-Key`, the selector moves out of band, and the retry
* arrives. Without a fence above the cache the retry either replays the owner's
* cached body under the new context, or files a fresh claim in a cell it has no
* business choosing. Either way nothing errors.
*
* ## What is asserted, and what deliberately is not
*
* The seeded owner cell is asserted BYTE-UNCHANGED — same id, same
* `responseStatus`, same `responseBody`, same `expiresAt` — because that is
* falsifiable. `expiresAt` carries the weight: the completion path rewrites it
* to `now + 24h` and a fresh claim rewrites it to `now + 2min`, so a wrapper
* that reached the cache at all would move it. The obvious alternative,
* "no row exists for the target cell", is not: `releaseClaim` deletes the
* pending row whenever the handler throws, so that assertion passes with the
* fence placed after `claimKey` too. The zero-call proof for `findUnique` /
* `create` lives one layer down in `src/lib/__tests__/idempotency.test.ts`,
* where the Prisma calls can be spied on directly.
*
* Everything under the test is real: the shipped wrapper, the shipped resolver,
* the composite unique index, the trigger that moves the epoch.
*/
import { NextRequest, NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import {
RECORD_EPOCH_HEADER,
RECORD_FENCE_ERROR_CODE,
RECORD_SCOPE_HEADER,
RECORD_SCOPE_SELF,
} from "@/lib/sharing/record-session-fence-contract";
const KEY = "retry-7c21ab04";
let counter = 0;
let handlerRuns = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `fence-idem-${suffix}`,
email: `fence-idem-${suffix}@example.test`,
role: "USER",
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function writeGrant(grantorId: string, granteeId: string) {
return getPrismaClient().accountGrant.create({
data: {
grantorId,
granteeId,
access: "WRITE",
acceptedAt: new Date(Date.now() - 60_000),
},
});
}
/** The shipped composition: `apiHandler(withIdempotency(handler))`. */
const delegableWrite: (request: NextRequest) => Promise<Response> = apiHandler(
withIdempotency<[NextRequest]>(async () => {
const { user } = await requireRecordAuth("write", "measurements");
// Counted AFTER the resolver, so it means "the handler body ran" rather
// than "the wrapper called through". The wrapper deliberately calls
// through on an `unfenced-client` verdict so the route can issue its own
// refusal, and a counter incremented above this line would report that
// pass-through as a side effect having happened.
handlerRuns += 1;
const row = await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 80 + handlerRuns,
unit: "kg",
measuredAt: new Date(),
source: "MANUAL",
},
});
return NextResponse.json(
{ data: { id: row.id, userId: row.userId }, error: null },
{ status: 201 },
);
}),
);
function post(): Promise<Response> {
return delegableWrite(
new NextRequest("http://localhost/api/test/delegable-write", {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": KEY,
},
body: JSON.stringify({ value: 81 }),
}),
);
}
function assertContext(epoch: number, scope: string | null): void {
headerJar.set(RECORD_EPOCH_HEADER, String(epoch));
headerJar.set(RECORD_SCOPE_HEADER, scope ?? RECORD_SCOPE_SELF);
}
async function switchOutOfBand(sessionId: string, target: string | null) {
await getPrismaClient().session.update({
where: { id: sessionId },
data: { actingAsUserId: target },
});
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: sessionId },
});
return row.recordEpoch;
}
async function cells() {
return getPrismaClient().idempotencyKey.findMany({ orderBy: { key: "asc" } });
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
handlerRuns = 0;
});
describe("FENCE-AC-05 an idempotent write under a moved record context", () => {
it("FENCE-AC-05 refuses the replay, leaves the seeded owner cell byte-unchanged, and writes to neither record", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await writeGrant(owner.id, delegate.id);
const session = await signIn(delegate.id);
// A completed owner cell for key K, written by the real path.
const inRecord = await switchOutOfBand(session.id, owner.id);
assertContext(inRecord, owner.id);
const first = await post();
expect(first.status).toBe(201);
expect(handlerRuns).toBe(1);
const seeded = await cells();
expect(seeded).toHaveLength(1);
const before = seeded[0];
expect(before.responseStatus).toBe(201);
// The selector moves out of band. The tab knows nothing about it and
// retries the same key under the context it formed its intent in.
const afterSwitch = await switchOutOfBand(session.id, null);
expect(afterSwitch).toBe(inRecord + 1);
const replay = await post();
const body = await replay.json();
// (a) refused
expect(replay.status).toBe(409);
expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
// (b) the seeded cell is untouched — nothing read it, claimed it, or
// rewrote it. `updatedAt` is the falsifiable half: a wrapper that reached
// the cache and released a claim would leave the row present but moved.
const after = await cells();
expect(after).toHaveLength(1);
expect(after[0].id).toBe(before.id);
expect(after[0].key).toBe(before.key);
expect(after[0].responseStatus).toBe(before.responseStatus);
expect(after[0].responseBody).toBe(before.responseBody);
expect(after[0].expiresAt.getTime()).toBe(before.expiresAt.getTime());
expect(after[0].createdAt.getTime()).toBe(before.createdAt.getTime());
// (c) no write landed on either record: one row from the first request and
// nothing since, on either side.
expect(handlerRuns).toBe(1);
const rows = await getPrismaClient().measurement.findMany();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(owner.id);
});
it("FENCE-AC-05 POSITIVE CONTROL: the same replay under a matching context returns the cached body", async () => {
// Without this, the assertions above would pass for a wrapper that had
// stopped replaying anything at all.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await writeGrant(owner.id, delegate.id);
const session = await signIn(delegate.id);
const inRecord = await switchOutOfBand(session.id, owner.id);
assertContext(inRecord, owner.id);
const first = await post();
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await post();
const replayBody = await replay.json();
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(replayBody.data.id).toBe(firstBody.data.id);
// Replayed, not re-run.
expect(handlerRuns).toBe(1);
expect(await getPrismaClient().measurement.findMany()).toHaveLength(1);
});
it("FENCE-AC-05 leaves a never-switched idempotent write byte-identical", async () => {
// No fence header at all, no switch ever. This is the shape every existing
// own-record idempotency contract has, and it must not have moved.
const solo = await makeUser("solo");
await signIn(solo.id);
const first = await post();
expect(first.status).toBe(201);
const replay = await post();
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(handlerRuns).toBe(1);
const stored = await cells();
expect(stored).toHaveLength(1);
// The un-delegated cell files under the client's key verbatim — the record
// fold does not touch a request that is nobody's delegate.
expect(stored[0].key).toBe(KEY);
});
it("FENCE-AC-05 lets a pre-fence bundle reach the route's own refusal", async () => {
// An `unfenced-client` verdict is not the wrapper's to refuse: the route
// owns the 403 that bundle recovers from.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await writeGrant(owner.id, delegate.id);
const session = await signIn(delegate.id);
await switchOutOfBand(session.id, owner.id);
// No fence headers at all.
const res = await post();
expect(res.status).toBe(403);
expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
expect(handlerRuns).toBe(0);
expect(await getPrismaClient().measurement.findMany()).toHaveLength(0);
});
});
+29
View File
@@ -4,6 +4,7 @@
* environment-setup.ts before application modules load.
*/
import { prisma } from "@/lib/db";
import { assertRecordContext } from "./mock-next-headers";
import type { PrismaClient } from "@/generated/prisma/client";
/**
@@ -118,3 +119,31 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
});
}
}
/**
* v1.37.0 — point a session at a record the way a test needs it pointed, and
* leave the request headers agreeing with the row.
*
* Every sharing fixture used to do this with a bare `session.update`. That is
* still the write, but since the record-session fence a cookie request also has
* to ASSERT the context it believes it is in: a session pointed at a record and
* a request carrying no assertion is a bundle that predates the fence, and it is
* refused with 403 before any resolver decides anything. A fixture that switched
* and did not assert would therefore be testing the deploy-compatibility arm
* rather than whatever it was written for.
*
* The assertion is read back off the row rather than computed, so it is
* truthful by construction and cannot drift from the trigger's idea of the
* epoch. Tests that WANT a stale or absent assertion set the headers themselves;
* those live in `tests/integration/record-session-fence*.test.ts`.
*/
export async function switchSessionTo(
sessionId: string,
actingAsUserId: string | null,
): Promise<void> {
const row = await prisma.session.update({
where: { id: sessionId },
data: { actingAsUserId },
});
assertRecordContext(row.recordEpoch, row.actingAsUserId);
}
@@ -17,7 +17,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -298,10 +298,7 @@ describe("accountAccess — the active record", () => {
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
const { accountAccess, id } = await readMe();
@@ -327,10 +324,7 @@ describe("accountAccess — the active record", () => {
const delegate = await makeUser("delegate");
const grant = await grantAccess(owner.id, delegate.id);
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
// The grant lapses while the browser sits inside the record. Expiry has
// no session cleanup — only revocation does — so the stamp survives it.
await getPrismaClient().accountGrant.update({
+4 -13
View File
@@ -17,7 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -103,10 +103,7 @@ async function household() {
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
return { owner, delegate, session };
}
@@ -185,10 +182,7 @@ describe("who the trail says did it", () => {
const delegate = await makeUser("delegate");
const stranger = await makeUser("stranger");
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: stranger.id },
});
await switchSessionTo(session.id, stranger.id);
expect((await act()).status).toBe(403);
await settle();
@@ -232,10 +226,7 @@ describe("delegated reads coalesce to one row a day", () => {
await act();
const secondSession = await signIn(second.id);
await getPrismaClient().session.update({
where: { id: secondSession.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(secondSession.id, owner.id);
await act();
await settle();
@@ -30,7 +30,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -99,10 +99,7 @@ async function switchInto(ownerId: string, delegateId: string) {
},
});
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
return { grant, session };
}
@@ -199,10 +196,7 @@ function contract<T>(
await route.seed(owner.id, route.ownerMarker);
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
await expectDenied(await route.call());
});
@@ -1108,10 +1102,7 @@ describe("GET /api/sleep/night", () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
const { GET } = await import("@/app/api/sleep/night/route");
await expectDenied(await call(GET as Handler, "/api/sleep/night"));
@@ -1550,10 +1541,7 @@ describe("GET /api/medications/intake", () => {
await seedTodaysDose(owner.id, "owner-statin");
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
await expectDenied(await today());
});
@@ -1682,10 +1670,7 @@ describe("the per-medication reads", () => {
});
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
const detail = await import("@/app/api/medications/[id]/route");
await expectDenied(
@@ -1882,10 +1867,7 @@ describe("GET /api/documents/inbound", () => {
},
});
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
expect(grant.access).toBe("WRITE");
const { POST } = await import("@/app/api/documents/inbound/route");
@@ -39,7 +39,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -134,20 +134,14 @@ async function switchInto(
expect(grant.acceptedAt).not.toBeNull();
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
return { grant, session };
}
/** A session inside a record nobody ever shared with this caller. */
async function claimWithoutGrant(ownerId: string, callerId: string) {
const session = await signIn(callerId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
}
async function revoke(grantId: string, ownerId: string) {
+3 -9
View File
@@ -32,7 +32,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -105,10 +105,7 @@ async function switchInto(
granteeId: delegateId,
});
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
return { grant, session };
}
@@ -242,10 +239,7 @@ function frontDoorRead(name: string, route: FrontDoorRead) {
await route.seed(owner.id, "owner");
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
await expectAccessDenied(await route.call());
});
+38 -8
View File
@@ -20,8 +20,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { assertRecordContext, cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -71,6 +71,11 @@ async function signIn(userId: string) {
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
// A new session is a new browser, and a new browser has adopted nothing. The
// reset matters where one test drives two of them: a stale adopted epoch
// carried across would make the second browser's first switch lose a
// compare-and-set it was never in.
adoptedEpoch = 0;
return session;
}
@@ -147,13 +152,39 @@ async function renounce(grantId: string): Promise<Response> {
);
}
/**
* Switch the way the shipped browser switches: name the epoch the client
* believes it is on, and ADOPT the context the response publishes.
*
* v1.37.0 — the adoption is not decoration. `POST /api/account/switch` and
* `GET /api/auth/me` are the only two responses a client may learn its record
* context from, and every same-origin request after a switch has to assert it
* or the record-session fence refuses. A fixture that switched and did not
* adopt would be testing the fence's pre-fence-bundle arm rather than the
* handshake this file is about — so the round trip is exercised here, which is
* the property the feature actually ships.
*/
async function switchTo(accountId: string | null): Promise<Response> {
const { POST } = await import("@/app/api/account/switch/route");
return POST(
jsonRequest("http://localhost/api/account/switch", "POST", { accountId }),
const response = await POST(
jsonRequest("http://localhost/api/account/switch", "POST", {
accountId,
expectedEpoch: adoptedEpoch,
}),
);
if (response.status === 200) {
const { recordSession } = (await response.clone().json()).data;
if (recordSession) {
adoptedEpoch = recordSession.epoch;
assertRecordContext(recordSession.epoch, recordSession.scope);
}
}
return response;
}
/** The epoch this fixture's browser currently believes it is on. */
let adoptedEpoch = 0;
/** A delegable read, exactly as a migrated route builds one. */
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
async () => {
@@ -205,6 +236,8 @@ beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
// Every test starts on a fresh browser that has adopted nothing.
adoptedEpoch = 0;
});
describe("the handshake, end to end", () => {
@@ -276,10 +309,7 @@ describe("the handshake, end to end", () => {
// And the delegate's next request is refused on the grant, independently
// of the session having been cleared.
cookieJar.set("healthlog_session", delegateSession.id);
await getPrismaClient().session.update({
where: { id: delegateSession.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(delegateSession.id, owner.id);
const afterRevoke = await readRecord();
expect(afterRevoke.status).toBe(403);
expect((await afterRevoke.json()).meta.errorCode).toBe(
@@ -42,7 +42,7 @@ import {
type MutatingHandlerContract,
} from "../fixtures/v137/sharing-matrix";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -140,20 +140,14 @@ async function switchInto(
expect(grant.acceptedAt).not.toBeNull();
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
return { grant, session };
}
/** A session inside a record nobody ever shared with this caller. */
async function claimWithoutGrant(ownerId: string, callerId: string) {
const session = await signIn(callerId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
}
type Handler = (
@@ -30,7 +30,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import type { ShareDomain } from "@/lib/sharing/scope";
@@ -117,10 +117,7 @@ async function switchInto(
});
const session = await signIn(delegateId);
cookieJar.set("healthlog_session", session.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
await switchSessionTo(session.id, ownerId);
return grant;
}
@@ -393,10 +390,7 @@ describe("the refusal does not distinguish itself", () => {
const stranger = await makeUser("stranger");
const session = await signIn(stranger.id);
cookieJar.set("healthlog_session", session.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await switchSessionTo(session.id, owner.id);
const noGrant = await PROBES.labs.call();
expect(outOfScope.status).toBe(noGrant.status);