mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(02-12.1): refuse a record request whose context has moved
- both record resolvers check the fence after authentication and before the carrier, off the one session row that also decides the scope - POST /api/account/switch takes an optional expectedEpoch and writes conditionally, so concurrent switchers resolve to one outcome - GET /api/auth/me publishes the context a browser bootstraps from - every response that resolved a record scope echoes it back - regenerate the API contract
This commit is contained in:
1 parent
59359738c8
commit
45fec0fc1b
15 files changed
+1631
-98
No files matched your search
+208
-83
@@ -12750,7 +12750,11 @@ paths:
|
||||
should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated
|
||||
here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every
|
||||
following request re-checks the grant. `accountId: null` clears the switch and always works, including from
|
||||
inside a switched session: this endpoint is the way out."
|
||||
inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a
|
||||
session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write
|
||||
conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409
|
||||
`sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and
|
||||
retry once against the reconciled epoch."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -12764,6 +12768,15 @@ paths:
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
- type: "null"
|
||||
expectedEpoch:
|
||||
description: "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a
|
||||
previous switch. Present makes the write conditional: if another tab moved the selector first the
|
||||
switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me.
|
||||
Omitted keeps the unconditional write, which is what a client predating the record-session fence
|
||||
sends. Cookie transport only."
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- accountId
|
||||
responses:
|
||||
@@ -12781,6 +12794,17 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"403": *a20
|
||||
"409":
|
||||
description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is
|
||||
`sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records —
|
||||
so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only
|
||||
request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a
|
||||
client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a
|
||||
third source of truth."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: Validation failed.
|
||||
content:
|
||||
@@ -36687,10 +36711,15 @@ components:
|
||||
type: string
|
||||
accountAccess:
|
||||
$ref: "#/components/schemas/AccountAccess"
|
||||
recordSession:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/RecordSessionState"
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- accountAccess
|
||||
- recordSession
|
||||
additionalProperties: {}
|
||||
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
|
||||
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this
|
||||
@@ -36702,26 +36731,127 @@ components:
|
||||
accounts:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AccountAccessEntry"
|
||||
description: Every record this caller may open, newest grant first.
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
minLength: 1
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
displayName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
level:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
- manage
|
||||
recordKind:
|
||||
type: string
|
||||
enum:
|
||||
- shared
|
||||
- managed
|
||||
sections:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
- type: "null"
|
||||
canWrite:
|
||||
type: boolean
|
||||
required:
|
||||
- accountId
|
||||
- username
|
||||
- displayName
|
||||
- access
|
||||
- level
|
||||
- recordKind
|
||||
- sections
|
||||
- canWrite
|
||||
additionalProperties: false
|
||||
active:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/AccountAccessEntry"
|
||||
- type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
minLength: 1
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
displayName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
level:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
- manage
|
||||
recordKind:
|
||||
type: string
|
||||
enum:
|
||||
- shared
|
||||
- managed
|
||||
sections:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
- type: "null"
|
||||
canWrite:
|
||||
type: boolean
|
||||
required:
|
||||
- accountId
|
||||
- username
|
||||
- displayName
|
||||
- access
|
||||
- level
|
||||
- recordKind
|
||||
- sections
|
||||
- canWrite
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a
|
||||
banner can name the person without joining two fields. Null when the caller is in their own record, and
|
||||
ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
|
||||
one."
|
||||
recordKind:
|
||||
type: string
|
||||
enum:
|
||||
- self
|
||||
- shared
|
||||
- managed
|
||||
description: The server-resolved kind of the record currently in view. `self` applies when `active` is null.
|
||||
canSwitch:
|
||||
type: boolean
|
||||
description: Whether there is anywhere to switch to. Bind it directly.
|
||||
required:
|
||||
- accounts
|
||||
- active
|
||||
@@ -36733,86 +36863,30 @@ components:
|
||||
be a second program deciding one person's access to a health record, and the two answer differently the first
|
||||
time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list,
|
||||
not a missing field."
|
||||
AccountAccessEntry:
|
||||
RecordSessionState:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
description: The account whose record it is — the value POST /api/account/switch takes, and the value the
|
||||
X-HealthLog-Account header carries.
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
epoch:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to
|
||||
this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request,
|
||||
and as `expectedEpoch` when switching.
|
||||
scope:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
description: The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads
|
||||
canonical `level`.
|
||||
level:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
- manage
|
||||
description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable
|
||||
surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management),
|
||||
which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.
|
||||
recordKind:
|
||||
type: string
|
||||
enum:
|
||||
- self
|
||||
- shared
|
||||
- managed
|
||||
description: Server-resolved presentation metadata for the record. It is never accepted as an authorization input.
|
||||
sections:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ShareSection"
|
||||
- type: "null"
|
||||
description: The sections of that record this caller may open, or null for the entire record. Null is first-class and is
|
||||
what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open
|
||||
sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets
|
||||
the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal,
|
||||
which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the
|
||||
consent screen's reading order.
|
||||
canWrite:
|
||||
type: boolean
|
||||
description: "Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ
|
||||
one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE
|
||||
additionally admits is said by `level`. Resolved server-side. Render it; never derive it."
|
||||
description: The account this session is pointed at, or null for its own record. Send it back as the
|
||||
`X-HealthLog-Record-Scope` header, with `self` standing for null.
|
||||
required:
|
||||
- accountId
|
||||
- username
|
||||
- displayName
|
||||
- access
|
||||
- level
|
||||
- recordKind
|
||||
- sections
|
||||
- canWrite
|
||||
- epoch
|
||||
- scope
|
||||
additionalProperties: false
|
||||
description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL
|
||||
here would resolve to a refusal. Clients paint their initials fallback."
|
||||
ShareSection:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
|
||||
clustering the delegable read surfaces rather than from the module list — the two answer different questions and
|
||||
never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
|
||||
enum: a scope that named the whole record would be a narrowing that means its own absence."
|
||||
description: Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which
|
||||
carries its selector per request and accumulates no session state to be stale about. A client adopts this and
|
||||
then asserts it on every same-origin request, so a request formed under a context that has since moved is
|
||||
refused rather than served against the wrong record.
|
||||
RecordActivityEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -37000,6 +37074,21 @@ components:
|
||||
accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than
|
||||
disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back.
|
||||
`lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised."
|
||||
ShareSection:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
|
||||
clustering the delegable read surfaces rather than from the module list — the two answer different questions and
|
||||
never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
|
||||
enum: a scope that named the whole record would be a narrowing that means its own absence."
|
||||
AccountGrantState:
|
||||
type: string
|
||||
enum:
|
||||
@@ -37172,6 +37261,13 @@ components:
|
||||
AccountSwitchState:
|
||||
type: object
|
||||
properties:
|
||||
recordSession:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/RecordSessionState"
|
||||
- type: "null"
|
||||
description: "The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an
|
||||
unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the
|
||||
moment the target equalled the current selector. Adopt this value; do not derive it."
|
||||
actingAs:
|
||||
anyOf:
|
||||
- type: object
|
||||
@@ -37198,6 +37294,7 @@ components:
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
required:
|
||||
- recordSession
|
||||
- actingAs
|
||||
additionalProperties: false
|
||||
description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and
|
||||
@@ -37638,6 +37735,34 @@ components:
|
||||
sync engine, device or notification registration, or any auth/refresh call: those are about the person holding
|
||||
the token, not the record being read, and the account payload refuses the header outright for exactly that
|
||||
reason."
|
||||
RecordEpoch:
|
||||
name: X-HealthLog-Record-Epoch
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 15
|
||||
description: "The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET
|
||||
/api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it.
|
||||
Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been
|
||||
pointed at another record is served without either header, which is what keeps a tab open across the deploy
|
||||
working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a
|
||||
mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry),
|
||||
and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers
|
||||
by leaving the record and reloading)."
|
||||
RecordScope:
|
||||
name: X-HealthLog-Record-Scope
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self`
|
||||
standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one
|
||||
without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are
|
||||
echoed back on any response that actually resolved a record scope, and a client discards a response whose echo
|
||||
contradicts the context it is now on — a response carrying no echo resolved no record scope and is served
|
||||
normally.
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Structural guards on the record-session fence.
|
||||
*
|
||||
* The behavioural proofs live in `src/lib/sharing/__tests__/record-session-fence.test.ts`
|
||||
* (the verdict and the refusals), `tests/integration/record-session-fence.test.ts`
|
||||
* (a refusal before any database work, with a positive control) and
|
||||
* `tests/integration/record-session-fence-idempotency.test.ts` (a refusal above
|
||||
* the replay cache). Those prove what today's code does. This file freezes what
|
||||
* tomorrow's may not do, because the fence has one shape that makes it worth
|
||||
* anything:
|
||||
*
|
||||
* * exactly two auth helpers consult it, and both consult it BEFORE they read
|
||||
* a carrier or look up a grant;
|
||||
* * the helpers that deliberately do not consult it still do not;
|
||||
* * one file writes the epoch and a closed list reads it;
|
||||
* * the header names live in four files and no fifth.
|
||||
*
|
||||
* ## What these matchers cannot see
|
||||
*
|
||||
* Everything here is a text match over source, so the guard's own limit is
|
||||
* worth writing down the way `session-surface-guard.test.ts` writes down its
|
||||
* own: **a fence bypass under a different header name would slip every matcher
|
||||
* below.** A future module that read `x-healthlog-record-context` and made its
|
||||
* own admission decision would be invisible here, exactly as the share-link
|
||||
* tree was invisible to a sweep that grepped only for `getSession`. The header
|
||||
* leg pins the names that exist; it cannot pin the ones that do not yet.
|
||||
*
|
||||
* Nor can it prove ordering at runtime. The placement legs compare source
|
||||
* offsets inside a function body, which is a proxy for execution order and
|
||||
* holds only because neither call sits inside a branch. The runtime property —
|
||||
* "no grant was looked up" — is proved by the integration file's `lastUsedAt`
|
||||
* and `sharing.record.accessed` instruments and by the `findActiveGrant`
|
||||
* zero-call spy, not here.
|
||||
*
|
||||
* Every leg asserts a non-zero match count before it asserts a set, because a
|
||||
* guard whose matcher finds nothing reports success, and this repository has
|
||||
* shipped several of those.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function filesMatching(re: RegExp): string[] {
|
||||
return sourceFiles().filter((rel) => re.test(read(rel)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The source text of a top-level exported function, braces included. Same
|
||||
* brace-matching walk `acting-account-boundary-guard.test.ts` uses.
|
||||
*/
|
||||
function functionBody(src: string, name: string): string {
|
||||
const start = src.indexOf(`export async function ${name}(`);
|
||||
if (start === -1) return "";
|
||||
const open = src.indexOf("{", src.indexOf(")", start));
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === "{") depth++;
|
||||
else if (src[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const API_HANDLER = read("lib/api-handler.ts");
|
||||
|
||||
/** The two helpers that resolve a record and therefore must consult the fence. */
|
||||
const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
|
||||
|
||||
/**
|
||||
* The helpers that deliberately do NOT consult the fence.
|
||||
*
|
||||
* This is a statement about the HELPERS and must not be read as "no admin route
|
||||
* is ever fenced". `POST /api/admin/backups/[id]/restore` composes
|
||||
* `withIdempotency` OUTSIDE `apiHandler`, so it is fence-visible through the
|
||||
* idempotency arm even though `requireAdmin` itself is on this list — and that
|
||||
* is wanted, not an oversight. An idempotent admin write under an unprovable
|
||||
* context should be refused like any other.
|
||||
*
|
||||
* Each entry's reason, once:
|
||||
*
|
||||
* * `requireActorAuth` is the way OUT of a switch. Fencing it would make an
|
||||
* unprovable context unrecoverable, and it serves the caller's own rows by
|
||||
* definition, so the selector cannot change what it returns.
|
||||
* * `requireAuth` bare already refuses outright under an active switch, and
|
||||
* its routes are classified actor-only elsewhere.
|
||||
* * `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` resolve through
|
||||
* `getSession()` and are structurally unreachable by a switch.
|
||||
*/
|
||||
const UNFENCED_HELPERS = [
|
||||
"requireAuth",
|
||||
"requireActorAuth",
|
||||
"requireAdmin",
|
||||
"requireCookieAuth",
|
||||
"requireFreshMfa",
|
||||
] as const;
|
||||
|
||||
describe("the fence runs in exactly two auth helpers", () => {
|
||||
it("is called by both record resolvers and by nothing else in api-handler", () => {
|
||||
const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)];
|
||||
// Non-zero proof: an emptied file would agree with an emptied expectation.
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
const callers = FENCED_RESOLVERS.filter((name) =>
|
||||
functionBody(API_HANDLER, name).includes("assertRecordSessionFence("),
|
||||
);
|
||||
expect(callers).toEqual([...FENCED_RESOLVERS]);
|
||||
// Two call sites, one import. Anything else means a third caller appeared.
|
||||
expect(calls.length).toBe(FENCED_RESOLVERS.length);
|
||||
});
|
||||
|
||||
it.each(FENCED_RESOLVERS)(
|
||||
"%s fences after authentication and before the carrier and the grant",
|
||||
(name) => {
|
||||
const body = functionBody(API_HANDLER, name);
|
||||
expect(body.length).toBeGreaterThan(100);
|
||||
|
||||
const fence = body.indexOf("assertRecordSessionFence(");
|
||||
const authenticate = body.indexOf("authenticateCaller(");
|
||||
const carrier = body.indexOf("readActingCarrier(");
|
||||
expect(fence).toBeGreaterThan(-1);
|
||||
expect(authenticate).toBeGreaterThan(-1);
|
||||
expect(carrier).toBeGreaterThan(-1);
|
||||
|
||||
// `authenticateCaller` NECESSARILY precedes the fence: it supplies the
|
||||
// session row the fence reads, and the whole atomicity argument rests on
|
||||
// both facts coming off that one read. So the invariant is "after
|
||||
// authentication, before the carrier" — NOT "before the first database
|
||||
// await", which would be false and must not be asserted here.
|
||||
expect(fence).toBeGreaterThan(authenticate);
|
||||
expect(fence).toBeLessThan(carrier);
|
||||
},
|
||||
);
|
||||
|
||||
it("fences before any grant lookup in the shared resolver path", () => {
|
||||
// `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers
|
||||
// tail-call. Assert it appears nowhere ABOVE the fence in either body.
|
||||
const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)];
|
||||
expect(grantCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const name of FENCED_RESOLVERS) {
|
||||
const body = functionBody(API_HANDLER, name);
|
||||
const fence = body.indexOf("assertRecordSessionFence(");
|
||||
const grant = body.indexOf("findActiveGrant(");
|
||||
// Either the body never names it (it is in the shared tail) or it names
|
||||
// it after the fence. Both are the property; neither may invert.
|
||||
if (grant !== -1) expect(fence).toBeLessThan(grant);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => {
|
||||
const body = functionBody(API_HANDLER, name);
|
||||
expect(body.length).toBeGreaterThan(50);
|
||||
expect(body).not.toContain("assertRecordSessionFence");
|
||||
});
|
||||
|
||||
it("the two unfenced routes stay unfenced", () => {
|
||||
// `/api/auth/me` is the reconciliation bootstrap and must answer while the
|
||||
// client's context is unknown; the switch endpoint is the way out of a
|
||||
// context that cannot be proved. Fencing either would make an unprovable
|
||||
// context unrecoverable.
|
||||
const me = read("app/api/auth/me/route.ts");
|
||||
const sw = read("app/api/account/switch/route.ts");
|
||||
expect(me).toContain("requireActorAuth");
|
||||
expect(sw).toContain("requireActorAuth");
|
||||
expect(me).not.toContain("assertRecordSessionFence");
|
||||
expect(sw).not.toContain("assertRecordSessionFence");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both lists below grow as the feature lands, and that is the point rather than
|
||||
* a defect: the idempotency arm and the browser's adopted-context module each
|
||||
* arrive as a visible diff here rather than as a quiet new reader. Same posture
|
||||
* as `CARRIER_ALLOWLIST` in `acting-account-boundary-guard.test.ts`.
|
||||
*/
|
||||
describe("the epoch has one writer and a closed reader set", () => {
|
||||
it("is written in exactly one file", () => {
|
||||
// The SQL spelling only. `acting-session.ts` owns the conditional write
|
||||
// because it needs `RETURNING`, which no model delegate expresses — and
|
||||
// keeping the statement there is what keeps this list at one.
|
||||
const writers = filesMatching(/record_epoch/);
|
||||
expect(writers.length).toBeGreaterThan(0);
|
||||
expect(writers).toEqual(["lib/sharing/acting-session.ts"]);
|
||||
});
|
||||
|
||||
it("is read by a closed list and nothing else", () => {
|
||||
// The identifier, bounded so `recordEpochParameter` in the OpenAPI module
|
||||
// does not count — that file publishes a header name, it does not read a
|
||||
// column.
|
||||
const readers = filesMatching(/\brecordEpoch(?![\w$])/);
|
||||
expect(readers.length).toBeGreaterThan(0);
|
||||
expect(readers).toEqual(
|
||||
[
|
||||
// Projects it off the session row it already loaded.
|
||||
"lib/auth/session.ts",
|
||||
// Carries it on the auth context the fence is handed.
|
||||
"lib/api-handler.ts",
|
||||
// Compares it. The one place that decides anything from it.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
// is why no file under `hooks/` or `components/` appears in the list above.
|
||||
// A client that started naming `recordEpoch` would be reaching past the
|
||||
// published shape into the row.
|
||||
const clientReaders = filesMatching(/\brecordEpoch(?![\w$])/).filter(
|
||||
(rel) =>
|
||||
rel.startsWith("hooks/") ||
|
||||
rel.startsWith("components/") ||
|
||||
rel.startsWith("app/"),
|
||||
);
|
||||
expect(clientReaders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the header names live in a closed set of files", () => {
|
||||
it("is declared, read and published — and nowhere else", () => {
|
||||
// Mirrors the `x-healthlog-account` leg in
|
||||
// `acting-account-boundary-guard.test.ts`. A route that started reading
|
||||
// these headers itself would be making an admission decision the fence
|
||||
// exists to make once.
|
||||
const namers = filesMatching(
|
||||
/RECORD_EPOCH_HEADER|RECORD_SCOPE_HEADER|x-healthlog-record-(epoch|scope)/i,
|
||||
);
|
||||
expect(namers.length).toBeGreaterThan(0);
|
||||
expect(namers).toEqual([
|
||||
// Publishes them to the clients that have to send them.
|
||||
"lib/openapi/routes/account-sharing.ts",
|
||||
// Declares them.
|
||||
"lib/sharing/record-session-fence-contract.ts",
|
||||
// Reads them off the request scope, and writes the response echo.
|
||||
// `apiHandler` attaches the echo through this module's helper rather than
|
||||
// naming the headers itself, which is what keeps this list at four.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the 409's code in the contract module and the client that branches on it", () => {
|
||||
const namers = filesMatching(/sharing\.session\.changed/);
|
||||
expect(namers.length).toBeGreaterThan(0);
|
||||
// The error class reads the constant rather than restating the string, so
|
||||
// `api-handler.ts` is deliberately NOT on this list.
|
||||
expect(namers).toContain("lib/sharing/record-session-fence-contract.ts");
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,37 @@
|
||||
* authorises nothing, and the resolver re-checks the grant on every request
|
||||
* that follows. A value stranded here by a revocation seconds later confers
|
||||
* exactly nothing.
|
||||
*
|
||||
* v1.37.0 — unfenced, deliberately, and the reason is the same one that makes
|
||||
* it an actor surface: this is the way back. A session whose context cannot be
|
||||
* proved must still be able to leave the record it may or may not be in, and
|
||||
* fencing the exit would make an unprovable context unrecoverable.
|
||||
*
|
||||
* What it takes instead is an optional `expectedEpoch`, which turns the
|
||||
* selector write into a compare-and-set. Two tabs pressing the switcher at once
|
||||
* both name the epoch they saw; one `UPDATE` matches, the loser gets zero rows
|
||||
* and the same 409 the fence raises, and the browser reconciles through
|
||||
* `/api/auth/me` before retrying. That is the monotonic ordering a server-side
|
||||
* pending lease was proposed for, in one statement, with no intermediate state
|
||||
* for a crashed initiator to strand.
|
||||
*
|
||||
* An ABSENT `expectedEpoch` keeps the unconditional write, and the two halves
|
||||
* of what that means are worth stating together:
|
||||
*
|
||||
* * It is reachable only by a bundle that predates the fence. The switcher is
|
||||
* rendered from `/api/auth/me` data, so a fence-aware client has adopted a
|
||||
* context before it can offer a switch at all — it never omits the field.
|
||||
* * It can therefore clobber a concurrent fenced switch. That is accepted:
|
||||
* the fenced tab's next request asserts an epoch the row no longer holds,
|
||||
* it takes the 409, and it reconciles to the truth; the pre-fence tab
|
||||
* reloads into a fence-aware bundle immediately afterwards. Fail-closed and
|
||||
* self-healing, bounded to the deploy window.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
RecordSessionChangedError,
|
||||
requireActorAuth,
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
@@ -39,7 +65,10 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { pointSessionAt } from "@/lib/sharing/acting-session";
|
||||
import {
|
||||
pointSessionAt,
|
||||
pointSessionAtIfUnchanged,
|
||||
} from "@/lib/sharing/acting-session";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { switchAccountSchema } from "@/lib/validations/account-sharing";
|
||||
|
||||
@@ -66,18 +95,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { accountId } = parsed.data;
|
||||
const { accountId, expectedEpoch } = parsed.data;
|
||||
const ip = getClientIp(request);
|
||||
|
||||
/**
|
||||
* Move the selector, conditionally when the caller named an epoch.
|
||||
*
|
||||
* Returns the record context the session ends up in, so the response can
|
||||
* publish it — this endpoint and `/api/auth/me` are the only two responses a
|
||||
* client may ADOPT a context from, and both carry it in the body. Every other
|
||||
* response's echo is used to validate and never to adopt.
|
||||
*
|
||||
* `null` on the unconditional arm, and that is the honest answer rather than
|
||||
* a gap: the epoch after an unconditional write is whatever the trigger
|
||||
* decided, this statement did not read it back, and a number computed here
|
||||
* would be wrong the moment the target equalled the current selector (the
|
||||
* trigger's `IS DISTINCT FROM` clause declines to move on a no-op write). A
|
||||
* caller that omitted `expectedEpoch` is by construction a bundle that would
|
||||
* not read the field anyway, and `/api/auth/me` states the truth on its next
|
||||
* boot.
|
||||
*/
|
||||
async function moveSelector(
|
||||
target: string | null,
|
||||
): Promise<{ epoch: number; scope: string | null } | null | "stale"> {
|
||||
if (expectedEpoch === undefined) {
|
||||
await pointSessionAt(auth.session.id, auth.user.id, target);
|
||||
return null;
|
||||
}
|
||||
const outcome = await pointSessionAtIfUnchanged(
|
||||
auth.session.id,
|
||||
auth.user.id,
|
||||
target,
|
||||
expectedEpoch,
|
||||
);
|
||||
if (outcome.kind === "stale") return "stale";
|
||||
return { epoch: outcome.epoch, scope: target };
|
||||
}
|
||||
|
||||
if (accountId === null) {
|
||||
await pointSessionAt(auth.session.id, auth.user.id, null);
|
||||
const moved = await moveSelector(null);
|
||||
if (moved === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
await auditLog("sharing.switch.off", {
|
||||
userId: auth.user.id,
|
||||
details: {},
|
||||
ipAddress: ip,
|
||||
}).catch(() => {});
|
||||
annotate({ action: { name: "sharing.switch.off" } });
|
||||
return apiSuccess({ actingAs: null });
|
||||
return apiSuccess({ actingAs: null, recordSession: moved });
|
||||
}
|
||||
|
||||
// The same predicate the resolver uses, on the same table, with no second
|
||||
@@ -105,7 +172,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
});
|
||||
|
||||
await pointSessionAt(auth.session.id, auth.user.id, owner.id);
|
||||
const moved = await moveSelector(owner.id);
|
||||
if (moved === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
|
||||
await auditLog("sharing.switch.on", {
|
||||
userId: auth.user.id,
|
||||
@@ -125,5 +196,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
displayName: owner.displayName,
|
||||
access: grant.access,
|
||||
},
|
||||
recordSession: moved,
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/lib/modules/gate";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -173,5 +174,23 @@ export const GET = apiHandler(async () => {
|
||||
// directly. Always present — an account with no grants gets an empty list,
|
||||
// not a missing field.
|
||||
accountAccess,
|
||||
// v1.37.0 — the record-session fence's bootstrap, and the only place a
|
||||
// browser can learn its context from scratch.
|
||||
//
|
||||
// `epoch` is the session row's selector counter; `scope` is the account the
|
||||
// session is pointed at, or null for its own record. The client adopts both
|
||||
// and attaches them to every subsequent same-origin request, so the server
|
||||
// can refuse a request formed under a context that has since moved. This
|
||||
// response and `POST /api/account/switch` are the only two a client may
|
||||
// ADOPT from — every other response carries the context in headers, and
|
||||
// those are used to validate a response, never to learn one.
|
||||
//
|
||||
// Null on the Bearer transport, which has no session row and no switch
|
||||
// state to be stale about. A native client neither sends nor receives the
|
||||
// fence headers; the frozen contract is untouched.
|
||||
// Derived by the fence rather than read off the context here: the columns
|
||||
// behind it have one reader by design, and a route that projected them
|
||||
// itself would be a second statement of what a record context is.
|
||||
recordSession: recordSessionForPayload(auth),
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,21 @@ export interface AuthUser {
|
||||
* server-side switch cannot paint owner controls in the target record.
|
||||
*/
|
||||
accountAccessStatus?: AccountAccessStatus;
|
||||
/**
|
||||
* v1.37.0 — the record-session context the server says this browser is in.
|
||||
*
|
||||
* `epoch` counts how many times this session's record selector has moved and
|
||||
* `scope` names the record it is pointed at (null for one's own). Adopted by
|
||||
* `fetchMe` and then attached to every same-origin request, so a request
|
||||
* formed under a context that has since moved is refused rather than served
|
||||
* against the wrong record.
|
||||
*
|
||||
* Null on the Bearer transport, which carries no such state. Optional so
|
||||
* existing `AuthUser` fixtures stay valid; an absent field is an older server
|
||||
* image and leaves the client unadopted, which the `bootstrap` sentinel makes
|
||||
* safe.
|
||||
*/
|
||||
recordSession?: { epoch: number; scope: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,11 +118,13 @@ export function resolveRecordCapabilities(
|
||||
active: AccountAccessEntry | null | undefined,
|
||||
accessRefused = false,
|
||||
recordSessionPending = false,
|
||||
contextUnproven = false,
|
||||
): RecordCapabilities {
|
||||
if (accessRefused || recordSessionPending) {
|
||||
if (accessRefused || recordSessionPending || contextUnproven) {
|
||||
return {
|
||||
accessRefused: accessRefused || undefined,
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
recordSessionPending:
|
||||
recordSessionPending || contextUnproven || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
@@ -174,6 +176,36 @@ export function resolveRecordCapabilities(
|
||||
* withdraws is a much smaller lie than one that stays and 403s, and the
|
||||
* banner lands from the same query in the same frame.
|
||||
*/
|
||||
/**
|
||||
* v1.37.0 — do the two answers about "which record is this" agree.
|
||||
*
|
||||
* `/api/auth/me` publishes the question twice, from two different angles, and
|
||||
* that is deliberate rather than redundant:
|
||||
*
|
||||
* * `accountAccess.active` is the RE-DECIDED answer. An entry reaches it only
|
||||
* by surviving a live-grant pass, so a selector left behind by a lapsed
|
||||
* grant reads as "not switched".
|
||||
* * `recordSession.scope` is the RAW selector, the same value the fence
|
||||
* compares a request's assertion against.
|
||||
*
|
||||
* When they disagree the session is pointed at a record the grant no longer
|
||||
* opens: every delegable route will refuse, while `active` being null makes
|
||||
* this hook answer "your own record, all controls". That combination paints an
|
||||
* add button on a page whose every write is about to 403 — the exact failure
|
||||
* `canAdd` was introduced to end. So a disagreement holds instead.
|
||||
*
|
||||
* A null or absent `recordSession` is not a disagreement. It is the Bearer
|
||||
* transport (no session row, no switch state) or a server image that predates
|
||||
* the field, and neither is a reason to blank the app.
|
||||
*/
|
||||
export function recordContextIsUnproven(
|
||||
recordSession: { epoch: number; scope: string | null } | null | undefined,
|
||||
active: AccountAccessEntry | null | undefined,
|
||||
): boolean {
|
||||
if (recordSession == null) return false;
|
||||
return recordSession.scope !== (active?.accountId ?? null);
|
||||
}
|
||||
|
||||
export function useRecordCapabilities(): RecordCapabilities {
|
||||
const { user } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
@@ -181,6 +213,7 @@ export function useRecordCapabilities(): RecordCapabilities {
|
||||
user?.accountAccess?.active,
|
||||
user?.accountAccessStatus === "invalid",
|
||||
transition.phase !== "ready",
|
||||
recordContextIsUnproven(user?.recordSession, user?.accountAccess?.active),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,10 +151,16 @@ function user(
|
||||
/**
|
||||
* Sign the delegate in over the cookie transport.
|
||||
*
|
||||
* `recordEpoch` defaults to 0, which is the record-session fence's exemption:
|
||||
* a session that has never been pointed at another record is served without a
|
||||
* fence header, so every pre-fence case in this file keeps meaning what it
|
||||
* meant. Cases that want the fence live pass an epoch explicitly.
|
||||
* v1.37.0 — this also attaches a TRUTHFUL record-session assertion, because
|
||||
* that is what the shipped browser client does on every same-origin request.
|
||||
* Without it every switched case in this file would be testing the fence's
|
||||
* pre-fence-bundle arm rather than the resolver arm it was written for: a
|
||||
* session pointed at a record is fenced, and a request carrying no assertion at
|
||||
* all is refused with 403 before the resolver decides anything.
|
||||
*
|
||||
* `recordEpoch` defaults to 0. A session at 0 pointed at its own record is the
|
||||
* fence's exemption, so the un-switched cases are byte-for-byte what they were.
|
||||
* Cases that want a stale or absent assertion override the headers themselves.
|
||||
*/
|
||||
function signedInCookie(
|
||||
actingAsUserId: string | null = null,
|
||||
@@ -170,6 +176,8 @@ function signedInCookie(
|
||||
},
|
||||
user: user(DELEGATE, role) as never,
|
||||
});
|
||||
headerJar.set("x-healthlog-record-epoch", String(recordEpoch));
|
||||
headerJar.set("x-healthlog-record-scope", actingAsUserId ?? "self");
|
||||
}
|
||||
|
||||
/** Sign the delegate in over the Bearer transport. */
|
||||
@@ -906,3 +914,115 @@ describe("the guardian resolver gates on the marker, not the grant", () => {
|
||||
expect(fencedBody).not.toContain("managed");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the record-session fence, from the resolver's side.
|
||||
*
|
||||
* The integration file proves the refusal against a real database with the
|
||||
* `lastUsedAt` / `sharing.record.accessed` instrument. What this adds is the
|
||||
* cheapest falsifiable statement of the same property: the grant query is not
|
||||
* issued at all.
|
||||
*
|
||||
* The instrument is `prisma.accountGrant.findFirst`, not a mocked
|
||||
* `findActiveGrant`. This file deliberately does not mock `@/lib/sharing/grants`
|
||||
* — the resolver consuming that module's own idea of "active" is a property
|
||||
* under test here, and a mocked predicate would report it working while it was
|
||||
* broken. The Prisma spy is one layer lower, is honest about the same fact, and
|
||||
* costs nothing.
|
||||
*/
|
||||
describe("the record-session fence, above the grant lookup", () => {
|
||||
it("issues no grant query when the asserted epoch is behind", async () => {
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
headerJar.set("x-healthlog-record-epoch", "3");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
|
||||
const res = await route(() => requireRecordAuth("read", "measurements"))();
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
|
||||
// The whole point: nothing was asked of the grant table.
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
// And nothing was stamped on it either.
|
||||
expect(touched).toEqual([]);
|
||||
});
|
||||
|
||||
it("issues no grant query when the client sent no assertion at all", async () => {
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
// A bundle that predates the fence: no assertion on the wire at all.
|
||||
headerJar.delete("x-healthlog-record-epoch");
|
||||
headerJar.delete("x-healthlog-record-scope");
|
||||
|
||||
const res = await route(() => requireRecordAuth("read", "measurements"))();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POSITIVE CONTROL: a matching assertion does reach the grant query", async () => {
|
||||
// Without this the test above would pass for a resolver that never queried
|
||||
// the grant table at all.
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
headerJar.set("x-healthlog-record-epoch", "4");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
|
||||
const ctx = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
expect(prisma.accountGrant.findFirst).toHaveBeenCalled();
|
||||
expect(touched).toEqual(["grant-1"]);
|
||||
});
|
||||
|
||||
it("guards the guardian resolver the same way", async () => {
|
||||
signedInCookie(OWNER, "USER", 2);
|
||||
userRows.set(OWNER, user(OWNER, "USER", new Date()));
|
||||
liveGrant({ access: "MANAGE" });
|
||||
headerJar.set("x-healthlog-record-epoch", "1");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
|
||||
const res = await route(() => requireGuardianAuth())();
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a never-switched session alone, header or no header", async () => {
|
||||
// The exemption. A session at epoch 0 pointed at its own record is served
|
||||
// byte-for-byte as it was before the fence existed.
|
||||
signedInCookie(null, "USER", 0);
|
||||
headerJar.delete("x-healthlog-record-epoch");
|
||||
headerJar.delete("x-healthlog-record-scope");
|
||||
const ctx = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("echoes the context it served under, and only when it resolved one", async () => {
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
headerJar.set("x-healthlog-record-epoch", "4");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
const served = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
expect(served.headers.get("x-healthlog-record-epoch")).toBe("4");
|
||||
expect(served.headers.get("x-healthlog-record-scope")).toBe(OWNER);
|
||||
|
||||
// An actor surface decides nothing about a record and therefore echoes
|
||||
// nothing. A client that discarded on ABSENCE would throw this away, and
|
||||
// with it the public `/api/version` poll.
|
||||
headerJar.clear();
|
||||
signedInCookie(null, "USER", 0);
|
||||
const actor = await route(() => requireActorAuth())();
|
||||
expect(actor.status).toBe(200);
|
||||
expect(actor.headers.get("x-healthlog-record-epoch")).toBeNull();
|
||||
expect(actor.headers.get("x-healthlog-record-scope")).toBeNull();
|
||||
});
|
||||
});
|
||||
+46
-1
@@ -38,7 +38,10 @@ import {
|
||||
} 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";
|
||||
import {
|
||||
assertRecordSessionFence,
|
||||
attachRecordContextEcho,
|
||||
} from "./sharing/record-session-fence";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
@@ -400,6 +403,28 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
}
|
||||
const nr = response as NextResponse;
|
||||
nr.headers.set("x-request-id", evt.getRequestId());
|
||||
// v1.37.0 — echo the record context this response was actually served
|
||||
// under, when one was decided. The value comes from the wide event and
|
||||
// from nowhere else: the fence stamps it on every call it makes, so
|
||||
// exactly the responses that resolved a record scope carry it and
|
||||
// everything else — public routes, actor surfaces, admin, `/me`, static —
|
||||
// carries nothing.
|
||||
//
|
||||
// Deliberately NOT derived here with a `getSession()`. That would put a
|
||||
// session read on every public route, and it would make the echo a second
|
||||
// derivation of the context rather than a report of the one that was
|
||||
// used, which is the difference between "what this response served" and
|
||||
// "what a later read thinks is true".
|
||||
//
|
||||
// Absence is therefore meaningful and safe: the client discards a
|
||||
// response whose echo CONTRADICTS its adopted context, and serves one
|
||||
// that carries no echo normally. A discard-on-absence rule would throw
|
||||
// away the `/api/version` poll on every cycle.
|
||||
//
|
||||
// The two header names stay inside the fence module, which is what keeps
|
||||
// them to four files overall (declared, read, attached client-side,
|
||||
// published) — see `src/__tests__/record-session-fence-guard.test.ts`.
|
||||
attachRecordContextEcho(nr.headers, evt.getRecordContext());
|
||||
return nr;
|
||||
});
|
||||
};
|
||||
@@ -832,6 +857,21 @@ export async function requireRecordAuth(
|
||||
domain: ShareScope,
|
||||
): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
// v1.37.0 — before any carrier is read, any grant is looked up, or any record
|
||||
// row is touched: does this request still believe what its session believes?
|
||||
//
|
||||
// The atomicity that makes this a tautology rather than a race: the epoch the
|
||||
// fence compares and the `actingAsUserId` the carrier below is built from are
|
||||
// BOTH projected off the one session row `authenticateCaller()` already
|
||||
// loaded. The scope this handler serves under and the epoch it validated are
|
||||
// therefore the same fact read at the same instant, not two reads a commit
|
||||
// can land between. Do not add a second session read here to "refresh" it —
|
||||
// that would reintroduce exactly the window this ordering removes.
|
||||
//
|
||||
// `authenticateCaller` necessarily runs first because it supplies the row the
|
||||
// fence reads, so the invariant is "before the carrier", not "before the
|
||||
// first database await".
|
||||
await assertRecordSessionFence(auth);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
@@ -903,6 +943,11 @@ function escalate(need: GrantNeed): GrantNeed {
|
||||
*/
|
||||
export async function requireGuardianAuth(): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
// Same placement and the same reason as `requireRecordAuth` above: the
|
||||
// guardian surfaces administer a managed profile, so a request arriving under
|
||||
// a context that has since moved must be refused before the marker is read,
|
||||
// not after.
|
||||
await assertRecordSessionFence(auth);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
|
||||
@@ -118,8 +118,45 @@ const grantListResponse = z
|
||||
"Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The record-session context, as the two surfaces a client may ADOPT one from
|
||||
* publish it.
|
||||
*
|
||||
* Both carry it in the response BODY, and that is the rule rather than a
|
||||
* coincidence: every other response reports its context in headers, and those
|
||||
* are for validating a response the client already has, never for learning one.
|
||||
* Two body-carrying sources, both actor surfaces that resolve canonical session
|
||||
* state, and no third path — which is why the 409 refusal below carries only an
|
||||
* error code and never the current epoch.
|
||||
*/
|
||||
const recordSessionState = z
|
||||
.object({
|
||||
epoch: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request, and as `expectedEpoch` when switching.",
|
||||
),
|
||||
scope: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The account this session is pointed at, or null for its own record. Send it back as the `X-HealthLog-Record-Scope` header, with `self` standing for null.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "RecordSessionState",
|
||||
description:
|
||||
"Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which carries its selector per request and accumulates no session state to be stale about. A client adopts this and then asserts it on every same-origin request, so a request formed under a context that has since moved is refused rather than served against the wrong record.",
|
||||
});
|
||||
|
||||
const switchResponse = z
|
||||
.object({
|
||||
recordSession: recordSessionState
|
||||
.nullable()
|
||||
.describe(
|
||||
"The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the moment the target equalled the current selector. Adopt this value; do not derive it.",
|
||||
),
|
||||
actingAs: z
|
||||
.object({
|
||||
accountId: z.string(),
|
||||
@@ -182,6 +219,12 @@ const sharingRefusal = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const recordSessionChanged = {
|
||||
description:
|
||||
"The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a third source of truth.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-request account selector, published once.
|
||||
*
|
||||
@@ -209,6 +252,43 @@ export const accountSelectorParameter: NonNullable<
|
||||
"Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason.",
|
||||
};
|
||||
|
||||
/**
|
||||
* The record-session fence's two request headers, published once.
|
||||
*
|
||||
* Cookie transport only, and stated as such in both descriptions: a Bearer
|
||||
* client neither sends these nor receives them back, and the native contract is
|
||||
* unchanged by the fence in every direction. A browser client attaches both to
|
||||
* every same-origin request — there is no per-route opt-in, because a route
|
||||
* that had to remember is a route that will forget.
|
||||
*
|
||||
* Defined here rather than inline for the same reason the account selector
|
||||
* above is: `src/__tests__/record-session-fence-guard.test.ts` holds the header
|
||||
* names to exactly four files — the contract module that declares them, the
|
||||
* server fence that reads them, the client module that attaches them, and this
|
||||
* one, which publishes them.
|
||||
*/
|
||||
export const recordEpochParameter: NonNullable<
|
||||
NonNullable<ZodOpenApiObject["components"]>["parameters"]
|
||||
>[string] = {
|
||||
name: "X-HealthLog-Record-Epoch",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string", maxLength: 15 },
|
||||
description:
|
||||
"The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET /api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it. Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been pointed at another record is served without either header, which is what keeps a tab open across the deploy working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry), and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers by leaving the record and reloading).",
|
||||
};
|
||||
|
||||
export const recordScopeParameter: NonNullable<
|
||||
NonNullable<ZodOpenApiObject["components"]>["parameters"]
|
||||
>[string] = {
|
||||
name: "X-HealthLog-Record-Scope",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string", maxLength: 64 },
|
||||
description:
|
||||
"The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self` standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are echoed back on any response that actually resolved a record scope, and a client discards a response whose echo contradicts the context it is now on — a response carrying no echo resolved no record scope and is served normally.",
|
||||
};
|
||||
|
||||
// ── What the client is told it may do ────────────────────────────────────────
|
||||
|
||||
export const accountAccessBlock = accountAccessBlockSchema.meta({
|
||||
@@ -231,6 +311,7 @@ const accountPayload = z
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
accountAccess: accountAccessBlock,
|
||||
recordSession: recordSessionState.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountPayload",
|
||||
@@ -466,7 +547,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Act on a granted record, or return to your own",
|
||||
description:
|
||||
"Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out.",
|
||||
"Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409 `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and retry once against the reconciled epoch.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: switchAccountSchema } },
|
||||
@@ -487,6 +568,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": sharingRefusal,
|
||||
"409": recordSessionChanged,
|
||||
"422": {
|
||||
description: "Validation failed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
|
||||
@@ -24,6 +24,8 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import {
|
||||
accountSelectorParameter,
|
||||
accountSharingPaths,
|
||||
recordEpochParameter,
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { allergyPaths } from "./allergies";
|
||||
@@ -156,7 +158,14 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
// module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps
|
||||
// holding the header's name to two files: the resolver that reads it, and
|
||||
// the one place that publishes it.
|
||||
parameters: { AccountSelector: accountSelectorParameter },
|
||||
// v1.37.0 — the record-session fence's two request headers, defined in the
|
||||
// same module for the same reason: the fence guard holds their names to four
|
||||
// files, and this index references rather than restates them.
|
||||
parameters: {
|
||||
AccountSelector: accountSelectorParameter,
|
||||
RecordEpoch: recordEpochParameter,
|
||||
RecordScope: recordScopeParameter,
|
||||
},
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
|
||||
@@ -24,8 +24,27 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
/**
|
||||
* The narrow client slice these functions need, so a caller can hand them a
|
||||
* transaction handle. Same shape as `grants.ts` uses for `accountGrant`.
|
||||
*
|
||||
* v1.37.0 — widened by one member. The conditional switch below needs
|
||||
* `RETURNING`, which no model delegate can express, so it goes through
|
||||
* `$queryRaw`. Widening the slice is what keeps the raw statement in THIS file:
|
||||
* the alternative is a caller that names `record_epoch` itself, and "only
|
||||
* `acting-session.ts` writes the selector" is the property the boundary guard
|
||||
* in `src/__tests__/acting-account-boundary-guard.test.ts` exists to hold. Both
|
||||
* existing call sites already pass `prisma` or a transaction handle, and both
|
||||
* satisfy the wider shape unchanged.
|
||||
*/
|
||||
type SessionDb = Pick<Prisma.TransactionClient, "session">;
|
||||
type SessionDb = Pick<Prisma.TransactionClient, "session" | "$queryRaw">;
|
||||
|
||||
/**
|
||||
* The outcome of a conditional switch.
|
||||
*
|
||||
* `stale` is not a failure of the write; it is another tab having got there
|
||||
* first. The caller turns it into the same 409 the fence raises, and the
|
||||
* browser reconciles through `/api/auth/me` rather than retrying blind.
|
||||
*/
|
||||
export type PointSessionOutcome =
|
||||
{ kind: "applied"; epoch: number } | { kind: "stale" };
|
||||
|
||||
/**
|
||||
* Point one browser session at an account's record, or (with `null`) back at
|
||||
@@ -48,6 +67,46 @@ export async function pointSessionAt(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — the same write, conditional on the epoch the caller believes the
|
||||
* session is at.
|
||||
*
|
||||
* One statement, and that is the whole design. Two tabs pressing the switcher
|
||||
* at the same moment both name the epoch they saw; exactly one `UPDATE` matches
|
||||
* the `WHERE`, and the loser gets zero rows back. That is the monotonic
|
||||
* ordering guarantee, expressed as a compare-and-set rather than as a
|
||||
* server-side pending lease with an expiry and an intermediate state that a
|
||||
* crashed initiator can strand.
|
||||
*
|
||||
* `RETURNING "record_epoch"` reads the value the TRIGGER assigned, in the same
|
||||
* statement that assigned it. So the epoch handed back to the client is the one
|
||||
* the database committed and not a number this file computed — which matters,
|
||||
* because a client that adopts a computed epoch would then assert a context the
|
||||
* row does not hold and refuse itself on its very next request.
|
||||
*
|
||||
* Parameter-bound throughout: `$queryRaw` as a tagged template, no
|
||||
* interpolation, no `Unsafe` variant.
|
||||
*/
|
||||
export async function pointSessionAtIfUnchanged(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
actingAsUserId: string | null,
|
||||
expectedEpoch: number,
|
||||
db: SessionDb = prisma,
|
||||
): Promise<PointSessionOutcome> {
|
||||
const rows = await db.$queryRaw<{ record_epoch: number }[]>`
|
||||
UPDATE "sessions"
|
||||
SET "acting_as_user_id" = ${actingAsUserId}
|
||||
WHERE "id" = ${sessionId}
|
||||
AND "user_id" = ${userId}
|
||||
AND "record_epoch" = ${expectedEpoch}
|
||||
RETURNING "record_epoch"
|
||||
`;
|
||||
const applied = rows[0];
|
||||
if (!applied) return { kind: "stale" };
|
||||
return { kind: "applied", epoch: Number(applied.record_epoch) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every session of `granteeId` that is currently acting as `grantorId`.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_SCOPE_HEADER,
|
||||
parseAssertedContext,
|
||||
recordScopeHeaderValue,
|
||||
recordSessionFenceVerdict,
|
||||
type AssertedRecordContext,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
@@ -76,6 +77,56 @@ export async function readAssertedRecordContext(): Promise<AssertedRecordContext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the context this response was served under onto its headers.
|
||||
*
|
||||
* Called once, from `apiHandler`, with whatever the fence stamped on the wide
|
||||
* event — which is `undefined` on every response that never resolved a record
|
||||
* scope. So the echo appears on exactly the responses where it means something,
|
||||
* and its ABSENCE means "nothing about a record was decided here" rather than
|
||||
* "unknown". That is what lets the browser's rule be discard-on-mismatch rather
|
||||
* than discard-on-absence: a discard-on-absence client would throw away the
|
||||
* public `/api/version` poll, the switcher list, admin, `/me` and every static
|
||||
* response.
|
||||
*
|
||||
* It lives here, not in `apiHandler`, so the two header names stay inside the
|
||||
* fence: declared in the contract module, read here, attached by the browser's
|
||||
* `src/lib/api/record-fence.ts`, and published by the OpenAPI route module. A
|
||||
* fifth file naming them is a route deciding something the fence decides once.
|
||||
*/
|
||||
export function attachRecordContextEcho(
|
||||
headers: Headers,
|
||||
context: { epoch: number; scope: string | null } | undefined,
|
||||
): void {
|
||||
if (!context) return;
|
||||
headers.set(RECORD_EPOCH_HEADER, String(context.epoch));
|
||||
headers.set(RECORD_SCOPE_HEADER, recordScopeHeaderValue(context.scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* The record context to publish in the account payload, for a client that has
|
||||
* none yet.
|
||||
*
|
||||
* `GET /api/auth/me` is the fence's bootstrap: a browser cannot assert a
|
||||
* context until something tells it one, and this is that something. It lives
|
||||
* here rather than in the route because the two columns behind it have one
|
||||
* reader by design — a route projecting them itself would be a second statement
|
||||
* of what a record context is, and the second copy is the one that drifts.
|
||||
*
|
||||
* Null on the Bearer transport, which has no session row and no switch state to
|
||||
* be stale about. The native client neither sends nor receives fence headers,
|
||||
* and this field tells it so explicitly rather than by omission.
|
||||
*/
|
||||
export function recordSessionForPayload(
|
||||
auth: AuthContext,
|
||||
): { epoch: number; scope: string | null } | null {
|
||||
if (auth.authMethod !== "cookie") return null;
|
||||
return {
|
||||
epoch: auth.session.recordEpoch ?? 0,
|
||||
scope: auth.session.actingAsUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse this request if the record context it asserted is not the one its
|
||||
* session is on.
|
||||
|
||||
@@ -108,6 +108,28 @@ export type InviteGrantBody = z.infer<typeof inviteGrantSchema>;
|
||||
*/
|
||||
export const switchAccountSchema = z.object({
|
||||
accountId: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).nullable(),
|
||||
/**
|
||||
* v1.37.0 — the record epoch the caller believes this session is at.
|
||||
*
|
||||
* Present turns the selector write into a compare-and-set: exactly one of two
|
||||
* tabs pressing the switcher at the same moment lands, and the loser gets a
|
||||
* 409 rather than silently overwriting the winner. It is not a credential —
|
||||
* the epoch is server-issued, monotonic, and scoped to the caller's own
|
||||
* session, so naming it can only ever refuse the caller's own write.
|
||||
*
|
||||
* Absent keeps the pre-fence unconditional write, and that arm is reachable
|
||||
* only by a bundle that predates the fence: the switcher is rendered from
|
||||
* `/api/auth/me` data, so a fence-aware client has necessarily adopted a
|
||||
* context before it can offer a switch at all.
|
||||
*/
|
||||
expectedEpoch: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
"The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a previous switch. Present makes the write conditional: if another tab moved the selector first the switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me. Omitted keeps the unconditional write, which is what a client predating the record-session fence sends. Cookie transport only.",
|
||||
),
|
||||
});
|
||||
|
||||
export type SwitchAccountBody = z.infer<typeof switchAccountSchema>;
|
||||
@@ -181,6 +181,25 @@ async function switchTo(sessionId: string, ownerId: string | null) {
|
||||
where: { id: sessionId },
|
||||
data: { actingAsUserId: ownerId },
|
||||
});
|
||||
await assertCurrentContext(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — attach the record-session assertion the shipped browser client
|
||||
* attaches to every same-origin request.
|
||||
*
|
||||
* Without it every switched case in this file would be exercising the fence's
|
||||
* pre-fence-bundle arm rather than the resolver arm it was written for: a
|
||||
* session pointed at a record is fenced, and a request carrying no assertion is
|
||||
* refused with 403 before the resolver decides anything. The assertion is read
|
||||
* back off the row rather than computed, so it is truthful by construction.
|
||||
*/
|
||||
async function assertCurrentContext(sessionId: string) {
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
headerJar.set("x-healthlog-record-epoch", String(row.recordEpoch));
|
||||
headerJar.set("x-healthlog-record-scope", row.actingAsUserId ?? "self");
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -415,6 +434,12 @@ describe("acting-account resolver — the carrier column", () => {
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.actingAsUserId).toBeNull();
|
||||
|
||||
// The deletion also moved the record epoch, so a browser still asserting
|
||||
// the pre-deletion context is refused by the fence — that transition is
|
||||
// proved in `tests/integration/record-session-fence.test.ts`. What THIS
|
||||
// test is about is the column, so it reconciles first (the browser's own
|
||||
// `/api/auth/me` bootstrap) and then reads.
|
||||
await assertCurrentContext(session.id);
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.scopeUserId).toBe(delegate.id);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* The record-session fence against real Postgres, real routes and the real
|
||||
* resolver.
|
||||
*
|
||||
* ## Why a status code is not the proof
|
||||
*
|
||||
* "The request 409s" passes wherever the fence sits — above the grant lookup,
|
||||
* below it, or after the handler has already read the record. What has to be
|
||||
* proved is that a refused request did NO WORK against the record, and this
|
||||
* file uses the two side effects `resolveSwitchedRecord` performs immediately
|
||||
* after admission as the instrument:
|
||||
*
|
||||
* * `AccountGrant.lastUsedAt` is stamped by `touchGrantUsage`;
|
||||
* * a day-coalesced `audit_logs` row with `action = 'sharing.record.accessed'`
|
||||
* is written by `recordDelegatedAccess`.
|
||||
*
|
||||
* Both run only past admission, so their ABSENCE is the negative instrument and
|
||||
* their PRESENCE is the positive control. They are fire-and-forget, so the
|
||||
* negative assertion is immediate while the positive one polls briefly. Prisma
|
||||
* query logging is not an option here: `src/lib/db.ts` builds the client with no
|
||||
* `log` config, so `prisma.$on("query")` does not exist.
|
||||
*
|
||||
* ## The positive controls, which are half the file
|
||||
*
|
||||
* A fence that refused everything would pass every negative assertion in this
|
||||
* plan. So three exemptions are proved explicitly rather than by absence:
|
||||
* a Bearer request with no fence header is served (FENCE-AC-10), a cookie
|
||||
* session at `record_epoch = 0` with no fence header is served (FENCE-AC-10),
|
||||
* and a matching assertion is served with both side effects present
|
||||
* (FENCE-AC-04).
|
||||
*
|
||||
* Break this file by deleting the `assertRecordSessionFence(auth)` line from
|
||||
* `requireRecordAuth`: the stale leg fails both on the status and on
|
||||
* `lastUsedAt` having moved.
|
||||
*/
|
||||
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";
|
||||
|
||||
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 { hashToken } from "@/lib/auth/hmac";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_FENCE_BOOTSTRAP,
|
||||
RECORD_FENCE_ERROR_CODE,
|
||||
RECORD_SCOPE_HEADER,
|
||||
RECORD_SCOPE_SELF,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
import { pointSessionAtIfUnchanged } from "@/lib/sharing/acting-session";
|
||||
import {
|
||||
acceptGrant,
|
||||
inviteGrant,
|
||||
revokeGrant,
|
||||
revokeGrantAndClearSwitch,
|
||||
} from "@/lib/sharing/grants";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `fence-${suffix}`,
|
||||
email: `fence-${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 mintToken(userId: string): Promise<string> {
|
||||
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "record-session-fence-test",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function seedWeight(userId: string, value: number) {
|
||||
return getPrismaClient().measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A delegable read, assembled from the real wrapper and the real resolver so
|
||||
* what is under test is the fence rather than one domain's query shape.
|
||||
*/
|
||||
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { user, actor } = await requireRecordAuth("read", "measurements");
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { value: "asc" },
|
||||
});
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
scopeUserId: user.id,
|
||||
actorUserId: actor.id,
|
||||
values: rows.map((r) => r.value),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** A delegable WRITE, so a refused mutation can be shown to write nothing. */
|
||||
const delegableWrite: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { user } = await requireRecordAuth("write", "measurements");
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 99,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: { ok: true }, error: null });
|
||||
},
|
||||
);
|
||||
|
||||
function callRead(): Promise<Response> {
|
||||
return delegableRead(new NextRequest("http://localhost/api/test/delegable"));
|
||||
}
|
||||
|
||||
function callWrite(): Promise<Response> {
|
||||
return delegableWrite(
|
||||
new NextRequest("http://localhost/api/test/delegable", { method: "POST" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert a record context on the next request. */
|
||||
function assertContext(epoch: number | string, scope: string | null): void {
|
||||
headerJar.set(RECORD_EPOCH_HEADER, String(epoch));
|
||||
headerJar.set(RECORD_SCOPE_HEADER, scope ?? RECORD_SCOPE_SELF);
|
||||
}
|
||||
|
||||
async function household(access: "READ" | "WRITE" = "READ") {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedWeight(owner.id, 81);
|
||||
await seedWeight(delegate.id, 64);
|
||||
const invited = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access,
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
granteeId: delegate.id,
|
||||
});
|
||||
return { owner, delegate, grant };
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a session at a record OUT OF BAND — the "raw or external switch" of
|
||||
* acceptance case 04. No client journal runs, no BroadcastChannel fires; the
|
||||
* selector simply moves and the epoch follows it.
|
||||
*/
|
||||
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 grantLastUsedAt(grantId: string): Promise<Date | null> {
|
||||
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
||||
where: { id: grantId },
|
||||
});
|
||||
return row.lastUsedAt;
|
||||
}
|
||||
|
||||
async function delegatedAccessRows(ownerId: string, actorId: string) {
|
||||
return getPrismaClient().auditLog.findMany({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
actorUserId: actorId,
|
||||
action: "sharing.record.accessed",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The two admission side effects are fire-and-forget; give them a moment. */
|
||||
async function waitFor(
|
||||
predicate: () => Promise<boolean>,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("FENCE-AC-04 a stale context is refused before any record work", () => {
|
||||
it("FENCE-AC-04 refuses a delegated read whose epoch is behind, touching neither the grant nor the record", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
// The tab formed its intent one transition ago.
|
||||
assertContext(epoch - 1, owner.id);
|
||||
|
||||
const res = await callRead();
|
||||
const body = await res.json();
|
||||
|
||||
// The instrument comes FIRST, deliberately. Both writes happen only past
|
||||
// admission, so their absence is the proof that no grant was looked up and
|
||||
// no record was opened — where a status code alone would pass with the
|
||||
// fence sitting anywhere, including after the handler had read the record.
|
||||
// Asserting the status first would mask this one behind it whenever the
|
||||
// fence is removed, which is exactly the case this file exists to catch.
|
||||
// Both writes are fire-and-forget, so the assertion is immediate on
|
||||
// purpose: a fence placed after the grant lookup would already have
|
||||
// stamped them by the time the response was built.
|
||||
expect(await grantLastUsedAt(grant.id)).toBeNull();
|
||||
expect(await delegatedAccessRows(owner.id, delegate.id)).toHaveLength(0);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
|
||||
});
|
||||
|
||||
it("FENCE-AC-04 refuses a stale delegated WRITE and writes nothing to either record", async () => {
|
||||
const { owner, delegate, grant } = await household("WRITE");
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
assertContext(epoch - 1, owner.id);
|
||||
|
||||
const res = await callWrite();
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
expect(await grantLastUsedAt(grant.id)).toBeNull();
|
||||
// One seeded row each, and no third row anywhere.
|
||||
const all = await getPrismaClient().measurement.findMany();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.some((m) => m.value === 99)).toBe(false);
|
||||
});
|
||||
|
||||
it("FENCE-AC-04 refuses a matching epoch whose SCOPE moved", async () => {
|
||||
// Belt and braces: the epoch alone distinguishes transitions, the scope
|
||||
// catches a selector that moved to a value the client did not expect.
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
assertContext(epoch, "some-other-account");
|
||||
|
||||
const res = await callRead();
|
||||
expect(res.status).toBe(409);
|
||||
expect(await grantLastUsedAt(grant.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("FENCE-AC-04 POSITIVE CONTROL: a matching assertion is served and both side effects land", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
assertContext(epoch, owner.id);
|
||||
|
||||
const res = await callRead();
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.scopeUserId).toBe(owner.id);
|
||||
expect(body.data.actorUserId).toBe(delegate.id);
|
||||
// The owner's 81 kg row and not the delegate's 64 kg one. An empty list
|
||||
// would satisfy a weaker assertion while hiding the failure that matters.
|
||||
expect(body.data.values).toEqual([81]);
|
||||
|
||||
// The response echoes the context it was served under.
|
||||
expect(res.headers.get(RECORD_EPOCH_HEADER)).toBe(String(epoch));
|
||||
expect(res.headers.get(RECORD_SCOPE_HEADER)).toBe(owner.id);
|
||||
|
||||
await waitFor(
|
||||
async () => (await grantLastUsedAt(grant.id)) !== null,
|
||||
"AccountGrant.lastUsedAt",
|
||||
);
|
||||
await waitFor(
|
||||
async () => (await delegatedAccessRows(owner.id, delegate.id)).length > 0,
|
||||
"sharing.record.accessed audit row",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-10 the exemptions, proved rather than assumed", () => {
|
||||
it("FENCE-AC-10 serves a Bearer request with no fence header at all", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const raw = await mintToken(delegate.id);
|
||||
cookieJar.clear();
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
headerJar.set("x-healthlog-account", owner.id);
|
||||
|
||||
const res = await callRead();
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.scopeUserId).toBe(owner.id);
|
||||
// Nothing is echoed on this transport either: the native contract neither
|
||||
// sends nor receives the fence headers.
|
||||
expect(res.headers.get(RECORD_EPOCH_HEADER)).toBeNull();
|
||||
expect(res.headers.get(RECORD_SCOPE_HEADER)).toBeNull();
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 serves a never-switched cookie session with no fence header at all", async () => {
|
||||
const delegate = await makeUser("solo");
|
||||
await seedWeight(delegate.id, 70);
|
||||
await signIn(delegate.id);
|
||||
|
||||
const res = await callRead();
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.values).toEqual([70]);
|
||||
// The exemption still echoes what it served under, so a fence-aware client
|
||||
// has something to validate against from its very first request.
|
||||
expect(res.headers.get(RECORD_EPOCH_HEADER)).toBe("0");
|
||||
expect(res.headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 serves a never-switched session that sends the bootstrap sentinel", async () => {
|
||||
const delegate = await makeUser("booting");
|
||||
await seedWeight(delegate.id, 71);
|
||||
await signIn(delegate.id);
|
||||
headerJar.set(RECORD_EPOCH_HEADER, RECORD_FENCE_BOOTSTRAP);
|
||||
headerJar.set(RECORD_SCOPE_HEADER, RECORD_FENCE_BOOTSTRAP);
|
||||
|
||||
const res = await callRead();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 refuses a HEADERLESS request on a fenced session with the code a pre-fence bundle recovers from", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchOutOfBand(session.id, owner.id);
|
||||
// No fence headers at all — a bundle that predates the fence.
|
||||
|
||||
const res = await callRead();
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
// The code the deployed bundle's grant-loss bridge already acts on: it
|
||||
// leaves the record and hard-navigates, which serves the fence-aware
|
||||
// bundle. Handing it the 409 instead would strand it.
|
||||
expect(body.meta.errorCode).toBe("sharing.access.denied");
|
||||
expect(await grantLastUsedAt(grant.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 keeps a session fenced after it has LEFT a record", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchOutOfBand(session.id, owner.id);
|
||||
const epoch = await switchOutOfBand(session.id, null);
|
||||
expect(epoch).toBe(2);
|
||||
|
||||
// A tab still believing it is inside the owner's record.
|
||||
assertContext(1, owner.id);
|
||||
expect((await callRead()).status).toBe(409);
|
||||
|
||||
// And the same session, asserting the truth, is served its own record.
|
||||
assertContext(2, null);
|
||||
const ok = await callRead();
|
||||
expect(ok.status).toBe(200);
|
||||
expect((await ok.json()).data.values).toEqual([64]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-08 concurrent switchers resolve to one monotonic outcome", () => {
|
||||
it("FENCE-AC-08 lets exactly one of two same-epoch switches land", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
// Start INSIDE a record, so both competing targets are genuinely distinct
|
||||
// from the current selector. Two tabs racing to a value the row already
|
||||
// holds is not a race at all — the trigger declines to move on a no-op
|
||||
// write and both callers are correctly told their intent stands, which is
|
||||
// the arm asserted separately below.
|
||||
const start = await switchOutOfBand(session.id, owner.id);
|
||||
expect(start).toBe(1);
|
||||
|
||||
// A real second account: the selector carries a foreign key, so a race
|
||||
// between two invented ids would fail on the constraint rather than on the
|
||||
// compare-and-set.
|
||||
const otherOwner = await makeUser("owner-b");
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
pointSessionAtIfUnchanged(session.id, delegate.id, null, start),
|
||||
pointSessionAtIfUnchanged(session.id, delegate.id, otherOwner.id, start),
|
||||
]);
|
||||
|
||||
const outcomes = [first.kind, second.kind].sort();
|
||||
expect(outcomes).toEqual(["applied", "stale"]);
|
||||
|
||||
const applied = first.kind === "applied" ? first : second;
|
||||
expect(applied.kind).toBe("applied");
|
||||
if (applied.kind === "applied") {
|
||||
// The epoch handed back is the one the trigger committed, read in the
|
||||
// same statement that committed it.
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: session.id },
|
||||
});
|
||||
expect(applied.epoch).toBe(row.recordEpoch);
|
||||
expect(applied.epoch).toBe(start + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("FENCE-AC-08 treats a switch to where the session already is as applied, and does not move the epoch", async () => {
|
||||
// The trigger declines a no-op write (`IS DISTINCT FROM`), so the epoch
|
||||
// stands. The caller is still told `applied`, and that is the honest
|
||||
// answer: they asked for the session to be pointed somewhere and it is.
|
||||
// Reporting `stale` here would make a client reconcile against a context
|
||||
// that never changed.
|
||||
const { delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
|
||||
const outcome = await pointSessionAtIfUnchanged(
|
||||
session.id,
|
||||
delegate.id,
|
||||
null,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ kind: "applied", epoch: 0 });
|
||||
});
|
||||
|
||||
it("FENCE-AC-08 refuses a switch that names an epoch the row has already left", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const start = 0;
|
||||
|
||||
const landed = await pointSessionAtIfUnchanged(
|
||||
session.id,
|
||||
delegate.id,
|
||||
owner.id,
|
||||
start,
|
||||
);
|
||||
expect(landed.kind).toBe("applied");
|
||||
|
||||
// A second tab still holding the pre-switch epoch.
|
||||
const stale = await pointSessionAtIfUnchanged(
|
||||
session.id,
|
||||
delegate.id,
|
||||
null,
|
||||
start,
|
||||
);
|
||||
expect(stale.kind).toBe("stale");
|
||||
|
||||
// And the row is where the winner put it, not where the loser wanted it.
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: session.id },
|
||||
});
|
||||
expect(row.actingAsUserId).toBe(owner.id);
|
||||
});
|
||||
|
||||
it("FENCE-AC-08 refuses a switch aimed at another user's session", async () => {
|
||||
// The CAS does not weaken the ownership pin: a matching epoch on somebody
|
||||
// else's session still matches no row.
|
||||
const { owner, delegate } = await household();
|
||||
const stranger = await makeUser("stranger");
|
||||
const session = await signIn(delegate.id);
|
||||
|
||||
const outcome = await pointSessionAtIfUnchanged(
|
||||
session.id,
|
||||
stranger.id,
|
||||
owner.id,
|
||||
0,
|
||||
);
|
||||
expect(outcome.kind).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-09 the fence refuses after every lifecycle transition", () => {
|
||||
it("FENCE-AC-09 refuses the pre-revocation context after a revoke", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
assertContext(epoch, owner.id);
|
||||
expect((await callRead()).status).toBe(200);
|
||||
|
||||
// The verb the sharing panel calls. It clears the selector inside the same
|
||||
// transaction that stamps the row, so the epoch moves with it and the tab's
|
||||
// context is stale from the very next request.
|
||||
await revokeGrantAndClearSwitch({
|
||||
grantId: grant.id,
|
||||
grantorId: owner.id,
|
||||
});
|
||||
|
||||
const res = await callRead();
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
|
||||
});
|
||||
|
||||
it("FENCE-AC-09 still refuses on the grant when a revocation leaves the selector in place", async () => {
|
||||
// `revokeGrant` is the bare state transition and clears no session. The
|
||||
// fence is not a substitute for the grant check and must not become one:
|
||||
// the context is still provable, so the fence passes, and the resolver
|
||||
// refuses on the grant exactly as it did before the fence existed.
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
assertContext(epoch, owner.id);
|
||||
expect((await callRead()).status).toBe(200);
|
||||
|
||||
await revokeGrant({ grantId: grant.id, grantorId: owner.id });
|
||||
|
||||
const res = await callRead();
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
|
||||
});
|
||||
|
||||
it("FENCE-AC-09 refuses the pre-deletion context after the owner account is deleted", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
const epoch = await switchOutOfBand(session.id, owner.id);
|
||||
|
||||
assertContext(epoch, owner.id);
|
||||
expect((await callRead()).status).toBe(200);
|
||||
|
||||
// The referential action, which runs no application code. The epoch moves
|
||||
// anyway, because the trigger owns it.
|
||||
await getPrismaClient().user.delete({ where: { id: owner.id } });
|
||||
|
||||
expect((await callRead()).status).toBe(409);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user