mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge record navigation and its session context fence
This commit is contained in:
commit
25b1b0eab6
143 files changed
+11172
-705
No files matched your search
+2
-4
@@ -18,9 +18,7 @@
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
/e2e/setup/storageState.json
|
||||
/e2e/setup/storageStateOwner.json
|
||||
/e2e/setup/storageStateDelegate.json
|
||||
/e2e/setup/storageState*.json
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -95,4 +93,4 @@ next-env.d.ts
|
||||
# times over. The same two names are excluded from eslint and vitest, for the
|
||||
# same reason in a different tool.
|
||||
/.wt-*/
|
||||
/.worktrees/
|
||||
/.worktrees/
|
||||
+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
|
||||
|
||||
@@ -203,10 +203,19 @@ test.describe("account sharing", () => {
|
||||
// date mapping, and BOTH still pass with the card wired back to `null`.
|
||||
// Only reading the posted body proves the value a person typed is the
|
||||
// value the server receives, which is the half that was missing.
|
||||
const lapse = ownerPage.locator('[data-slot="grant-invite-expires"]');
|
||||
// The VISIBLE overlay, not the hidden ISO mirror: `data-slot` rides
|
||||
// `{...rest}` onto `DateField`'s hidden input, so the old selector resolved
|
||||
// to an element that can never be visible and typed into nothing.
|
||||
const lapse = ownerPage.getByTestId("grant-invite-expires-input");
|
||||
await expect(lapse).toBeVisible();
|
||||
const chosenDay = isoDayFromNow(30);
|
||||
await lapse.fill(chosenDay);
|
||||
// The overlay commits on blur; without it the ISO text sits in local state
|
||||
// and the hidden mirror still holds the old value when the form submits.
|
||||
await lapse.blur();
|
||||
await expect(
|
||||
ownerPage.locator('input[data-slot="grant-invite-expires"]'),
|
||||
).toHaveValue(chosenDay);
|
||||
|
||||
// The level, through the real control and onto the real wire.
|
||||
//
|
||||
|
||||
+326
-23
@@ -65,6 +65,61 @@ export const E2E_OWNER = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/** A separate delegate session for the eight scoped-record browser journeys. */
|
||||
export const E2E_SCOPE_DELEGATE = {
|
||||
email: "e2e-scope-delegate@healthlog.test",
|
||||
username: "e2e-scope-delegate",
|
||||
password: "Qz8!Vp4rL2nX7mKs",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/** One non-personal target record per closed sharing domain. */
|
||||
export const E2E_SCOPE_RECORDS = [
|
||||
{
|
||||
domain: "measurements",
|
||||
username: "e2e-scope-measurements",
|
||||
href: "/measurements",
|
||||
},
|
||||
{
|
||||
domain: "medications",
|
||||
username: "e2e-scope-medications",
|
||||
href: "/medications",
|
||||
},
|
||||
{ domain: "labs", username: "e2e-scope-labs", href: "/labs" },
|
||||
{ domain: "profile", username: "e2e-scope-profile", href: "/profile" },
|
||||
{ domain: "illness", username: "e2e-scope-illness", href: "/illness" },
|
||||
{ domain: "mind", username: "e2e-scope-mind", href: "/mood" },
|
||||
{ domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" },
|
||||
{ domain: "documents", username: "e2e-scope-documents", href: "/documents" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Whole-record grants used to prove the three adult access levels and the
|
||||
* separate managed-profile boundary in a real browser session.
|
||||
*/
|
||||
export const E2E_LEVEL_RECORDS = [
|
||||
{
|
||||
username: "e2e-level-read",
|
||||
access: "READ",
|
||||
recordKind: "shared",
|
||||
},
|
||||
{
|
||||
username: "e2e-level-write",
|
||||
access: "WRITE",
|
||||
recordKind: "shared",
|
||||
},
|
||||
{
|
||||
username: "e2e-level-manage",
|
||||
access: "MANAGE",
|
||||
recordKind: "shared",
|
||||
},
|
||||
{
|
||||
username: "e2e-level-managed",
|
||||
access: "MANAGE",
|
||||
recordKind: "managed",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Weights seeded one per account, chosen so neither can be mistaken for the
|
||||
* other in rendered markup and neither collides with a value any other spec
|
||||
@@ -105,6 +160,47 @@ export const DELEGATE_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateDelegate.json",
|
||||
);
|
||||
|
||||
export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateScopeDelegate.json",
|
||||
);
|
||||
|
||||
export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateScopeA11y.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at
|
||||
* `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as
|
||||
* well.
|
||||
*
|
||||
* The record-session fence made the cost visible. Four specs moved the same
|
||||
* session row's record selector — the two fence specs, the cross-tab journey
|
||||
* and the scoped-sharing journey — and the fence's whole subject is that row's
|
||||
* context and its monotonic epoch. On one worker they never overlapped; on two
|
||||
* they moved the epoch under each other, and the failure was the harness racing
|
||||
* itself. Passing only at `--workers=1` is not a passing suite: CI runs two.
|
||||
*
|
||||
* A separate LOGIN, not a separate account: the selector lives on the session
|
||||
* row, so two jars for one account are two independent contexts. The grants,
|
||||
* the fixtures and the assertions all stay exactly as they were.
|
||||
*/
|
||||
export const FENCE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateFence.json",
|
||||
);
|
||||
|
||||
export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateFenceOffline.json",
|
||||
);
|
||||
|
||||
export const CROSS_TAB_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateCrossTab.json",
|
||||
);
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, {
|
||||
memoryCost: 19456,
|
||||
@@ -201,6 +297,163 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The scoped-record journey uses a dedicated delegate so its preseeded
|
||||
// grants cannot affect the invitation lifecycle exercised by the sharing
|
||||
// journey above. Its own module preferences are explicitly off: a target
|
||||
// record's resolved scope, rather than the actor's preferences, must keep
|
||||
// each granted doorway visible.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_SCOPE_DELEGATE.username,
|
||||
E2E_SCOPE_DELEGATE.email,
|
||||
await hashPassword(E2E_SCOPE_DELEGATE.password),
|
||||
now,
|
||||
JSON.stringify({
|
||||
medications: false,
|
||||
labs: false,
|
||||
illness: false,
|
||||
mood: false,
|
||||
mentalHealth: false,
|
||||
inboundDocuments: false,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM account_grants
|
||||
WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[E2E_SCOPE_DELEGATE.username],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE sessions SET acting_as_user_id = NULL
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[E2E_SCOPE_DELEGATE.username],
|
||||
);
|
||||
|
||||
for (const record of E2E_SCOPE_RECORDS) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $4, true, $5::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
[
|
||||
cuid(),
|
||||
record.username,
|
||||
`${record.username}@healthlog.test`,
|
||||
now,
|
||||
JSON.stringify({
|
||||
medications: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
mood: true,
|
||||
mentalHealth: true,
|
||||
inboundDocuments: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO account_grants
|
||||
(id, grantor_id, grantee_id, access, scope_json, invited_at,
|
||||
accepted_at, created_at)
|
||||
SELECT $1, owner.id, delegate.id, 'READ', $2::jsonb, $3, $3, $3
|
||||
FROM users owner, users delegate
|
||||
WHERE owner.username = $4 AND delegate.username = $5`,
|
||||
[
|
||||
cuid(),
|
||||
JSON.stringify([record.domain]),
|
||||
now,
|
||||
record.username,
|
||||
E2E_SCOPE_DELEGATE.username,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Whole-record fixtures intentionally sit beside the narrow records
|
||||
// above. They make the browser prove the presentation and mutation
|
||||
// boundaries of READ, WRITE, MANAGE, and a guardian-managed record
|
||||
// without changing the invitation lifecycle fixture below.
|
||||
for (const record of E2E_LEVEL_RECORDS) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
managed_profile_at, onboarding_completed_at,
|
||||
onboarding_tour_completed, module_preferences_json)
|
||||
VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = NULL,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
managed_profile_at = EXCLUDED.managed_profile_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
[
|
||||
cuid(),
|
||||
record.username,
|
||||
`${record.username}@healthlog.test`,
|
||||
now,
|
||||
record.recordKind === "managed" ? now : null,
|
||||
JSON.stringify({
|
||||
medications: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
mood: true,
|
||||
mentalHealth: true,
|
||||
inboundDocuments: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO account_grants
|
||||
(id, grantor_id, grantee_id, access, scope_json, invited_at,
|
||||
accepted_at, created_at)
|
||||
SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3
|
||||
FROM users owner, users delegate
|
||||
WHERE owner.username = $4 AND delegate.username = $5`,
|
||||
[
|
||||
cuid(),
|
||||
record.access,
|
||||
now,
|
||||
record.username,
|
||||
E2E_SCOPE_DELEGATE.username,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Cycle is delegated to its profile gate rather than module preferences.
|
||||
await pool.query(
|
||||
`INSERT INTO cycle_profiles
|
||||
(id, user_id, cycle_tracking_enabled, created_at, updated_at)
|
||||
SELECT $1, u.id, true, $2, $2
|
||||
FROM users u WHERE u.username = $3
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
cycle_tracking_enabled = true,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[cuid(), now, "e2e-scope-cycle"],
|
||||
);
|
||||
|
||||
// Repeated local E2E runs reuse the same seeded account and database.
|
||||
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
|
||||
// run can start above the 20-operation ceiling and fail before exercising
|
||||
@@ -281,7 +534,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
WHERE key LIKE 'sharing:%'`,
|
||||
);
|
||||
|
||||
// The login bucket, for the same reason. This setup signs in THREE times
|
||||
// The login bucket, for the same reason. This setup signs in FOUR times
|
||||
// now (the shared jar, the owner, and the sharing journey's own delegate
|
||||
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
|
||||
// local runs in a row would otherwise end with a 429 from the fixture
|
||||
@@ -313,6 +566,29 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.37.0 — clear what the browser suite WRITES, so the suite can be run
|
||||
// twice.
|
||||
//
|
||||
// The adult-WRITE journey saves a blood-pressure reading through the real
|
||||
// form, and the form stamps a minute-rounded `measuredAt`. Two runs inside
|
||||
// the same minute therefore collide on the natural key
|
||||
// (user, type, measured_at, source) — the second save is a duplicate, and
|
||||
// the journey asserts its POST succeeded. It has always been that way; it
|
||||
// used to surface as a 500 and now surfaces as the 409 the route learned to
|
||||
// give, which is what made it legible. A suite that cannot be re-run is a
|
||||
// suite whose green is a statement about the clock.
|
||||
//
|
||||
// Only rows the journey itself creates: MANUAL, and carrying no
|
||||
// `external_id`, which is what distinguishes them from the seeded markers
|
||||
// above.
|
||||
await pool.query(
|
||||
`DELETE FROM measurements
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)
|
||||
AND source = 'MANUAL'
|
||||
AND external_id IS NULL`,
|
||||
["e2e-level-write"],
|
||||
);
|
||||
|
||||
// Console (instead of structured logging) is intentional here —
|
||||
// global-setup runs outside the app's logging context, and the
|
||||
// line is useful when debugging a CI failure where the seed didn't
|
||||
@@ -320,31 +596,58 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
console.log(
|
||||
`[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`,
|
||||
);
|
||||
// Log the fixture accounts in here and persist their cookie jars so specs
|
||||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the
|
||||
// per-spec login + rate-limit dance.
|
||||
const baseURL =
|
||||
config.projects[0]?.use.baseURL ??
|
||||
process.env.E2E_BASE_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Log one account in, clearing the login bucket first.
|
||||
*
|
||||
* The ceiling is FIVE attempts per IP per quarter-hour and this setup now
|
||||
* signs in eight times, so clearing once before the batch is no longer
|
||||
* enough — the sixth would be answered by the fixture's own 429 rather
|
||||
* than by the product. Only the auth surfaces' buckets are touched, and
|
||||
* only between logins this setup is itself performing.
|
||||
*/
|
||||
const capture = async (
|
||||
account: { username: string; password: string },
|
||||
path: string,
|
||||
): Promise<void> => {
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
await captureAuthState(baseURL, account, path);
|
||||
};
|
||||
|
||||
// The shared jar every authenticated spec reads.
|
||||
await capture(E2E_USER, STORAGE_STATE_PATH);
|
||||
|
||||
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a
|
||||
// grant signed in at once, and the login endpoint is IP-rate-limited, so
|
||||
// the owner is logged in here rather than inside the spec.
|
||||
await capture(E2E_OWNER, OWNER_STORAGE_STATE_PATH);
|
||||
|
||||
// The delegate's own jar for that journey. Same account as the shared one,
|
||||
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH
|
||||
// for what happens when the journey switches the shared row instead.
|
||||
await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||||
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||||
|
||||
// v1.37.0 — one jar per switching spec. See the block comment on
|
||||
// FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's
|
||||
// row with the scoped-sharing journey, which is why the suite only passed
|
||||
// on a single worker.
|
||||
await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Log the fixture accounts in here and persist their cookie jars so specs
|
||||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec
|
||||
// login + rate-limit dance.
|
||||
const baseURL =
|
||||
config.projects[0]?.use.baseURL ??
|
||||
process.env.E2E_BASE_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
// The shared jar every authenticated spec reads.
|
||||
await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH);
|
||||
|
||||
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant
|
||||
// signed in at once, and the login endpoint is IP-rate-limited, so the owner
|
||||
// is logged in here rather than inside the spec.
|
||||
await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH);
|
||||
|
||||
// The delegate's own jar for that journey. Same account as the shared one,
|
||||
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
|
||||
// what happens when the journey switches the shared row instead.
|
||||
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,9 +9,17 @@
|
||||
export {
|
||||
E2E_USER,
|
||||
E2E_OWNER,
|
||||
E2E_SCOPE_DELEGATE,
|
||||
E2E_SCOPE_RECORDS,
|
||||
E2E_LEVEL_RECORDS,
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER_MARKER_KG,
|
||||
STORAGE_STATE_PATH,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
SCOPE_A11Y_STORAGE_STATE_PATH,
|
||||
FENCE_STORAGE_STATE_PATH,
|
||||
FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
CROSS_TAB_STORAGE_STATE_PATH,
|
||||
} from "./global-setup";
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
/**
|
||||
* FENCE-AC-07 — the disk layers cannot serve one record's bytes inside
|
||||
* another.
|
||||
*
|
||||
* ## Why this is a spec of its own
|
||||
*
|
||||
* `playwright.config.ts` sets `serviceWorkers: "block"` for every other
|
||||
* project, and for a good reason written there: a worker-originated `fetch` is
|
||||
* not subject to `page.route`, so the SW would bypass the per-spec route mocks.
|
||||
*
|
||||
* This case is the exact inverse. Its whole claim is about what the
|
||||
* `healthlog-data-*` cache can and cannot serve across a record switch — and
|
||||
* with the worker blocked that cache is never populated at all, so the positive
|
||||
* control ("the owner snapshot really WAS on disk before the switch") could
|
||||
* never pass no matter what the application did. A check that cannot pass is
|
||||
* worse than no check, so this runs in the `chromium-service-worker` project
|
||||
* with the worker allowed, and uses no route mocks.
|
||||
*
|
||||
* ## What is asserted, in order
|
||||
*
|
||||
* 1. the owner's record reads really are on disk before the switch — a test
|
||||
* that seeded nothing proves nothing;
|
||||
* 2. only current-version caches exist, which is the eviction the fence's
|
||||
* design relies on to make "a response with no echo" safe;
|
||||
* 3. the switch empties the data cache before it reloads;
|
||||
* 4. every response now on disk carries the TARGET's record scope and never
|
||||
* the previous one — the claim, read off the disk layer itself rather
|
||||
* than inferred from what a page did or did not paint;
|
||||
* 5. and offline, that cache is what answers a record read, still carrying
|
||||
* the target's context. The non-empty scope set is the paired positive
|
||||
* control: an empty cache would satisfy "no previous-record bytes" while
|
||||
* proving nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pages whose reads the service worker is configured to hold on disk
|
||||
* (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`,
|
||||
* `/api/measurements` and `/api/medications`). Visited rather than fetched, so
|
||||
* the requests are the app's own and carry the record-session assertion.
|
||||
*/
|
||||
const WARM_PAGES = ["/", "/measurements", "/medications"] as const;
|
||||
|
||||
/**
|
||||
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
||||
*
|
||||
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
||||
* nav drops the other destinations and the record reads this file drives are
|
||||
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
||||
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
||||
* hides the thing under test.
|
||||
*/
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the target record, retrying the click if the switch did not land.
|
||||
*
|
||||
* See the twin in `v137-record-session-fence.spec.ts`: the switch is a
|
||||
* compare-and-set the client retries exactly once, and a harness driving it at
|
||||
* machine speed can legitimately lose twice. Bounded, and a third failure is a
|
||||
* real one.
|
||||
*/
|
||||
async function enterRecord(page: Page) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
await entry.click();
|
||||
try {
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return accountId;
|
||||
} catch {
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
}
|
||||
}
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function expectShellReady(page: Page) {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
// A real budget, not the 10 s default. The gate IS the cross-tab hold: a
|
||||
// peer sits behind it until its own `/api/auth/me` resolves, and that read
|
||||
// queues behind whatever the other worker's browser is doing. Ten seconds
|
||||
// is a statement about machine load; thirty is about the hold actually
|
||||
// never releasing, which is the failure worth reporting.
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the `healthlog-data-*` caches hold right now. */
|
||||
async function dataCacheState(page: Page) {
|
||||
return page.evaluate(async () => {
|
||||
const names = await caches.keys();
|
||||
const data = names.filter((n) => n.startsWith("healthlog-data-"));
|
||||
let entries = 0;
|
||||
for (const name of data) {
|
||||
entries += (await (await caches.open(name)).keys()).length;
|
||||
}
|
||||
return { names, dataCaches: data, entries };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The record context every cached response was stored WITH.
|
||||
*
|
||||
* This is the disk-layer claim, read off the disk layer. A cached record
|
||||
* response carries the echo it was served under, so if a previous record's
|
||||
* bytes were still reachable they would show up here as that record's scope.
|
||||
*/
|
||||
async function cachedScopes(page: Page): Promise<string[]> {
|
||||
return page.evaluate(async () => {
|
||||
const scopes: string[] = [];
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) {
|
||||
const cached = await cache.match(request);
|
||||
const scope = cached?.headers.get("x-healthlog-record-scope");
|
||||
if (scope) scopes.push(scope);
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
// The gate is spelled out here rather than left inside a helper because
|
||||
// the next act is a one-shot `page.evaluate`, and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
// The session is shared with the other sharing journeys, so "not inside
|
||||
// a record" is not something a fresh page can assume.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) > 0) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Wait for the worker to control the page — until it does, nothing it
|
||||
// would cache is going through it.
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker?.controller !== null,
|
||||
undefined,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
// Warm the disk cache by VISITING the pages, not by issuing bare
|
||||
// fetches. A bare `fetch` in `page.evaluate` carries no record-session
|
||||
// assertion, so on a session that has been inside a shared record the
|
||||
// fence refuses it and nothing cacheable comes back — the cache filled
|
||||
// from the app's own reads instead, and those were the ones that happened
|
||||
// to resolve no record scope. Driving the app is what puts real,
|
||||
// echo-carrying record responses on disk.
|
||||
for (const path of WARM_PAGES) {
|
||||
await page.goto(path);
|
||||
// Spelled out rather than left inside `expectShellReady`, because the
|
||||
// next act is a one-shot `page.evaluate` and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read. A gate hidden in a helper
|
||||
// is invisible to that guard and to the next reader.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await dataCacheState(page)).entries, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// (1) POSITIVE CONTROL: the owner's bytes really are on disk.
|
||||
const seeded = await dataCacheState(page);
|
||||
expect(seeded.dataCaches.length).toBeGreaterThan(0);
|
||||
expect(seeded.entries).toBeGreaterThan(0);
|
||||
|
||||
// (2) Only current-version caches survive activation. This is the
|
||||
// eviction the fence's "a response with no echo is served normally" rule
|
||||
// depends on: a PRE-fence cached response carries no echo, and it can
|
||||
// only be served if its cache is still there.
|
||||
const version = seeded.dataCaches[0].replace("healthlog-data-", "");
|
||||
const stale = seeded.names.filter(
|
||||
(n) =>
|
||||
/^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const targetAccountId = await entry.getAttribute("data-account-id");
|
||||
expect(targetAccountId).not.toBeNull();
|
||||
await entry.click();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// (3) NOT asserted: "the data cache is momentarily empty".
|
||||
//
|
||||
// The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch`
|
||||
// runs before the reload — but the reload immediately refills them from
|
||||
// the target's own reads, so an empty cache is a state that exists for a
|
||||
// few milliseconds and cannot be sampled reliably. Polling for it made
|
||||
// this case fail for a reason that had nothing to do with the claim.
|
||||
//
|
||||
// The claim is asserted directly below instead, and it is the stronger
|
||||
// statement anyway: what matters is not that the cache was briefly empty
|
||||
// but that nothing in it belongs to the previous record.
|
||||
|
||||
// Visit inside the TARGET record so there is something of its own on
|
||||
// disk to find.
|
||||
for (const path of WARM_PAGES) {
|
||||
await page.goto(path);
|
||||
// Spelled out rather than left inside `expectShellReady`, because the
|
||||
// next act is a one-shot `page.evaluate` and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read. A gate hidden in a helper
|
||||
// is invisible to that guard and to the next reader.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await dataCacheState(page)).entries, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// (4) + (5) The disk layer now holds the TARGET's bytes and only those,
|
||||
// read off the disk layer itself.
|
||||
//
|
||||
// The first draft asserted the shared-record banner after an offline
|
||||
// reload. That could never pass and had nothing to do with the claim:
|
||||
// `/api/auth/me` is not a cached read, so offline it fails, `useAuth`
|
||||
// errors and the shell paints its offline gate rather than a banner. The
|
||||
// banner's absence says nothing about what is on disk.
|
||||
//
|
||||
// What IS on disk is checkable, and it is the whole point: every cached
|
||||
// record response carries the echo it was served under, so a previous
|
||||
// record's bytes surviving the switch would appear here as that record's
|
||||
// scope.
|
||||
const scopes = await cachedScopes(page);
|
||||
// POSITIVE CONTROL: something really is cached with a context, so the
|
||||
// assertion below is about a non-empty set.
|
||||
expect(scopes.length).toBeGreaterThan(0);
|
||||
for (const scope of scopes) {
|
||||
expect(scope).toBe(targetAccountId);
|
||||
}
|
||||
expect(scopes).not.toContain("self");
|
||||
|
||||
// And the target's own record read really is one of the entries on disk,
|
||||
// so the scope assertion above is about the reads that matter rather
|
||||
// than about some incidental cached response.
|
||||
const cachedUrls = await page.evaluate(async () => {
|
||||
const urls: string[] = [];
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
for (const request of await (await caches.open(name)).keys()) {
|
||||
urls.push(new URL(request.url).pathname);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
});
|
||||
expect(cachedUrls).toContain("/api/measurements");
|
||||
|
||||
// NOT asserted: that a bare `fetch` from page context can read those
|
||||
// bytes back while offline. Since v1.37.0 an echoing response also
|
||||
// carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and
|
||||
// the Cache API honours `Vary` — so a request WITHOUT the fence headers
|
||||
// does not match the stored entry at all. That is the correct behaviour
|
||||
// and a second line of defence worth naming: bytes cached under one
|
||||
// record context cannot be retrieved by a request asserting a different
|
||||
// one, or none. The app's own reads carry the headers and do match.
|
||||
} finally {
|
||||
await context.setOffline(false);
|
||||
// Hand the shared session back to its own record; see the note in
|
||||
// `v137-record-session-fence.spec.ts`.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-07 a cached record response cannot be SERVED under a different context", async ({
|
||||
browser,
|
||||
}) => {
|
||||
/**
|
||||
* The serving path, which the case above does not exercise.
|
||||
*
|
||||
* The test above asserts what is IN the cache. That is worth asserting and
|
||||
* it is not the same claim: an entry can sit on disk under one context and
|
||||
* still be handed to a request made under another, and on the EXTERNAL
|
||||
* switch path nothing wipes it — the app never initiated the switch, so
|
||||
* `clearOfflineCachesForRecordSwitch` never runs. On that path `Vary` is
|
||||
* the only thing standing between a cached delegated response and the next
|
||||
* request for the same URL.
|
||||
*
|
||||
* So this asks the Cache API the question a service worker asks it:
|
||||
* `cache.match(request)` honours the stored response's `Vary`, so a request
|
||||
* whose fence headers differ from the ones the entry was stored with does
|
||||
* not match at all. Remove the `Vary` block from `attachRecordContextEcho`
|
||||
* and the second lookup starts hitting.
|
||||
*/
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) > 0) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker?.controller !== null,
|
||||
undefined,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
// Enter the record and let its reads land on disk.
|
||||
await enterRecord(page);
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
|
||||
// The context those bytes were stored under, read off the entry itself.
|
||||
const stored = await page.evaluate(async () => {
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) {
|
||||
if (!new URL(request.url).pathname.startsWith("/api/measurements"))
|
||||
continue;
|
||||
const response = await cache.match(request);
|
||||
if (!response) continue;
|
||||
return {
|
||||
cacheName: name,
|
||||
url: request.url,
|
||||
vary: response.headers.get("Vary"),
|
||||
epoch: response.headers.get("x-healthlog-record-epoch"),
|
||||
scope: response.headers.get("x-healthlog-record-scope"),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// POSITIVE CONTROL: there really is an entry, stored with a context.
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.epoch).toMatch(/^\d+$/);
|
||||
expect(stored!.scope).not.toBe("self");
|
||||
|
||||
// The EXTERNAL switch: driven from a request context, so the app never
|
||||
// runs its cache wipe and the entry above survives.
|
||||
const left = await context.request.post("/api/account/switch", {
|
||||
data: { accountId: null },
|
||||
});
|
||||
expect(left.status()).toBe(200);
|
||||
const me = await context.request.get("/api/auth/me");
|
||||
const now = (await me.json()).data.recordSession as {
|
||||
epoch: number;
|
||||
scope: string | null;
|
||||
};
|
||||
// The context genuinely moved, or the comparison below is vacuous.
|
||||
expect(String(now.epoch)).not.toBe(stored!.epoch);
|
||||
|
||||
const lookups = await page.evaluate(
|
||||
async ({
|
||||
cacheName,
|
||||
url,
|
||||
storedEpoch,
|
||||
storedScope,
|
||||
nowEpoch,
|
||||
nowScope,
|
||||
}) => {
|
||||
const cache = await caches.open(cacheName);
|
||||
const ask = async (epoch: string, scope: string) =>
|
||||
(await cache.match(
|
||||
new Request(url, {
|
||||
headers: {
|
||||
"x-healthlog-record-epoch": epoch,
|
||||
"x-healthlog-record-scope": scope,
|
||||
},
|
||||
}),
|
||||
)) !== undefined;
|
||||
return {
|
||||
underStoredContext: await ask(storedEpoch, storedScope),
|
||||
underNewContext: await ask(nowEpoch, nowScope),
|
||||
withNoAssertion:
|
||||
(await cache.match(new Request(url))) !== undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
cacheName: stored!.cacheName,
|
||||
url: stored!.url,
|
||||
storedEpoch: stored!.epoch!,
|
||||
storedScope: stored!.scope!,
|
||||
nowEpoch: String(now.epoch),
|
||||
nowScope: now.scope ?? "self",
|
||||
},
|
||||
);
|
||||
|
||||
// POSITIVE CONTROL: the entry IS servable to the context it belongs to.
|
||||
// Without this, "the other context misses" would pass for an entry that
|
||||
// was simply not there.
|
||||
expect(lookups.underStoredContext).toBe(true);
|
||||
// The claim. Remove the `Vary` and this starts hitting.
|
||||
expect(lookups.underNewContext).toBe(false);
|
||||
expect(lookups.withNoAssertion).toBe(false);
|
||||
} finally {
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,615 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { FENCE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
/**
|
||||
* The record-session fence, in a real browser.
|
||||
*
|
||||
* `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer:
|
||||
* a peer tab holds while the initiator's switch is in flight, because the
|
||||
* initiator told it to. This file proves the cases that layer cannot reach —
|
||||
* the ones where nobody tells anybody anything:
|
||||
*
|
||||
* * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely
|
||||
* to "something changed";
|
||||
* * FENCE-AC-02 the initiator's response never arrives, because the tab is
|
||||
* torn off mid-flight;
|
||||
* * FENCE-AC-03 the initiator is CLOSED after the server committed, so no
|
||||
* commit broadcast is ever published;
|
||||
* * FENCE-AC-06 a record response delayed across the switch is discarded by
|
||||
* the client rather than painted;
|
||||
* * FENCE-AC-07 the disk layers — CacheStorage and the service worker's
|
||||
* `healthlog-data-*` — cannot serve one record's bytes inside another.
|
||||
*
|
||||
* ## Why every absence assertion here is paired
|
||||
*
|
||||
* "The peer never showed the owner's data" passes on a page that is
|
||||
* permanently wedged, which is a worse outcome than the bug. So each of those
|
||||
* legs additionally asserts the peer REACHES a usable state on the target
|
||||
* scope once `/me` resolves. An absence-only assertion in this file would be a
|
||||
* check that cannot fail.
|
||||
*
|
||||
* Uses the existing synthetic `e2e/setup` fixtures; introduces no new account
|
||||
* anchors.
|
||||
*/
|
||||
|
||||
const RECORD_READS = [
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/insights/targets",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
"/api/labs",
|
||||
] as const;
|
||||
|
||||
const EPOCH_HEADER = "x-healthlog-record-epoch";
|
||||
const SCOPE_HEADER = "x-healthlog-record-scope";
|
||||
|
||||
/**
|
||||
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
||||
*
|
||||
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
||||
* nav drops the other destinations and the record reads this file drives are
|
||||
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
||||
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
||||
* hides the thing under test.
|
||||
*/
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
function switcherEntry(page: Page, username = TARGET_USERNAME) {
|
||||
return page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave any record this session is already inside.
|
||||
*
|
||||
* These specs each begin by switching INTO a record, so they need to start
|
||||
* outside one — and the session is shared with the other sharing journeys, so
|
||||
* "outside one" is not something a fresh page can assume. Without this the
|
||||
* file passes in isolation and fails whenever it runs after a spec that left
|
||||
* the session switched, which is the least useful kind of failure.
|
||||
*/
|
||||
async function ensureOwnRecord(page: Page) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) === 0) return;
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0, { timeout: 30_000 });
|
||||
// And WAIT for the reload to finish.
|
||||
//
|
||||
// Leaving a record is a full document navigation, and the banner vanishing
|
||||
// only means that navigation started. Returning there hands the next step a
|
||||
// page that has not re-read `/api/auth/me` yet — so its adopted epoch is
|
||||
// still `bootstrap`, its switch sends `expectedEpoch: 0`, the compare-and-set
|
||||
// loses against the epoch the exit itself just moved, and the switch quietly
|
||||
// does not happen. The symptom is a banner that never appears, thirty
|
||||
// seconds later, with nothing wrong in the product.
|
||||
await expectShellReady(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the target record, retrying the click if the switch did not land.
|
||||
*
|
||||
* Not a workaround for a flaky product — a fixture acknowledging a contract the
|
||||
* product chose deliberately. `POST /api/account/switch` is a compare-and-set
|
||||
* on the session's record epoch, and the client reconciles and retries exactly
|
||||
* ONCE; a second lost CAS surfaces as a failed toast rather than being retried
|
||||
* forever, which is the right call for a person at a keyboard.
|
||||
*
|
||||
* These tests are not a person at a keyboard. Some drive two tabs of ONE
|
||||
* session at machine speed while the peer is reloading and re-adopting, which
|
||||
* is precisely the "the browser is racing itself" case that contract names —
|
||||
* so the switcher click legitimately loses sometimes, and the product is
|
||||
* behaving as designed when it does. The fixture re-presses the button, which
|
||||
* is what the person would do.
|
||||
*
|
||||
* Bounded and asserted: if three attempts cannot enter the record, that is a
|
||||
* real failure and it fails.
|
||||
*/
|
||||
async function enterRecord(page: Page, username = TARGET_USERNAME) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
let accountId: string | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await openSwitcher(page);
|
||||
const entry = switcherEntry(page, username);
|
||||
await expect(entry).toBeVisible();
|
||||
accountId ??= await entry.getAttribute("data-account-id");
|
||||
await entry.click();
|
||||
try {
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return accountId;
|
||||
} catch {
|
||||
// The switcher menu may still be open over the page; start clean.
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
}
|
||||
}
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
return accountId;
|
||||
}
|
||||
|
||||
/** Wait for the shell to be usable again — the paired positive control. */
|
||||
async function expectShellReady(page: Page) {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
// A real budget, not the 10 s default. The gate IS the cross-tab hold: a
|
||||
// peer sits behind it until its own `/api/auth/me` resolves, and that read
|
||||
// queues behind whatever the other worker's browser is doing. Ten seconds
|
||||
// is a statement about machine load; thirty is about the hold actually
|
||||
// never releasing, which is the failure worth reporting.
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The furthest-along assertion in a collected set.
|
||||
*
|
||||
* The epoch is monotonic, so the highest one is the freshest context this
|
||||
* browser has reached — independent of the order the requests happened to be
|
||||
* observed in.
|
||||
*/
|
||||
function latestAssertion(
|
||||
collected: { epoch: string; scope: string }[],
|
||||
): { epoch: string; scope: string } | null {
|
||||
let best: { epoch: string; scope: string } | null = null;
|
||||
for (const assertion of collected) {
|
||||
if (!/^\d+$/.test(assertion.epoch)) continue;
|
||||
if (best === null || Number(assertion.epoch) > Number(best.epoch)) {
|
||||
best = assertion;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The record context a page asserted on its most recent record read. */
|
||||
function collectAssertions(
|
||||
page: Page,
|
||||
into: { epoch: string; scope: string }[],
|
||||
) {
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (!(RECORD_READS as readonly string[]).includes(path)) return;
|
||||
const headers = request.headers();
|
||||
const epoch = headers[EPOCH_HEADER];
|
||||
const scope = headers[SCOPE_HEADER];
|
||||
if (epoch === undefined || scope === undefined) return;
|
||||
into.push({ epoch, scope });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two full switches, two peer reloads each, and a poll that reloads until
|
||||
// the server write is visible to the second tab. That does not fit the
|
||||
// 30 s default, and a test that times out reports a failure that says
|
||||
// nothing about the fence.
|
||||
test.setTimeout(120_000);
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
const peerAssertions: { epoch: string; scope: string }[] = [];
|
||||
collectAssertions(peer, peerAssertions);
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
|
||||
const accountId = await enterRecord(initiator);
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
// Cleared HERE, not before the click: between the click and the reload
|
||||
// the peer is still issuing reads under the context it had, and counting
|
||||
// those made the assertion below compare a pre-switch `self` against the
|
||||
// post-switch account. Only what the peer asserts once it has reconciled
|
||||
// is evidence about reconciliation.
|
||||
peerAssertions.length = 0;
|
||||
|
||||
// The peer reaches a usable shell again — the positive control, without
|
||||
// which every assertion below would pass on a wedged page.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// And every record read it now issues names the record it is actually
|
||||
// in, not "something". A peer that reconciled to a guess would show up
|
||||
// here as a scope naming the wrong account or a stale epoch.
|
||||
// The HIGHEST-epoch assertion, not every collected one.
|
||||
//
|
||||
// The epoch is monotonic, and requests do not arrive in the order they
|
||||
// were issued — a read from the page before the reload can be observed
|
||||
// after one from the page after it. Requiring EVERY collected assertion
|
||||
// to name the new record is therefore a claim about network ordering
|
||||
// rather than about reconciliation, and it fails intermittently for a
|
||||
// reason that has nothing to do with the fence. What reconciliation
|
||||
// means is that the peer CONVERGES on the new context, which is exactly
|
||||
// the highest epoch it ever asserts.
|
||||
const inRecord = latestAssertion(peerAssertions);
|
||||
expect(inRecord).not.toBeNull();
|
||||
expect(inRecord!.scope).toBe(accountId);
|
||||
const inRecordEpoch = Number(inRecord!.epoch);
|
||||
expect(inRecordEpoch).toBeGreaterThan(0);
|
||||
|
||||
// ── and the same on the way out ───────────────────────────────────
|
||||
await initiator
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click();
|
||||
await expect(
|
||||
initiator.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
peerAssertions.length = 0;
|
||||
// Polled with reloads rather than reloaded once. The initiator's banner
|
||||
// disappearing means its own document navigated, not that the server
|
||||
// write has landed and become visible to another tab — asserting on one
|
||||
// reload made this leg a race against the switch-out commit.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
return peer.locator('[data-slot="shared-record-banner"]').count();
|
||||
},
|
||||
{ timeout: 40_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const backHome = latestAssertion(peerAssertions);
|
||||
expect(backHome).not.toBeNull();
|
||||
expect(backHome!.scope).toBe("self");
|
||||
// Leaving moved the epoch too: a tab believing it is still inside the
|
||||
// record is the same defect as the reverse.
|
||||
expect(Number(backHome!.epoch)).toBeGreaterThan(inRecordEpoch);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
const peerAssertions: { epoch: string; scope: string }[] = [];
|
||||
collectAssertions(peer, peerAssertions);
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
|
||||
// Drive the switch server-side and abort the response on the way back:
|
||||
// the write lands, the initiator never learns it did, and no commit
|
||||
// broadcast is ever published.
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(500);
|
||||
|
||||
// The peer must never paint owner-ready state it guessed at …
|
||||
const guessed = peerAssertions.filter(
|
||||
(a) => a.scope === accountId && a.epoch === "bootstrap",
|
||||
);
|
||||
expect(guessed).toEqual([]);
|
||||
|
||||
// … and must still REACH ready on the target scope once `/me` answers.
|
||||
// Without this the leg above would pass on a permanently wedged page.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
|
||||
// Hold the response, close the tab while it is in flight. The server has
|
||||
// committed; nothing will ever broadcast the commit.
|
||||
const held = deferred();
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
await held.promise;
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(300);
|
||||
held.release();
|
||||
await initiator.close();
|
||||
|
||||
// The surviving tab is not permanently gated: `/me` is the bootstrap and
|
||||
// it answers regardless of who began the transition.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const peer = await context.newPage();
|
||||
// Collected from the very start: the refusal can land on a poll the app
|
||||
// issues by itself, before this test navigates anywhere.
|
||||
const refusals: string[] = [];
|
||||
peer.on("response", (response) => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (response.status() !== 409) return;
|
||||
// ANY refused API read, not one of a named five. Which cells a page
|
||||
// mounts is a rendering decision that moves with the product, and a
|
||||
// hard-coded list turns "the fence refused the stale tab" into "the
|
||||
// fence refused one of the paths I happened to name" — which is how
|
||||
// this case first passed while observing nothing.
|
||||
if (!path.startsWith("/api/")) return;
|
||||
refusals.push(path);
|
||||
});
|
||||
|
||||
try {
|
||||
await peer.goto("/");
|
||||
await expectShellReady(peer);
|
||||
await ensureOwnRecord(peer);
|
||||
|
||||
// A switch driven from a request context that runs no client journal at
|
||||
// all — the "raw or external" case the cooperative layer cannot see.
|
||||
await openSwitcher(peer);
|
||||
const entry = switcherEntry(peer);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
await peer.keyboard.press("Escape");
|
||||
|
||||
const response = await context.request.post("/api/account/switch", {
|
||||
data: { accountId },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The tab is now asserting a context the row has left — but observing
|
||||
// the refusal takes one deliberate step, and the reason is worth stating
|
||||
// because it is a property of the design rather than a quirk of the test.
|
||||
//
|
||||
// `GET /api/auth/me` is UNFENCED (it is the reconciliation bootstrap and
|
||||
// must answer while the context is unknown) and it is one of the two
|
||||
// responses a client may ADOPT from. So whenever the app happens to
|
||||
// re-read `/me` before it reads a record, it silently adopts the new
|
||||
// context and no stale assertion is ever sent. That is correct — and it
|
||||
// means a test that just navigates observes nothing, which is how the
|
||||
// first draft of this case passed while proving nothing.
|
||||
//
|
||||
// The window the fence exists for is the opposite ordering: a suspended
|
||||
// peer whose RECORD read goes out before any reconciliation. Holding
|
||||
// `/me` produces exactly that ordering, deterministically.
|
||||
// Held exactly once, and never unrouted: calling `unroute` while a
|
||||
// handler is still parked inside its `await` abandons that route, and
|
||||
// the `continue()` on the far side then throws "Route is already
|
||||
// handled". Letting the handler fall through after the first hold keeps
|
||||
// one owner for the route from start to finish.
|
||||
const heldMe: { release: (() => void) | null; done: boolean } = {
|
||||
release: null,
|
||||
done: false,
|
||||
};
|
||||
await peer.route("**/api/auth/me", async (route) => {
|
||||
if (heldMe.done) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
heldMe.done = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
heldMe.release = resolve;
|
||||
});
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const soft = peer.locator('aside[aria-label="Sidebar"]');
|
||||
await expect(soft).toBeVisible();
|
||||
await soft.getByRole("link", { name: "Measurements" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => refusals.length, { timeout: 20_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Let the reconciliation through and confirm the tab recovers rather
|
||||
// than sitting on the refusal.
|
||||
heldMe.release?.();
|
||||
|
||||
// Reconciled, not evicted: the tab ends up INSIDE the record the
|
||||
// external switch put it in, with a usable shell.
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: FENCE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// The intercept goes on BEFORE the first navigation. Installed after the
|
||||
// page had already loaded, it matched nothing — the only request for that
|
||||
// path had come and gone, and the case passed while observing nothing.
|
||||
//
|
||||
// And it holds `/api/measurements`, a FENCED record read. The first
|
||||
// version held `/api/auth/me` because that one is alive on every boot —
|
||||
// but `/me` is an actor surface, the fence never runs on it, and it
|
||||
// therefore echoes no record context at all. Holding it captured a null
|
||||
// echo forever, which is the same "observed nothing" failure wearing a
|
||||
// different path.
|
||||
const held: {
|
||||
armed: boolean;
|
||||
release: (() => void) | null;
|
||||
epoch: string | null;
|
||||
scope: string | null;
|
||||
} = { armed: false, release: null, epoch: null, scope: null };
|
||||
|
||||
await page.route("**/api/measurements**", async (route) => {
|
||||
if (!held.armed) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
held.armed = false;
|
||||
const response = await route.fetch();
|
||||
held.epoch = response.headers()["x-healthlog-record-epoch"] ?? null;
|
||||
held.scope = response.headers()["x-healthlog-record-scope"] ?? null;
|
||||
await new Promise<void>((resolve) => {
|
||||
held.release = resolve;
|
||||
});
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
await ensureOwnRecord(page);
|
||||
|
||||
const accountId = await enterRecord(page);
|
||||
|
||||
// Arm the hold, then make the app issue the read inside the record.
|
||||
held.armed = true;
|
||||
const sidebar = page.locator('aside[aria-label="Sidebar"]');
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar
|
||||
.getByRole("link", { name: "Measurements" })
|
||||
.click({ noWaitAfter: true });
|
||||
|
||||
await expect
|
||||
.poll(() => held.release !== null, { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
|
||||
// The response was admitted under the record's context and says so.
|
||||
// Proof the intercept saw a real fenced read rather than matching
|
||||
// nothing, which is the whole failure mode this case kept falling into.
|
||||
expect(held.epoch).toMatch(/^\d+$/);
|
||||
expect(held.scope).toBe(accountId);
|
||||
|
||||
// Now move the context out from under it, and only then let it land.
|
||||
const leaving = page
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
await page.waitForTimeout(500);
|
||||
held.release?.();
|
||||
await leaving.catch(() => {});
|
||||
|
||||
// The page settles on the record it is actually in — its own — without
|
||||
// painting the one the held response described.
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0, { timeout: 20_000 });
|
||||
await expectShellReady(page);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { CROSS_TAB_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
test.describe.serial("cross-tab shared-record session switches", () => {
|
||||
test("holds a peer tab before both switch-in and switch-out change the shared session", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: CROSS_TAB_STORAGE_STATE_PATH,
|
||||
});
|
||||
const switchingPage = await context.newPage();
|
||||
const peerPage = await context.newPage();
|
||||
const pausedSwitches: ReturnType<typeof deferred>[] = [];
|
||||
const peerRecordReads: string[] = [];
|
||||
|
||||
await switchingPage.route("**/api/account/switch", async (route) => {
|
||||
const pause = pausedSwitches.shift();
|
||||
if (pause) await pause.promise;
|
||||
await route.continue();
|
||||
});
|
||||
peerPage.on("request", (request) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/insights/targets",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
"/api/labs",
|
||||
"/api/labs/ocr/capability",
|
||||
"/api/insights/coach/nudge-status",
|
||||
].includes(path)
|
||||
) {
|
||||
peerRecordReads.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([switchingPage.goto("/"), peerPage.goto("/")]);
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
|
||||
await openSwitcher(switchingPage);
|
||||
const entry = switchingPage.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const switchIn = deferred();
|
||||
pausedSwitches.push(switchIn);
|
||||
peerRecordReads.length = 0;
|
||||
const switchInClick = entry.click({ noWaitAfter: true });
|
||||
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toHaveCount(0);
|
||||
await peerPage.waitForTimeout(200);
|
||||
expect(peerRecordReads).toEqual([]);
|
||||
|
||||
switchIn.release();
|
||||
await switchInClick;
|
||||
await expect(
|
||||
switchingPage.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const switchOut = deferred();
|
||||
pausedSwitches.push(switchOut);
|
||||
peerRecordReads.length = 0;
|
||||
const switchOutClick = switchingPage
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
await peerPage.waitForTimeout(200);
|
||||
expect(peerRecordReads).toEqual([]);
|
||||
|
||||
switchOut.release();
|
||||
await switchOutClick;
|
||||
await expect(
|
||||
switchingPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,83 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
import { ACCESSIBILITY_STATES } from "../tests/fixtures/v137/e2e-journeys";
|
||||
async function scan(page: Page) {
|
||||
const result = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe("sharing accessibility anchors", () => {
|
||||
for (const state of ACCESSIBILITY_STATES) {
|
||||
test(`anchor: ${state.name}`, () => {
|
||||
expect(state.contract).toBe("inventory-only");
|
||||
async function openProfileRecord(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
const entry = page.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-profile"]',
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
await entry.click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
}
|
||||
|
||||
async function leaveRecord(page: Page) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("shared record accessibility states", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH });
|
||||
|
||||
test("success and empty shared profile states have no WCAG violations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openProfileRecord(page);
|
||||
await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible();
|
||||
await scan(page);
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("the capability hydration loading state has no WCAG violations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await scan(page);
|
||||
});
|
||||
|
||||
test("the shared profile read error state has no WCAG violations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/profile/summary**", (route) =>
|
||||
route.fulfill({ status: 500, body: "{}" }),
|
||||
);
|
||||
await openProfileRecord(page);
|
||||
await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible();
|
||||
await scan(page);
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("the shared-record refusal state has no WCAG violations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openProfileRecord(page);
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await scan(page);
|
||||
await page.locator('[data-slot="shared-record-unavailable-leave"]').click();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,524 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
E2E_LEVEL_RECORDS,
|
||||
E2E_SCOPE_RECORDS,
|
||||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
|
||||
import { RELEASE_JOURNEYS } from "../tests/fixtures/v137/e2e-journeys";
|
||||
const DOMAIN_READS = {
|
||||
measurements: "/api/measurements",
|
||||
medications: "/api/medications",
|
||||
labs: "/api/labs",
|
||||
profile: "/api/profile/summary",
|
||||
illness: "/api/illness/episodes",
|
||||
mind: "/api/mood-entries",
|
||||
cycle: "/api/cycle/calendar",
|
||||
documents: "/api/documents/inbound",
|
||||
} as const;
|
||||
|
||||
test.describe("sharing release journey anchors", () => {
|
||||
for (const journey of RELEASE_JOURNEYS) {
|
||||
test(`anchor: ${journey.name}`, () => {
|
||||
expect(journey.contract).toBe("inventory-only");
|
||||
/**
|
||||
* v1.37.0 — the record-session assertion the app is currently sending.
|
||||
*
|
||||
* The two API probes in this file are deliberate: they check a status code the
|
||||
* UI does not surface (a MANAGE-only generated read) and a payload the UI
|
||||
* paginates. But a bare `fetch` inside `page.evaluate` carries no
|
||||
* record-session headers, and the fence refuses an unasserted request on any
|
||||
* session that has been inside a shared record — so the probes started failing
|
||||
* with 403 for a reason that had nothing to do with what they test.
|
||||
*
|
||||
* They are NOT an old-bundle simulation and must not become one: that case is
|
||||
* covered deliberately in `src/__tests__/record-session-fence-compat.test.ts`
|
||||
* and `tests/integration/record-session-fence.test.ts`, where the 403 and the
|
||||
* recovery are the subject. Here the subject is the grant level, so the probe
|
||||
* asserts exactly what the app beside it asserts — copied off a real app
|
||||
* request rather than computed, so it cannot drift from what the client
|
||||
* actually believes.
|
||||
*/
|
||||
function trackFenceAssertion(page: Page): () => {
|
||||
"x-healthlog-record-epoch": string;
|
||||
"x-healthlog-record-scope": string;
|
||||
} {
|
||||
// The HIGHEST epoch seen, not the most recent one observed.
|
||||
//
|
||||
// The counter is monotonic, and requests do not arrive in the order they
|
||||
// were issued: a request from the page before a switch can be seen after one
|
||||
// from the page after it, and last-writer-wins then leaves the probe holding
|
||||
// a context the session has already left — a 409, intermittently, on a test
|
||||
// about grant levels. Taking the maximum removes the ordering question
|
||||
// rather than trying to win it.
|
||||
let latest = { epoch: "bootstrap", scope: "bootstrap", seen: -1 };
|
||||
page.on("request", (request) => {
|
||||
const headers = request.headers();
|
||||
const epoch = headers["x-healthlog-record-epoch"];
|
||||
const scope = headers["x-healthlog-record-scope"];
|
||||
if (epoch === undefined || scope === undefined) return;
|
||||
if (!/^\d+$/.test(epoch)) return;
|
||||
const value = Number(epoch);
|
||||
if (value < latest.seen) return;
|
||||
latest = { epoch, scope, seen: value };
|
||||
});
|
||||
return () => ({
|
||||
"x-healthlog-record-epoch": latest.epoch,
|
||||
"x-healthlog-record-scope": latest.scope,
|
||||
});
|
||||
}
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-menu"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function openRecord(page: Page, username: string) {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
if (accountId === null) {
|
||||
throw new Error(`Account switcher entry for ${username} has no account id`);
|
||||
}
|
||||
const accessLevel = await entry.getAttribute("data-access-level");
|
||||
const recordKind = await entry.getAttribute("data-record-kind");
|
||||
await entry.click();
|
||||
return { accountId, accessLevel, recordKind };
|
||||
}
|
||||
|
||||
async function leaveRecord(page: Page) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function withDivergentActiveAccountAccess(body: unknown): unknown {
|
||||
if (body === null || typeof body !== "object") {
|
||||
throw new Error("Expected an object from /api/auth/me");
|
||||
}
|
||||
|
||||
const envelope = body as { data?: unknown };
|
||||
const payload = "data" in envelope ? envelope.data : envelope;
|
||||
if (payload === null || typeof payload !== "object") {
|
||||
throw new Error("Expected an auth payload from /api/auth/me");
|
||||
}
|
||||
|
||||
const auth = payload as {
|
||||
accountAccess?: {
|
||||
active?: {
|
||||
access?: unknown;
|
||||
level?: unknown;
|
||||
canWrite?: unknown;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
if (!auth.accountAccess?.active) {
|
||||
throw new Error("Expected a switched account-access payload");
|
||||
}
|
||||
|
||||
const corrupted = {
|
||||
...auth,
|
||||
accountAccess: {
|
||||
...auth.accountAccess,
|
||||
active: {
|
||||
...auth.accountAccess.active,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
return "data" in envelope ? { ...envelope, data: corrupted } : corrupted;
|
||||
}
|
||||
|
||||
test.describe.serial("scoped sharing browser journeys", () => {
|
||||
test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
|
||||
|
||||
for (const record of E2E_SCOPE_RECORDS) {
|
||||
test(`opens only the ${record.domain} record doorway`, async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${record.username}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
|
||||
const unexpectedOcrCapability =
|
||||
record.domain === "labs"
|
||||
? page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === "/api/labs/ocr/capability",
|
||||
{ timeout: 500 },
|
||||
)
|
||||
: null;
|
||||
const read = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === DOMAIN_READS[record.domain],
|
||||
);
|
||||
await entry.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${record.href}$`));
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toContainText(record.username);
|
||||
await expect(
|
||||
page.locator(`a[href="${record.href}"]`).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toHaveCount(0);
|
||||
await read;
|
||||
if (unexpectedOcrCapability) {
|
||||
await expect(unexpectedOcrCapability).rejects.toThrow(/Timeout/);
|
||||
}
|
||||
|
||||
await leaveRecord(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("refuses a malformed switched payload before target or owner reads mount", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
|
||||
const entry = page.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
|
||||
// The switch normally writes this mirror before its full reload. Remove it
|
||||
// in the new document so the global preloaders cannot guess an own-record
|
||||
// scope while `/api/auth/me` is still deciding whether the target is safe.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem("healthlog-record-scope");
|
||||
});
|
||||
|
||||
let corruptAuthPayload = true;
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const response = await route.fetch();
|
||||
if (!corruptAuthPayload) {
|
||||
await route.fulfill({ response });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: withDivergentActiveAccountAccess(await response.json()),
|
||||
});
|
||||
});
|
||||
|
||||
const ownerOnlyReads: string[] = [];
|
||||
const trackOwnerOnlyRead = (request: {
|
||||
method(): string;
|
||||
url(): string;
|
||||
}) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/labs/ocr/capability",
|
||||
"/api/insights/coach/nudge-status",
|
||||
"/api/coach/about-me/questions",
|
||||
"/api/auth/me/notification-prefs",
|
||||
].includes(path)
|
||||
) {
|
||||
ownerOnlyReads.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", trackOwnerOnlyRead);
|
||||
const targetPreloads: string[] = [];
|
||||
const trackTargetPreload = (request: {
|
||||
method(): string;
|
||||
url(): string;
|
||||
}) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
].includes(path)
|
||||
) {
|
||||
targetPreloads.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", trackTargetPreload);
|
||||
try {
|
||||
await entry.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/labs$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="invalid-record-access-refusal"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable-leave"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-tour-id="labs-hero"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Both global preloader routes must remain inert while the malformed
|
||||
// response is refused, even though the scope mirror was intentionally
|
||||
// cleared before each reload.
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('[data-slot="invalid-record-access-refusal"]'),
|
||||
).toBeVisible();
|
||||
await page.goto("/medications");
|
||||
await expect(
|
||||
page.locator('[data-slot="invalid-record-access-refusal"]'),
|
||||
).toBeVisible();
|
||||
expect(ownerOnlyReads).toEqual([]);
|
||||
expect(targetPreloads).toEqual([]);
|
||||
|
||||
// Leave the refused record, then demand fresh own-record reads. This
|
||||
// catches a response that was parked under the owner hash while the
|
||||
// refusal was on screen and would otherwise paint without a new request.
|
||||
corruptAuthPayload = false;
|
||||
const freshDashboard = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/dashboard/snapshot",
|
||||
);
|
||||
await page
|
||||
.locator('[data-slot="shared-record-unavailable-leave"]')
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await freshDashboard;
|
||||
|
||||
const freshMedications = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/medications",
|
||||
);
|
||||
await page.goto("/medications");
|
||||
await freshMedications;
|
||||
} finally {
|
||||
page.off("request", trackOwnerOnlyRead);
|
||||
page.off("request", trackTargetPreload);
|
||||
await page.unroute("**/api/auth/me");
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an adult READ grant read-only and outside Settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) => candidate.access === "READ",
|
||||
);
|
||||
if (!record) throw new Error("READ fixture is missing");
|
||||
|
||||
const { accessLevel, recordKind } = await openRecord(page, record.username);
|
||||
expect(accessLevel).toBe("view");
|
||||
expect(recordKind).toBe("shared");
|
||||
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-access-level", "view");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "shared");
|
||||
await expect(banner).toContainText(record.username);
|
||||
|
||||
await page.goto("/measurements");
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("lets an adult WRITE grant add only to the selected record", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) => candidate.access === "WRITE",
|
||||
);
|
||||
if (!record) throw new Error("WRITE fixture is missing");
|
||||
|
||||
const { accountId, accessLevel, recordKind } = await openRecord(
|
||||
page,
|
||||
record.username,
|
||||
);
|
||||
expect(accessLevel).toBe("view-and-add");
|
||||
expect(recordKind).toBe("shared");
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
await expect(banner).toHaveAttribute("data-access-level", "view-and-add");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "shared");
|
||||
|
||||
await page.goto("/measurements");
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
|
||||
const post = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === "/api/measurements",
|
||||
);
|
||||
await page.locator('[data-slot="measurement-add"]').click();
|
||||
await page.locator("#sys").fill("124");
|
||||
await page.locator("#dia").fill("78");
|
||||
await page.getByRole("button", { name: /^save$/i }).click();
|
||||
expect((await post).status()).toBeLessThan(300);
|
||||
|
||||
// Read the value back THROUGH THE APP rather than with a hand-rolled
|
||||
// probe.
|
||||
//
|
||||
// The probe copied the fence headers off a live request so it would not be
|
||||
// refused — and then raced them: requests do not arrive in the order they
|
||||
// were issued, so it could pick up a context the session had already left
|
||||
// and answer 409 on a test about grant levels. Copying the app's state to
|
||||
// stand beside the app is the wrong shape; asking the app is the right one,
|
||||
// and it is also closer to what this test claims — that the reading landed
|
||||
// in the SELECTED record and is visible there.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
|
||||
await expect(page.getByText("124", { exact: false }).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("opens MANAGE-only generated reads without opening adult Settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fenceHeaders = trackFenceAssertion(page);
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) =>
|
||||
candidate.access === "MANAGE" && candidate.recordKind === "shared",
|
||||
);
|
||||
if (!record) throw new Error("adult MANAGE fixture is missing");
|
||||
|
||||
const { accessLevel, recordKind } = await openRecord(page, record.username);
|
||||
expect(accessLevel).toBe("manage");
|
||||
expect(recordKind).toBe("shared");
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "shared");
|
||||
|
||||
await page.goto("/");
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
const generatedRead = await page.evaluate(async (headers) => {
|
||||
const response = await fetch("/api/dashboard/summary", { headers });
|
||||
return response.status;
|
||||
}, fenceHeaders());
|
||||
expect(generatedRead).toBeLessThan(300);
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("keeps managed Settings on the active record and integrations status-only", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = E2E_LEVEL_RECORDS.find(
|
||||
(candidate) => candidate.recordKind === "managed",
|
||||
);
|
||||
if (!record) throw new Error("managed record fixture is missing");
|
||||
|
||||
const { accountId, accessLevel, recordKind } = await openRecord(
|
||||
page,
|
||||
record.username,
|
||||
);
|
||||
expect(accessLevel).toBe("manage");
|
||||
expect(recordKind).toBe("managed");
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
|
||||
await page.goto("/settings/account");
|
||||
const profile = page.locator('[data-record-settings-family="profile"]');
|
||||
await expect(profile).toBeVisible();
|
||||
await expect(profile).toHaveAttribute("data-record-id", accountId);
|
||||
|
||||
const integrationRead = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname ===
|
||||
"/api/record-settings/integrations",
|
||||
);
|
||||
await page.goto("/settings/integrations");
|
||||
const integrationResponse = await integrationRead;
|
||||
expect(integrationResponse.status()).toBeLessThan(300);
|
||||
const integrationPayload = (await integrationResponse.json()) as {
|
||||
data?: { recordId?: string };
|
||||
};
|
||||
expect(integrationPayload.data?.recordId).toBe(accountId);
|
||||
const integrations = page.locator(
|
||||
'[data-record-settings-family="integrations"]',
|
||||
);
|
||||
await expect(integrations).toBeVisible();
|
||||
await expect(integrations).toHaveAttribute("data-record-id", accountId);
|
||||
await expect(
|
||||
integrations.locator("button, input, select, textarea"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goBack();
|
||||
await expect(profile).toHaveAttribute("data-record-id", accountId);
|
||||
await page.goForward();
|
||||
await expect(integrations).toHaveAttribute("data-record-id", accountId);
|
||||
await expect(banner).toHaveAttribute("data-account-id", accountId);
|
||||
|
||||
await leaveRecord(page);
|
||||
await page.goto("/measurements");
|
||||
await expect(banner).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refuses direct ungranted routes before coach reads start", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openRecord(page, "e2e-scope-measurements");
|
||||
const protectedCoachReads: string[] = [];
|
||||
const trackCoachRead = (request: { url(): string }) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/insights/coach/nudge-status",
|
||||
"/api/coach/about-me/questions",
|
||||
"/api/auth/me/notification-prefs",
|
||||
].includes(path)
|
||||
) {
|
||||
protectedCoachReads.push(path);
|
||||
}
|
||||
};
|
||||
page.on("request", trackCoachRead);
|
||||
try {
|
||||
await page.goto("/coach");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
expect(protectedCoachReads).toEqual([]);
|
||||
|
||||
await page.goto("/settings/access");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-unavailable"]'),
|
||||
).toBeVisible();
|
||||
expect(protectedCoachReads).toEqual([]);
|
||||
} finally {
|
||||
page.off("request", trackCoachRead);
|
||||
}
|
||||
await leaveRecord(page);
|
||||
});
|
||||
});
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Vorsorge",
|
||||
"illness": "Krankheit",
|
||||
"documents": "Dokumente",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
"profile": "Profile",
|
||||
"vorsorge": "Checkups",
|
||||
"illness": "Illness",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
"profile": "Perfil",
|
||||
"vorsorge": "Revisiones",
|
||||
"illness": "Enfermedad",
|
||||
"documents": "Documentos",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Examens",
|
||||
"illness": "Maladie",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
"profile": "Profilo",
|
||||
"vorsorge": "Controlli",
|
||||
"illness": "Malattia",
|
||||
"documents": "Documenti",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Badania",
|
||||
"illness": "Choroba",
|
||||
"documents": "Dokumenty",
|
||||
|
||||
@@ -75,14 +75,63 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
// The disk-layer spec needs a live service worker, which the shared
|
||||
// `use` block blocks for every other spec. It runs in its own project.
|
||||
testIgnore: ["v137-record-session-fence-offline.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
},
|
||||
},
|
||||
{
|
||||
// v1.37.0 — the one project that lets the service worker run.
|
||||
//
|
||||
// Every other project blocks it, and the reason is written in the `use`
|
||||
// block above: a worker-originated `fetch` is not subject to
|
||||
// `page.route`, so the SW would bypass the per-spec route mocks. The
|
||||
// record-session fence's disk-layer case is the exact inverse — its whole
|
||||
// claim is about what the `healthlog-data-*` cache can and cannot serve
|
||||
// across a switch, and with the worker blocked that cache is never
|
||||
// populated, so its positive control could never pass. A check that
|
||||
// cannot pass is worse than no check, so the spec gets a project rather
|
||||
// than a caveat.
|
||||
//
|
||||
// It uses no route mocks for exactly this reason, and it is the only
|
||||
// spec in this project.
|
||||
name: "chromium-service-worker",
|
||||
testMatch: ["v137-record-session-fence-offline.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
serviceWorkers: "allow",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
// The scoped-sharing journeys each carry a persistent server-side
|
||||
// session stamp. They use isolated desktop jars and are not responsive
|
||||
// layout checks, so running a second project against the same stamps
|
||||
// would test session interference rather than the record journeys.
|
||||
testIgnore: [
|
||||
"v137-sharing-managed-profiles.spec.ts",
|
||||
"v137-sharing-managed-profiles-a11y.spec.ts",
|
||||
// v1.37.0 — every spec that MOVES a session's record selector runs in
|
||||
// exactly one project.
|
||||
//
|
||||
// Each of these now has its own login and its own session row (see
|
||||
// `FENCE_STORAGE_STATE_PATH` in `e2e/setup/global-setup.ts`), which is
|
||||
// what lets them run beside each other at any worker count. A second
|
||||
// PROJECT is the one thing a separate jar cannot fix: it would drive
|
||||
// the very same row from two browsers at once, moving the epoch under
|
||||
// itself. The scoped-sharing journeys above are excluded for the same
|
||||
// reason, one release earlier.
|
||||
"v137-record-session-fence.spec.ts",
|
||||
"v137-sharing-cross-tab-session.spec.ts",
|
||||
// Runs only in the service-worker project.
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
],
|
||||
use: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
// `playwright install chromium` instead of also pulling
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- v1.37.0 — a monotonic counter for "how many times has this session's record
|
||||
-- selector moved".
|
||||
--
|
||||
-- The problem it solves is not authorization; `acting_as_user_id` plus the
|
||||
-- grant join already decides that. It is agreement. A browser holds several
|
||||
-- tabs on one session, and a request that left a suspended tab before a switch
|
||||
-- landed arrives after it — carrying an intent formed under the old record and
|
||||
-- addressed to a server that has since moved. The request is perfectly
|
||||
-- authorized and answers the wrong question. So every request that resolves a
|
||||
-- record now asserts which context it believes it is in, and the server refuses
|
||||
-- when that belief no longer matches the row.
|
||||
--
|
||||
-- The counter has to be the DATABASE's, not the application's, because the
|
||||
-- application is not the only writer of the column it tracks. Three of the
|
||||
-- five writers run no application code at all:
|
||||
--
|
||||
-- * `sessions_acting_as_user_id_fkey ON DELETE SET NULL` — deleting an owner
|
||||
-- account nulls every delegate session pointing at it, by referential
|
||||
-- action, with nothing to hook;
|
||||
-- * raw `UPDATE sessions SET acting_as_user_id = NULL` in the end-to-end
|
||||
-- fixtures;
|
||||
-- * an operator at `psql`, and whatever writes this column next year.
|
||||
--
|
||||
-- An application-level increment would be a discipline those three break
|
||||
-- silently, and a fence whose counter can silently fail to move is a fence that
|
||||
-- passes stale requests. A row trigger cannot be forgotten.
|
||||
--
|
||||
-- BEFORE, not AFTER, and that timing is load-bearing: on any statement that
|
||||
-- moves the selector, the trigger overwrites whatever `record_epoch` the
|
||||
-- incoming statement supplied, so a writer cannot steer the counter while
|
||||
-- switching.
|
||||
--
|
||||
-- What that does NOT cover, stated because the first draft of this comment
|
||||
-- overstated it: a statement that names `record_epoch` and does NOT change
|
||||
-- `acting_as_user_id` never fires this trigger at all, so it writes the column
|
||||
-- freely. The trigger owns the counter on the path that matters — every
|
||||
-- selector move — and what keeps application code away from the column
|
||||
-- otherwise is the source guard in
|
||||
-- `src/__tests__/record-session-fence-guard.test.ts`, which holds the set of
|
||||
-- files naming it to one.
|
||||
--
|
||||
-- `UPDATE OF "acting_as_user_id"` plus the `WHEN` clause keep the two writes
|
||||
-- that touch a session on nearly every request off the trigger entirely — the
|
||||
-- throttled `last_active_at` stamp and the sliding `expires_at` extension both
|
||||
-- name other columns, and neither would satisfy the `IS DISTINCT FROM` test
|
||||
-- even if it did.
|
||||
--
|
||||
-- Precedent for a trigger on this line: `0298_notification_delivery_identity`
|
||||
-- ships `push_attempt_attribution_guard` the same way. A trigger is invisible
|
||||
-- to Prisma's datamodel comparison, which 0298 already proved tolerable.
|
||||
|
||||
ALTER TABLE "sessions" ADD COLUMN "record_epoch" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- A session already sitting inside somebody else's record is not a session
|
||||
-- that has never left its own, and the fence's exemption for pre-existing
|
||||
-- browser tabs is defined as epoch zero. Normalise before the trigger exists,
|
||||
-- so that from the first request after deploy `record_epoch = 0` means exactly
|
||||
-- "never pointed at another record, and not pointed at one now".
|
||||
UPDATE "sessions" SET "record_epoch" = 1 WHERE "acting_as_user_id" IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "sessions_bump_record_epoch"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW."record_epoch" := OLD."record_epoch" + 1;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "sessions_record_epoch_bump"
|
||||
BEFORE UPDATE OF "acting_as_user_id" ON "sessions"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."acting_as_user_id" IS DISTINCT FROM NEW."acting_as_user_id")
|
||||
EXECUTE FUNCTION "sessions_bump_record_epoch"();
|
||||
@@ -1179,6 +1179,24 @@ model Session {
|
||||
/// into its own account.
|
||||
actingAsUserId String? @map("acting_as_user_id")
|
||||
|
||||
/// v1.37.0 — how many times this session's record selector has moved.
|
||||
///
|
||||
/// Monotonic, starts at 0, and NEVER written by application code: migration
|
||||
/// 0302 installs a `BEFORE UPDATE OF acting_as_user_id` trigger that sets it,
|
||||
/// so the increment also happens on the paths where no application code runs
|
||||
/// at all — the `ON DELETE SET NULL` referential action above, a raw fixture
|
||||
/// `UPDATE`, an operator at `psql`. A convention that every writer must
|
||||
/// remember is a convention those three break silently.
|
||||
///
|
||||
/// Consumer: the record-session fence
|
||||
/// (`src/lib/sharing/record-session-fence.ts`), which refuses a cookie
|
||||
/// request whose asserted epoch no longer matches the row. `0` additionally
|
||||
/// means "this session has never been pointed at another record", which is
|
||||
/// the fence's exemption for browser tabs that predate the feature; the
|
||||
/// migration normalises existing rows so that invariant holds from the first
|
||||
/// request after deploy.
|
||||
recordEpoch Int @default(0) @map("record_epoch")
|
||||
|
||||
user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
|
||||
@@ -94,6 +94,20 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
// whole auth context for exactly this reason: had it taken the id, the
|
||||
// route would name the column too, and the list would grow per surface.
|
||||
"lib/sharing/account-access.ts",
|
||||
// v1.37.0 — the record-session fence COMPARES it and never writes it. It
|
||||
// reads the value off the `AuthContext` the resolver already holds rather
|
||||
// than from the row, which is the property the fence rests on: the scope
|
||||
// the handler serves under and the epoch the fence validated are one row
|
||||
// read, so they cannot straddle a switch. A version of this file that
|
||||
// resolved its own session would be a second answer to "whose record is
|
||||
// this" and would also be wrong more often.
|
||||
"lib/sharing/record-session-fence.ts",
|
||||
// v1.37.0 — names the column in prose only. The matcher does not exempt
|
||||
// comments and is left that way on purpose (see the note above), so the
|
||||
// module that explains the fence has to be listed. It imports nothing at
|
||||
// all, which the fence's own unit test asserts, so it can read no column
|
||||
// even in principle.
|
||||
"lib/sharing/record-session-fence-contract.ts",
|
||||
].sort();
|
||||
|
||||
it("no other file reads or writes the carrier column", () => {
|
||||
@@ -125,7 +139,7 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
* Who may ask "which record does this request claim" without an auth
|
||||
* context in hand.
|
||||
*
|
||||
* `readClaimedActingAccount()` answers before any grant check has run, so a
|
||||
* `readClaimedRecordContext()` answers before any grant check has run, so a
|
||||
* caller that treats its answer as permission has skipped the only thing
|
||||
* that grants any. Exactly one caller exists, beside the file that declares
|
||||
* it: the idempotency wrapper, which uses the claim to pick a cache cell and
|
||||
@@ -133,9 +147,17 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
* acting account has an auth context by then and goes through
|
||||
* `requireRecordAuth`, which checks a live grant before returning. A second
|
||||
* caller here is a design question, not a patch.
|
||||
*
|
||||
* The matcher names the v1.36.0 spelling (`readClaimedActingAccount`) as well
|
||||
* as today's. That function was removed in v1.37.0 when the wrapper started
|
||||
* needing the session's record context on the same read; matching both means
|
||||
* a reintroduced narrow copy trips this leg instead of quietly becoming a
|
||||
* second answer.
|
||||
*/
|
||||
it("the unchecked claim has exactly two consumers", () => {
|
||||
const consumers = filesMatching(/readClaimedActingAccount/);
|
||||
const consumers = filesMatching(
|
||||
/readClaimedRecordContext|readClaimedActingAccount/,
|
||||
);
|
||||
expect(consumers.length).toBeGreaterThan(0);
|
||||
expect(consumers).toEqual([
|
||||
"lib/auth/acting-carrier.ts",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
|
||||
const pathnameRef = { value: "/settings/security" };
|
||||
const activeRecordRef = { value: true };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameRef.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: activeRecordRef.value
|
||||
? {
|
||||
id: "guardian",
|
||||
modules: undefined,
|
||||
accountAccess: {
|
||||
active: {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
},
|
||||
accounts: [],
|
||||
canSwitch: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsShell,
|
||||
} from "@/components/settings/settings-shell";
|
||||
import { SharedRecordBanner } from "@/components/layout/shared-record-banner";
|
||||
|
||||
function renderSettings(pathname = "/settings/security") {
|
||||
pathnameRef.value = pathname;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SettingsShell>
|
||||
<div>managed settings content</div>
|
||||
</SettingsShell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("managed record navigation", () => {
|
||||
it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => {
|
||||
const html = renderSettings();
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind === "managed-guardian",
|
||||
);
|
||||
const unavailableDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind !== "managed-guardian",
|
||||
);
|
||||
|
||||
expect(html).toContain('data-active-record-id="managed-record"');
|
||||
expect(html).toContain('id="settings-section-security-title"');
|
||||
expect(html).not.toMatch(/href="[^"]*managed-record/);
|
||||
|
||||
for (const section of managedDestinations) {
|
||||
expect(
|
||||
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
|
||||
?.length ?? 0,
|
||||
).toBe(2);
|
||||
}
|
||||
|
||||
for (const section of unavailableDestinations) {
|
||||
expect(html).not.toContain(`href="/settings/${section.slug}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the exit control present while administering a managed record", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SharedRecordBanner />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-account-id="managed-record"');
|
||||
expect(html).toContain('data-slot="shared-record-banner-exit"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* A fenced route may not be reached by a transport that cannot carry a header.
|
||||
*
|
||||
* The record-session fence is asserted on request HEADERS, attached inside
|
||||
* `src/lib/api/api-fetch.ts`. Three browser transports never go through that
|
||||
* wrapper and cannot be made to:
|
||||
*
|
||||
* * `<img src="/api/…">` — the browser issues the subresource itself;
|
||||
* * `<iframe src="/api/…">` — likewise, and it is a document load;
|
||||
* * `<a href="/api/…" download>` — a navigation, not a fetch.
|
||||
*
|
||||
* On a session that has never entered a shared record this is invisible: the
|
||||
* fence exempts `record_epoch = 0`, so a headerless request is served exactly
|
||||
* as it always was. The moment the session enters or leaves a shared record the
|
||||
* epoch moves, the exemption ends, and every one of those three gets
|
||||
* `403 sharing.access.denied` — permanently, because nothing about them will
|
||||
* ever start sending a header. A thumbnail that silently stops rendering after
|
||||
* somebody visits a shared record is the shape of the bug.
|
||||
*
|
||||
* The fix shape already existed in the repository before this guard did:
|
||||
* `src/components/documents/document-ai-transport.ts` reaches
|
||||
* `/api/documents/inbound/{id}/original` through `apiFetchRaw` and turns the
|
||||
* blob into a `File`. Everything here follows it — fetch through the app
|
||||
* transport, hand the DOM an object URL, revoke it on unmount.
|
||||
*
|
||||
* ## What this matcher can and cannot see
|
||||
*
|
||||
* It resolves ONE level of local aliasing: `const src = \`/api/…\`` followed by
|
||||
* `src={src}` is caught, because that is the shape the real offenders had and a
|
||||
* matcher that only read inline literals would have found two of the six and
|
||||
* reported success. It does NOT trace a path built by a helper, assembled
|
||||
* across two hops, or returned from a hook.
|
||||
*
|
||||
* It also only knows the routes it can RESOLVE. A path whose route module it
|
||||
* cannot find is reported separately rather than silently passing, because an
|
||||
* unresolvable path is the one case where a green result would mean nothing.
|
||||
*
|
||||
* ### The blind spots, named
|
||||
*
|
||||
* Every one of these issues a browser request that carries no record-session
|
||||
* assertion, and none of them is matched here. They are written down rather
|
||||
* than matched because each is either absent from the tree today or costs more
|
||||
* to detect than it currently buys — but a future offender in any of these
|
||||
* shapes SLIPS this guard, and that is the thing to know before trusting a
|
||||
* green run:
|
||||
*
|
||||
* * `srcSet` on an `<img>` / `<source>`, and `poster` on a `<video>`;
|
||||
* * `<object data=…>` and `<embed src=…>`;
|
||||
* * `<form action="/api/…">`, which navigates on submit;
|
||||
* * `window.open("/api/…")`;
|
||||
* * `navigator.sendBeacon("/api/…")`, which is fire-and-forget and cannot
|
||||
* carry a custom header at all;
|
||||
* * a CSS `url("/api/…")` in a style attribute, a styled component, or a
|
||||
* stylesheet;
|
||||
* * props SPREAD onto an element (`<img {...props} />`) where the path
|
||||
* arrives from a caller;
|
||||
* * anything outside `src/components` and `src/app` — those are the only two
|
||||
* trees walked.
|
||||
*
|
||||
* The `element.src = "/api/…"` ASSIGNMENT form IS matched, because it is cheap
|
||||
* and the tree already contains one (`umami-script.tsx`, an unfenced route, so
|
||||
* no offender today — but it is proof the shape occurs here).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const API_ROOT = join(SRC, "app", "api");
|
||||
|
||||
/** Client-side trees. `src/app/api` is the server end and is excluded. */
|
||||
const CLIENT_ROOTS = [join(SRC, "components"), join(SRC, "app")];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"__tests__",
|
||||
"__mocks__",
|
||||
"node_modules",
|
||||
".next",
|
||||
"generated",
|
||||
]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) {
|
||||
if (p === API_ROOT) continue;
|
||||
walk(p, out);
|
||||
} else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".test.ts") &&
|
||||
!name.endsWith(".test.tsx")
|
||||
) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `/api/...` path template to its route module.
|
||||
*
|
||||
* `${…}` segments match any `[param]` directory, which is how
|
||||
* `/api/documents/inbound/${id}/thumbnail` finds
|
||||
* `src/app/api/documents/inbound/[id]/thumbnail/route.ts`. Query strings and
|
||||
* trailing fragments are dropped before matching.
|
||||
*/
|
||||
function resolveRouteModules(path: string): string[] {
|
||||
const clean = path.split(/[?#]/)[0];
|
||||
const segments = clean
|
||||
.replace(/^\/api\//, "")
|
||||
.split("/")
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
let dirs = [API_ROOT];
|
||||
for (const segment of segments) {
|
||||
const templated = segment.includes("${");
|
||||
const next: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
if (!templated && existsSync(join(dir, segment))) {
|
||||
next.push(join(dir, segment));
|
||||
continue;
|
||||
}
|
||||
// A templated segment is a WILDCARD over every child directory, literal
|
||||
// and dynamic alike. `${id}` finds `[id]`; `${provider}` finds
|
||||
// `withings`, `fitbit`, `whoop`, `google-health`. Taking only the
|
||||
// dynamic children would have silently dropped the second shape, and a
|
||||
// path that resolves to nothing reads as "no fenced route here".
|
||||
if (!templated) continue;
|
||||
for (const child of readdirSync(dir)) {
|
||||
const p = join(dir, child);
|
||||
if (statSync(p).isDirectory()) next.push(p);
|
||||
}
|
||||
}
|
||||
dirs = next;
|
||||
}
|
||||
return dirs
|
||||
.map((dir) => join(dir, "route.ts"))
|
||||
.filter((module) => existsSync(module));
|
||||
}
|
||||
|
||||
/** Does this route resolve a RECORD, and therefore consult the fence? */
|
||||
function isFenced(routeModule: string): boolean {
|
||||
const src = readFileSync(routeModule, "utf8");
|
||||
return (
|
||||
/\brequireRecordAuth\s*\(/.test(src) ||
|
||||
/\brequireGuardianAuth\s*\(/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
interface Reference {
|
||||
file: string;
|
||||
line: number;
|
||||
sink: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const API_LITERAL = /^[`"']((?:\/api\/)[^`"']*)[`"']$/;
|
||||
|
||||
/**
|
||||
* Every `/api/...` path handed to a headerless transport in one file.
|
||||
*
|
||||
* Sinks: `src=`, `href=`, and any `window.location` write. Each is resolved
|
||||
* against a one-level alias map built from the file's own
|
||||
* `const NAME = "/api/…"` declarations.
|
||||
*/
|
||||
/**
|
||||
* Comments are stripped before matching.
|
||||
*
|
||||
* A doc comment that NAMES `src="/api/…"` while explaining why the code no
|
||||
* longer does that is prose, not a transport — and this guard's own
|
||||
* explanations are written in exactly that shape. Commented-out code is
|
||||
* dropped with them, which is correct: it issues no request. Same treatment
|
||||
* and the same reason as `account-payload-consumer-guard.test.ts`.
|
||||
*/
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function headerlessReferences(file: string, source: string): Reference[] {
|
||||
const aliases = new Map<string, string>();
|
||||
// `const NAME = "/api/…"`, and the ternary form
|
||||
// `const NAME = cond ? \`/api/…\` : "#"` that the download links use. Both
|
||||
// are one hop, which is the documented limit of this resolution.
|
||||
for (const m of source.matchAll(
|
||||
/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*[^;\n]*?([`"'])(\/api\/[^`"']*)\2/g,
|
||||
)) {
|
||||
if (!aliases.has(m[1])) aliases.set(m[1], m[3]);
|
||||
}
|
||||
|
||||
const found: Reference[] = [];
|
||||
const record = (index: number, sink: string, raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const literal = API_LITERAL.exec(trimmed);
|
||||
const path = literal ? literal[1] : (aliases.get(trimmed) ?? null);
|
||||
if (path === null) return;
|
||||
found.push({
|
||||
file,
|
||||
line: source.slice(0, index).split("\n").length,
|
||||
sink,
|
||||
path,
|
||||
});
|
||||
};
|
||||
|
||||
// `src={expr}` / `src="literal"` and the same for `href`.
|
||||
//
|
||||
// The expression container is read with a brace COUNTER, not a
|
||||
// `[^}]*` class: a template literal like `` `/api/x/${id}/y` `` contains a
|
||||
// `}` of its own, and a non-counting matcher truncates at it and then fails
|
||||
// to recognise the literal at all. That is how the first draft of this guard
|
||||
// found two of six offenders and reported success.
|
||||
for (const m of source.matchAll(/\b(src|href)=/g)) {
|
||||
const start = m.index + m[0].length;
|
||||
const ch = source[start];
|
||||
if (ch === "{") {
|
||||
let depth = 0;
|
||||
let end = start;
|
||||
for (; end < source.length; end++) {
|
||||
if (source[end] === "{") depth++;
|
||||
else if (source[end] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
record(m.index, m[1], source.slice(start + 1, end));
|
||||
} else if (ch === '"' || ch === "'" || ch === "`") {
|
||||
const end = source.indexOf(ch, start + 1);
|
||||
if (end !== -1) record(m.index, m[1], source.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
// Any `window.location` write or navigation.
|
||||
for (const m of source.matchAll(
|
||||
/window\.location(?:\.href)?\s*=\s*([^;\n]+)|window\.location\.(?:assign|replace)\(\s*([^)\n]+)\)/g,
|
||||
)) {
|
||||
record(m.index, "window.location", m[1] ?? m[2] ?? "");
|
||||
}
|
||||
// The imperative form: `el.src = "/api/…"`. A JSX attribute is the common
|
||||
// shape and the one the real offenders had, but a script or image built in
|
||||
// an effect issues exactly the same headerless request — and the tree
|
||||
// already contains that shape, so this is not a hypothetical.
|
||||
for (const m of source.matchAll(/\.\s*(src|href)\s*=\s*([^;\n]+)/g)) {
|
||||
record(m.index, `.${m[1]} =`, m[2]);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("no headerless browser transport reaches a fenced route", () => {
|
||||
const files = CLIENT_ROOTS.flatMap((root) => walk(root));
|
||||
const references = files.flatMap((file) =>
|
||||
headerlessReferences(
|
||||
file.slice(SRC.length + 1),
|
||||
stripComments(readFileSync(file, "utf8")),
|
||||
),
|
||||
);
|
||||
|
||||
it("finds a plausible client tree and a plausible set of references", () => {
|
||||
// Non-zero proof, twice: a walk that found nothing, or a matcher that
|
||||
// matched nothing, would make every assertion below vacuous.
|
||||
expect(files.length).toBeGreaterThan(200);
|
||||
expect(references.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it("resolves every referenced /api path to a route module", () => {
|
||||
// An unresolvable path is the one case where a green result would mean
|
||||
// nothing at all, so it fails loudly rather than being skipped.
|
||||
const unresolved = references.filter(
|
||||
(r) => resolveRouteModules(r.path).length === 0,
|
||||
);
|
||||
expect(unresolved.map((r) => `${r.file}:${r.line} → ${r.path}`)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the imperative `.src =` form, not only JSX attributes", () => {
|
||||
// A new matcher that matches nothing is a check that cannot fail, so it is
|
||||
// anchored to a real occurrence: the monitoring script builds its element
|
||||
// in an effect and assigns `.src`. That route is UNFENCED, so it is not an
|
||||
// offender — which is exactly why it makes a safe anchor, and why this leg
|
||||
// asserts the reference is SEEN rather than that it is refused.
|
||||
const assignments = references.filter((r) => r.sink === ".src =");
|
||||
expect(assignments.length).toBeGreaterThan(0);
|
||||
expect(assignments.map((r) => r.path)).toContain(
|
||||
"/api/monitoring/umami-script",
|
||||
);
|
||||
expect(
|
||||
resolveRouteModules("/api/monitoring/umami-script").some(isFenced),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a known fenced route, so the fenced check is live", () => {
|
||||
// Without this the leg below would pass for an `isFenced` that always
|
||||
// returned false.
|
||||
const thumbnail = resolveRouteModules(
|
||||
"/api/documents/inbound/${id}/thumbnail",
|
||||
);
|
||||
expect(thumbnail).toHaveLength(1);
|
||||
expect(isFenced(thumbnail[0])).toBe(true);
|
||||
const connect = resolveRouteModules("/api/withings/connect");
|
||||
expect(connect).toHaveLength(1);
|
||||
expect(isFenced(connect[0])).toBe(false);
|
||||
// The wildcard shape resolves to several real routes, none of them fenced.
|
||||
const providers = resolveRouteModules("/api/${provider}/connect");
|
||||
expect(providers.length).toBeGreaterThan(2);
|
||||
expect(providers.some(isFenced)).toBe(false);
|
||||
});
|
||||
|
||||
it("names no fenced route in a src=, href= or window.location sink", () => {
|
||||
const offenders = references.filter((r) =>
|
||||
resolveRouteModules(r.path).some(isFenced),
|
||||
);
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const report = offenders
|
||||
.map((o) => ` ❌ ${o.file}:${o.line} — ${o.sink}= ${o.path}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`${offenders.length} headerless reference(s) to a record-fenced route:\n${report}\n\n` +
|
||||
"These cannot carry the record-session fence headers, so they are refused with " +
|
||||
"403 sharing.access.denied on any session that has entered or left a shared " +
|
||||
"record. Fetch through `apiFetchRaw` and hand the DOM an object URL — see " +
|
||||
"`src/components/documents/document-ai-transport.ts` for the shape, and revoke " +
|
||||
"the URL when the component unmounts.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The ten acceptance cases of the record-session fence, mapped to the files
|
||||
* that prove them — as a runnable artifact rather than a table in a document.
|
||||
*
|
||||
* A dropped case is the failure this exists to catch. A test deleted during a
|
||||
* refactor takes its coverage with it silently; a document claiming ten cases
|
||||
* keeps claiming ten. So every case carries a stable id, the id appears in the
|
||||
* TITLE of at least one `it(` / `test(` in the file named below, and this file
|
||||
* asserts the mapping.
|
||||
*
|
||||
* ## What this guard cannot do, said plainly
|
||||
*
|
||||
* It proves an id appears in a test title. It does NOT prove that test asserts
|
||||
* anything worth asserting — that is what the named instruments are for, and
|
||||
* each case below records which instrument carries its weight. A guard that
|
||||
* only counted titles would be satisfiable by ten empty tests, so the
|
||||
* per-case `instrument` field is a review anchor, not a claim this file
|
||||
* verifies.
|
||||
*
|
||||
* ## Executed here versus deferred
|
||||
*
|
||||
* Five cases are proved by Vitest files that run in the ordinary gate. Five
|
||||
* ride `e2e/v137-record-session-fence.spec.ts` and therefore run only in the
|
||||
* Playwright acceptance stage. That split is recorded in `runner` below and
|
||||
* asserted, so nobody can read a green run of THIS file as "all ten proved" —
|
||||
* it means "all ten are claimed, and here is which ones have actually
|
||||
* executed".
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
interface AcceptanceCase {
|
||||
id: string;
|
||||
/** What the handoff asked for. */
|
||||
case: string;
|
||||
/** Where it lives, relative to the repository root. */
|
||||
home: string;
|
||||
/** Vitest runs in the ordinary gate; Playwright runs in the acceptance stage. */
|
||||
runner: "vitest" | "playwright";
|
||||
/** The thing that makes the assertion falsifiable. A review anchor. */
|
||||
instrument: string;
|
||||
}
|
||||
|
||||
const CASES: AcceptanceCase[] = [
|
||||
{
|
||||
id: "FENCE-AC-01",
|
||||
case: "Switch in and out; peers reconcile to the exact new epoch and scope",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Every record read the peer issues afterwards is inspected for the asserted epoch and scope; the epoch must have moved and the scope must name the record, not merely differ. Paired with a shell-ready assertion so a wedged page cannot pass.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-02",
|
||||
case: "Server switch driven externally, initiator response aborted",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Peers never assert a guessed owner scope under the bootstrap sentinel, AND do reach ready on the target scope once /me resolves. The second half is mandatory: an absence-only assertion passes on a permanently wedged page.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-03",
|
||||
case: "Initiator closed after the server commit, before any commit broadcast",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"The surviving tab reaches ready rather than sitting behind a permanent hydration gate — /me is the bootstrap and answers regardless of who began the transition.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-04",
|
||||
case: "Raw or external switch; a stale peer read and mutation refused before handler or database work",
|
||||
home: "tests/integration/record-session-fence.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"AccountGrant.lastUsedAt unchanged and no audit_logs row with action = 'sharing.record.accessed' for the pair — both are written only past admission, so their absence is the no-database-work proof. Asserted BEFORE the status code so a removed fence fails on the instrument rather than being masked. Paired with a positive control that moves both. Also the findFirst zero-call spy in src/lib/__tests__/acting-account-resolver.test.ts, plus an e2e leg driving the switch from a request context that runs no client journal.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-05",
|
||||
case: "Owner idempotent mutation and replay, switch before handler authorization",
|
||||
home: "tests/integration/record-session-fence-idempotency.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"The seeded owner cell is byte-unchanged — same id, responseStatus, responseBody, expiresAt, createdAt. expiresAt carries the weight: completion rewrites it to now+24h and a fresh claim to now+2min, so any wrapper that reached the cache moves it. The 'no row exists' assertion was rejected as unfalsifiable, because releaseClaim deletes the pending row whenever the handler throws.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-06",
|
||||
case: "Record response delayed across the switch; the client discards and holds",
|
||||
home: "src/lib/api/__tests__/record-fence-client.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"A response whose echoed epoch is one behind the adopted one makes the caller throw and fires the hold exactly once, while a response carrying NO echo is returned untouched and fires nothing. The second half is the version-poller case and is what stops the rule from being discard-on-absence. An e2e leg holds a real record read open across a real switch.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-07",
|
||||
case: "CacheStorage seeded with one record's responses; the disk layer cannot serve them under another",
|
||||
home: "e2e/v137-record-session-fence-offline.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Two tests, contents and serving, because the first alone is not the claim. CONTENTS: a non-zero seeded entry count as the positive control, the cache-version eviction, then every stored response carrying the target's scope and none the previous record's. SERVING: after an EXTERNAL switch — the path on which the app never runs its cache wipe, so the previous record's entries survive — cache.match() under the stored context hits (positive control) and under the new context misses. Removing the Vary block makes the serving test fail and leaves the contents test green, which is why both exist. NOT asserted: a zero entry count after a switch (the wipe is real but the reload refills it within milliseconds), and no offline leg — a bare page-context fetch cannot match a Vary-keyed entry, which is itself the property the serving test proves. Its own spec and its own Playwright project because every other project sets serviceWorkers: 'block', under which the data cache is never populated and the positive control could not pass whatever the application did.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-08",
|
||||
case: "Concurrent switchers; monotonic compare-and-set ordering, a stale switch cannot override",
|
||||
home: "tests/integration/record-session-fence.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"Two same-epoch switches to genuinely distinct targets resolve to exactly one 'applied' and one 'stale', and the epoch handed back to the winner equals the row's committed value. A separate leg pins the no-op case, where the trigger declines to move and 'applied' is the honest answer.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-09",
|
||||
case: "Revoke, account deletion, managed-profile lifecycle, FK-driven clearing",
|
||||
home: "tests/integration/record-session-epoch-trigger.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"The ON DELETE SET NULL referential action and a raw SQL clear both bump the epoch with no application code running, while the per-request last_active_at and expires_at writes do not. A lifecycle leg in tests/integration/record-session-fence.test.ts then asserts the fence refuses the pre-transition context after a revoke and after an owner deletion.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-10",
|
||||
case: "Pre-fence cookie callers fail closed into their reload; token and native keep the frozen contract",
|
||||
home: "src/__tests__/record-session-fence-compat.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"Every link in the pre-fence tab's only recovery chain is frozen, and the 409 is asserted ABSENT from the grant-loss predicate. The fence's own positive controls live in tests/integration/record-session-fence.test.ts: a Bearer request with no fence header is served, and an epoch-zero cookie request with no fence header is served. Without those two a fence that refused everything would pass every negative test in this plan.",
|
||||
},
|
||||
];
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(ROOT, rel), "utf8");
|
||||
}
|
||||
|
||||
/** Every `it(` / `test(` title in a file. */
|
||||
function testTitles(source: string): string[] {
|
||||
const titles: string[] = [];
|
||||
for (const m of source.matchAll(
|
||||
/\b(?:it|test)(?:\.\w+)*\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g,
|
||||
)) {
|
||||
titles.push(m[2]);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
describe("the ten acceptance cases are all claimed by a runnable test", () => {
|
||||
it("declares exactly ten distinct ids", () => {
|
||||
expect(CASES).toHaveLength(10);
|
||||
expect(new Set(CASES.map((c) => c.id)).size).toBe(10);
|
||||
for (const c of CASES) {
|
||||
expect(c.id).toMatch(/^FENCE-AC-\d{2}$/);
|
||||
// A review anchor with nothing in it is not one.
|
||||
expect(c.instrument.length).toBeGreaterThan(80);
|
||||
expect(c.case.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(CASES)("$id appears in a test title in $home", (c) => {
|
||||
const titles = testTitles(read(c.home));
|
||||
// Non-zero proof: a matcher that found no titles at all would make the
|
||||
// membership check below vacuous.
|
||||
expect(titles.length).toBeGreaterThan(0);
|
||||
const carrying = titles.filter((t) => t.includes(c.id));
|
||||
expect(carrying.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("records which cases have actually executed in this gate", () => {
|
||||
// Five cases ride Playwright and therefore prove nothing until the
|
||||
// acceptance stage runs. Stating the split here is what stops a green run
|
||||
// of this file from being read as "all ten proved".
|
||||
const vitest = CASES.filter((c) => c.runner === "vitest").map((c) => c.id);
|
||||
const playwright = CASES.filter((c) => c.runner === "playwright").map(
|
||||
(c) => c.id,
|
||||
);
|
||||
|
||||
expect(vitest).toEqual([
|
||||
"FENCE-AC-04",
|
||||
"FENCE-AC-05",
|
||||
"FENCE-AC-06",
|
||||
"FENCE-AC-08",
|
||||
"FENCE-AC-09",
|
||||
"FENCE-AC-10",
|
||||
]);
|
||||
expect(playwright).toEqual([
|
||||
"FENCE-AC-01",
|
||||
"FENCE-AC-02",
|
||||
"FENCE-AC-03",
|
||||
"FENCE-AC-07",
|
||||
]);
|
||||
expect(vitest.length + playwright.length).toBe(10);
|
||||
});
|
||||
|
||||
it("names only files that exist and carry real tests", () => {
|
||||
for (const home of new Set(CASES.map((c) => c.home))) {
|
||||
const source = read(home);
|
||||
expect(source.length).toBeGreaterThan(500);
|
||||
expect(testTitles(source).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* FENCE-AC-10 — the only recovery a pre-fence browser tab has, frozen.
|
||||
*
|
||||
* A tab open at deploy time that is inside or after a shared-record context
|
||||
* sends no fence header, so the server refuses it with 403
|
||||
* `sharing.access.denied`. That is not an arbitrary choice of code: it is the
|
||||
* one code the CURRENTLY DEPLOYED bundle already acts on. `SharedRecordGrantLossBridge`
|
||||
* is mounted globally, subscribes to the whole query cache, and on the first
|
||||
* errored query carrying that code leaves the record and performs a full
|
||||
* document navigation — which serves the fence-aware bundle. One reload,
|
||||
* self-healing, no new code in a bundle nobody can edit any more.
|
||||
*
|
||||
* Every link in that chain is load-bearing and every one of them looks
|
||||
* removable to somebody tidying up. So each is asserted here, with a non-zero
|
||||
* match first, and the reason written down.
|
||||
*
|
||||
* The fourth leg is the opposite assertion and matters as much: the 409
|
||||
* `sharing.session.changed` must NOT be in the set the grant-loss bridge fires
|
||||
* on. That code means "reconcile, do not leave"; routing it into a bridge that
|
||||
* hard-navigates out of the record would turn a one-frame hold into an
|
||||
* eviction, and would let one confused tab yank every other tab of the same
|
||||
* browser out of a record nobody revoked.
|
||||
*
|
||||
* Break it by adding `sharing.session.changed` to the bridge's predicate.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
const SWITCH_HOOK = read("hooks/use-account-switch.ts");
|
||||
const PROVIDERS = read("components/providers.tsx");
|
||||
const GRANT_LOSS_BRIDGE = read(
|
||||
"components/layout/shared-record-grant-loss-bridge.tsx",
|
||||
);
|
||||
const FENCE_BRIDGE = read("components/layout/record-session-fence-bridge.tsx");
|
||||
|
||||
describe("FENCE-AC-10 a pre-fence tab can still recover", () => {
|
||||
it("FENCE-AC-10 still keys the grant-loss predicate on sharing.access.denied", () => {
|
||||
// Non-zero proof first: an emptied file would satisfy a bare `toContain`
|
||||
// check on nothing.
|
||||
expect(SWITCH_HOOK.length).toBeGreaterThan(1000);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/SHARING_ACCESS_DENIED\s*=\s*"sharing\.access\.denied"/,
|
||||
);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/export function isSharingAccessDenied[\s\S]{0,200}SHARING_ACCESS_DENIED/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 still mounts the grant-loss bridge globally", () => {
|
||||
expect(PROVIDERS).toContain("SharedRecordGrantLossBridge");
|
||||
expect(PROVIDERS).toMatch(/<SharedRecordGrantLossBridge\s*\/>/);
|
||||
// And the bridge still subscribes rather than asking call sites.
|
||||
expect(GRANT_LOSS_BRIDGE).toContain("subscribeToGrantLoss");
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/subscribeToGrantLoss[\s\S]{0,600}isSharingAccessDenied\(event\.query\.state\.error\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 still leaves the record with a full document navigation", () => {
|
||||
// `router.push` would not do: a soft navigation keeps the in-memory cache,
|
||||
// every React tree holding a rendered value, and — critically here — the
|
||||
// PRE-FENCE JavaScript bundle. The reload is what serves the new one.
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/async function landOnRecord[\s\S]{0,400}window\.location\.assign\(destination\)/,
|
||||
);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/useLeaveSharedRecordOnGrantLoss[\s\S]{0,700}await landOnRecord\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 does NOT route the 409 into the grant-loss bridge", () => {
|
||||
// The predicate the bridge fires on.
|
||||
const predicate = /subscribeToGrantLoss\(([\s\S]*?)\n\}/.exec(SWITCH_HOOK);
|
||||
expect(predicate).not.toBeNull();
|
||||
const body = predicate![1];
|
||||
expect(body.length).toBeGreaterThan(50);
|
||||
expect(body).toContain("isSharingAccessDenied");
|
||||
expect(body).not.toContain("isRecordSessionChanged");
|
||||
expect(body).not.toContain("sharing.session.changed");
|
||||
// The grant-loss bridge component itself must not learn about it either.
|
||||
expect(GRANT_LOSS_BRIDGE).not.toContain("sharing.session.changed");
|
||||
expect(GRANT_LOSS_BRIDGE).not.toContain("isRecordSessionChanged");
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 routes the 409 into a reconciling bridge instead", () => {
|
||||
// The positive control for the leg above: without it, "the 409 is not in
|
||||
// the grant-loss bridge" would pass for a release that dropped the 409
|
||||
// handling altogether.
|
||||
expect(PROVIDERS).toMatch(/<RecordSessionFenceBridge\s*\/>/);
|
||||
expect(FENCE_BRIDGE).toContain("isRecordSessionChanged");
|
||||
expect(FENCE_BRIDGE).toContain("holdForRecordSessionReconcile");
|
||||
expect(FENCE_BRIDGE).toContain("onRecordFenceMismatch");
|
||||
// It reconciles; it does not leave.
|
||||
expect(FENCE_BRIDGE).not.toContain("landOnRecord");
|
||||
expect(FENCE_BRIDGE).not.toContain("window.location.assign");
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 keeps the two codes distinct at the source", () => {
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/SHARING_SESSION_CHANGED\s*=\s*"sharing\.session\.changed"/,
|
||||
);
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/export function isRecordSessionChanged[\s\S]{0,200}SHARING_SESSION_CHANGED/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-10 the browser adopts a context from exactly two responses", () => {
|
||||
it("FENCE-AC-10 has exactly two callers of adoptRecordFenceState", () => {
|
||||
// A third adoption path would be a third source of truth about which
|
||||
// record this browser is on — and it would arrive on the very responses
|
||||
// whose context is in doubt. The 409 body therefore carries an error code
|
||||
// and nothing else, by construction.
|
||||
const CONSUMERS = [
|
||||
// `GET /api/auth/me`, the bootstrap.
|
||||
"hooks/use-auth.ts",
|
||||
// `POST /api/account/switch`, the only other canonical resolver.
|
||||
"hooks/use-account-switch.ts",
|
||||
];
|
||||
|
||||
const callers = CONSUMERS.filter((rel) =>
|
||||
/adoptRecordFenceState\(/.test(read(rel)),
|
||||
);
|
||||
expect(callers).toEqual(CONSUMERS);
|
||||
|
||||
// And no third file calls it. The declaring module is allowed to name it.
|
||||
for (const rel of [
|
||||
"components/layout/record-session-fence-bridge.tsx",
|
||||
"components/layout/shared-record-grant-loss-bridge.tsx",
|
||||
"lib/api/api-fetch.ts",
|
||||
]) {
|
||||
expect(read(rel)).not.toContain("adoptRecordFenceState");
|
||||
}
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 adopts before it releases a hold", () => {
|
||||
// A hold released onto an unadopted context puts the shell back on screen
|
||||
// while every subsequent request still asserts `bootstrap`.
|
||||
const src = read("hooks/use-auth.ts");
|
||||
const adopt = src.indexOf("adoptRecordFenceState(data.recordSession)");
|
||||
const settle = src.indexOf("settleRecordSessionTransition(");
|
||||
const scope = src.indexOf("setRecordScope(");
|
||||
expect(adopt).toBeGreaterThan(-1);
|
||||
expect(settle).toBeGreaterThan(-1);
|
||||
expect(scope).toBeGreaterThan(-1);
|
||||
expect(adopt).toBeLessThan(scope);
|
||||
expect(adopt).toBeLessThan(settle);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 names the epoch on every switch", () => {
|
||||
// A fence-aware client never omits `expectedEpoch`; the server's
|
||||
// unconditional arm is reachable only by a bundle that predates the fence.
|
||||
expect(SWITCH_HOOK).toMatch(
|
||||
/apiPost<SwitchResponse>\([\s\S]{0,200}expectedEpoch:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FENCE-AC-10 the transport attaches the assertion, not the call sites", () => {
|
||||
it("FENCE-AC-10 wraps all three fetch entries", () => {
|
||||
const src = read("lib/api/api-fetch.ts");
|
||||
const wraps = [...src.matchAll(/withRecordFenceHeaders\(/g)];
|
||||
const validates = [...src.matchAll(/validateResponseContext\(/g)];
|
||||
// Three entries: `apiFetch`, `apiFetchEnvelope`, `apiFetchRaw`. The raw one
|
||||
// matters most — ~25 call sites including the Coach SSE stream, several of
|
||||
// which are record traffic.
|
||||
expect(wraps.length).toBe(3);
|
||||
expect(validates.length).toBe(3);
|
||||
expect(src).toMatch(
|
||||
/export async function apiFetchRaw[\s\S]{0,900}withRecordFenceHeaders\(init\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("FENCE-AC-10 imports the header names from the client-safe contract only", () => {
|
||||
// Importing the server fence or the acting carrier would pull `next/headers`
|
||||
// or Prisma into the browser bundle: clean typecheck, failed `pnpm build`.
|
||||
const src = read("lib/api/record-fence.ts");
|
||||
expect(src).toContain('from "@/lib/sharing/record-session-fence-contract"');
|
||||
expect(src).not.toContain('from "@/lib/sharing/record-session-fence"');
|
||||
expect(src).not.toContain('from "@/lib/auth/acting-carrier"');
|
||||
expect(src).not.toContain('from "@/lib/api-handler"');
|
||||
expect(src).not.toContain('from "@/lib/db"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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: declared in the contract
|
||||
* module, read by the server fence, attached by the browser transport, and
|
||||
* published by the OpenAPI route module.
|
||||
*
|
||||
* ## 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. It must not be read as "no admin route
|
||||
* is ever fenced", and it must not be read as the reverse either.
|
||||
* `POST /api/admin/backups/[id]/restore` composes `withIdempotency` OUTSIDE
|
||||
* `apiHandler`, so the wrapper's fence check runs on it — but that check
|
||||
* refuses only the STALE half. An `unfenced-client` verdict falls through to
|
||||
* the handler, whose `requireAdmin` is on this list, so a headerless admin
|
||||
* request is served exactly as it was before the fence existed. The accurate
|
||||
* claim is therefore narrow: an idempotent admin write under a context the
|
||||
* client asserted WRONGLY is refused; one that asserts nothing is not.
|
||||
*
|
||||
* 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",
|
||||
// Reads it off the same session lookup the idempotency wrapper already
|
||||
// makes, so the fence can be evaluated above the replay cache without a
|
||||
// second round trip.
|
||||
"lib/auth/acting-carrier.ts",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is consulted by the idempotency wrapper, above the cache", () => {
|
||||
const src = read("lib/idempotency.ts");
|
||||
const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)");
|
||||
const cache = src.indexOf("findCached(ctx)");
|
||||
const claimRow = src.indexOf("claimKey(ctx)");
|
||||
expect(fence).toBeGreaterThan(-1);
|
||||
expect(cache).toBeGreaterThan(-1);
|
||||
expect(claimRow).toBeGreaterThan(-1);
|
||||
// A stale request must not learn whether a cell exists, replay a body out
|
||||
// of one, or insert a claim row. The runtime proof is the zero-call
|
||||
// instrument in `src/lib/__tests__/idempotency.test.ts`; this is the
|
||||
// tripwire that says the line moved.
|
||||
expect(fence).toBeLessThan(cache);
|
||||
expect(fence).toBeLessThan(claimRow);
|
||||
// And it comes from the fence module, not from a second copy of the rule.
|
||||
expect(src).toContain('from "@/lib/sharing/record-session-fence"');
|
||||
});
|
||||
|
||||
it("does not leak the raw counter into the browser's vocabulary", () => {
|
||||
// The client never names the epoch as a column. It receives
|
||||
// `recordSession: { epoch, scope }` and asserts it back as a header, which
|
||||
// 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([
|
||||
// Attaches them to every same-origin request, and validates the echo.
|
||||
"lib/api/record-fence.ts",
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES,
|
||||
resolveSharedRecordNavigation,
|
||||
} from "@/lib/navigation/shared-record";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import {
|
||||
isDestinationInSharedRecord,
|
||||
visibleNavDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
|
||||
describe("shared-record navigation", () => {
|
||||
it("keeps every granted route family in one closed eight-domain inventory", () => {
|
||||
expect(Object.keys(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES)).toEqual(
|
||||
SHARE_DOMAINS,
|
||||
);
|
||||
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].length,
|
||||
`${domain} needs at least one route family`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a scoped grant only its matching doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["labs"]);
|
||||
|
||||
expect(navigation.destinationHrefs).toEqual(["/labs"]);
|
||||
expect(navigation.allowsPath("/labs/panel/7")).toBe(true);
|
||||
expect(navigation.allowsPath("/medications")).toBe(false);
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(undefined, true, true, ["labs"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/labs"]);
|
||||
expect(isDestinationInSharedRecord("/labs/panel/7", ["labs"])).toBe(true);
|
||||
expect(isDestinationInSharedRecord("/medications", ["labs"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["profile"]);
|
||||
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile"]);
|
||||
expect(navigation.allowsPath("/profile")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the active record scope instead of the actor's module flags", () => {
|
||||
const actorModules = {
|
||||
medications: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
mentalHealth: false,
|
||||
cycle: false,
|
||||
illness: false,
|
||||
inboundDocuments: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(actorModules, true, true, ["medications"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/medications"]);
|
||||
});
|
||||
|
||||
it("keeps whole-record navigation distinct from an all-sections scope", () => {
|
||||
const wholeRecord = resolveSharedRecordNavigation(null);
|
||||
const allSections = resolveSharedRecordNavigation([...SHARE_DOMAINS]);
|
||||
|
||||
expect(wholeRecord.allowsPath("/")).toBe(true);
|
||||
expect(allSections.allowsPath("/")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses unclassified direct URLs in presentation before pages start reads", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["documents"]);
|
||||
|
||||
expect(navigation.allowsPath("/documents/record-1")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/account")).toBe(false);
|
||||
expect(navigation.allowsPath("/new-unclassified-route")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps server-resolved access and record kind distinct in chrome", () => {
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "read",
|
||||
canWrite: false,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "view", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "view-and-add", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "manage", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
recordKind: "managed",
|
||||
}),
|
||||
).toEqual({ access: "manage", recordKind: "managed" });
|
||||
});
|
||||
|
||||
it("fails closed when a malformed entry reaches presentation", () => {
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: false,
|
||||
recordKind: "managed",
|
||||
} as never),
|
||||
).toEqual({ access: "view", recordKind: "self" });
|
||||
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "unknown",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
} as never),
|
||||
).toEqual({ access: "view", recordKind: "self" });
|
||||
});
|
||||
});
|
||||
@@ -879,6 +879,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.",
|
||||
},
|
||||
"app/api/mood-entries/route.ts": {
|
||||
domain: "mind",
|
||||
why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.",
|
||||
@@ -939,6 +943,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "cycle",
|
||||
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
|
||||
},
|
||||
"app/api/cycle/calendar/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The initial cycle calendar read. Its gate resolves tracking eligibility from the substituted record id, and the handler only reads that record's bounded calendar inputs.",
|
||||
},
|
||||
"app/api/cycle/insights/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The initial cycle insights read. Its rate-limit key and every phase-correlation input use the resolved record id; it creates no provider egress or mutation.",
|
||||
},
|
||||
"app/api/cycle/day-logs/route.ts": {
|
||||
domain: "cycle",
|
||||
why: 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.',
|
||||
@@ -1793,9 +1805,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* find out from the merge rather than from production. Import-status polling
|
||||
* adds two record entries and two MANAGE entries: 190 → 194. Managed-record
|
||||
* settings add two guardian-only entries: 194 → 196. Field-specific
|
||||
* managed-record configuration adds one more: 196 → 197.
|
||||
* managed-record configuration adds one more: 196 → 197. The bounded
|
||||
* read-only profile summary makes the next explicit admission: 197 → 198.
|
||||
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 197;
|
||||
const FROZEN_ENTRY_COUNT = 200;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -13,10 +10,6 @@ import {
|
||||
NOTIFICATION_CHANNELS,
|
||||
NOTIFICATION_DELIVERY_MATRIX,
|
||||
} from "../../tests/fixtures/v137/notification-matrix";
|
||||
import {
|
||||
ACCESSIBILITY_STATES,
|
||||
RELEASE_JOURNEYS,
|
||||
} from "../../tests/fixtures/v137/e2e-journeys";
|
||||
|
||||
describe("sharing and handler inventories", () => {
|
||||
it("freezes the exact sharing-domain vocabulary", () => {
|
||||
@@ -104,28 +97,3 @@ describe("security principals and compatibility", () => {
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS[3]?.legacyDecoder).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("journey and accessibility inventory", () => {
|
||||
it("names every release journey and accessibility state exactly once", () => {
|
||||
expect(RELEASE_JOURNEYS).toHaveLength(8);
|
||||
expect(ACCESSIBILITY_STATES).toHaveLength(5);
|
||||
expect(new Set(RELEASE_JOURNEYS.map(({ name }) => name)).size).toBe(
|
||||
RELEASE_JOURNEYS.length,
|
||||
);
|
||||
expect(new Set(ACCESSIBILITY_STATES.map(({ name }) => name)).size).toBe(
|
||||
ACCESSIBILITY_STATES.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the browser anchors discoverable without focused or skipped cases", () => {
|
||||
const anchorFiles = [
|
||||
"v137-sharing-managed-profiles.spec.ts",
|
||||
"v137-sharing-managed-profiles-a11y.spec.ts",
|
||||
];
|
||||
|
||||
for (const file of anchorFiles) {
|
||||
const source = readFileSync(join(process.cwd(), "e2e", file), "utf8");
|
||||
expect(source).not.toMatch(/\b(?:test|describe)\.(?:only|skip)\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -43,7 +43,7 @@ const DEFAULT_FORWARD_DAYS = 180;
|
||||
const MAX_SPAN_DAYS = 400;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* is causal.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -50,7 +50,7 @@ const WINDOW_DAYS = 365;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -100,6 +100,7 @@ const SESSION_OK = {
|
||||
id: "sess-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: null,
|
||||
recordEpoch: 0,
|
||||
},
|
||||
user: SESSION_USER as never,
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ function signedIn() {
|
||||
id: "s1",
|
||||
expiresAt: new Date(Date.now() + 1e6),
|
||||
actingAsUserId: null,
|
||||
recordEpoch: 0,
|
||||
},
|
||||
user: { id: "user-1" } as never,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
localDayWindow,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
@@ -797,30 +798,45 @@ async function postMeasurement(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const results = await prisma.$transaction(
|
||||
parsed.data.measurements.map((m) =>
|
||||
prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: getUnitForType(m.type),
|
||||
source: (m.source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt: m.measuredAt,
|
||||
// v1.23 — encrypt the note at rest; the legacy plaintext column is
|
||||
// written null for new rows.
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// v1.4.25 W10 reconcile (code-review M4): mirror the
|
||||
// single-entry path so the multi-entry batch persists
|
||||
// `deviceType` instead of silently dropping it.
|
||||
deviceType: m.deviceType ?? null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
// The whole batch is one transaction, so a duplicate anywhere in it rolls
|
||||
// the rest back — and the caller has to be told which condition it hit.
|
||||
// Untouched, that duplicate escaped as an unhandled error and the batch
|
||||
// answered 500, which is indistinguishable from a server fault and is what
|
||||
// every client retries against.
|
||||
let results;
|
||||
try {
|
||||
results = await prisma.$transaction(
|
||||
parsed.data.measurements.map((m) =>
|
||||
prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: getUnitForType(m.type),
|
||||
source: (m.source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt: m.measuredAt,
|
||||
// v1.23 — encrypt the note at rest; the legacy plaintext column is
|
||||
// written null for new rows.
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// v1.4.25 W10 reconcile (code-review M4): mirror the
|
||||
// single-entry path so the multi-entry batch persists
|
||||
// `deviceType` instead of silently dropping it.
|
||||
deviceType: m.deviceType ?? null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
return apiError("A measurement with this data already exists", 409, {
|
||||
errorCode: "measurement.duplicate_timestamp",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const shapedResults = results.map(shapeMeasurementNotes);
|
||||
|
||||
@@ -984,10 +1000,13 @@ async function postMeasurement(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
// `isP2002` rather than `instanceof Prisma.PrismaClientKnownRequestError`.
|
||||
// The helper's own docblock says why it exists: the structural check
|
||||
// survives client-bundling quirks, and the `instanceof` form does not —
|
||||
// when it misses, a duplicate escapes as an unhandled error and the caller
|
||||
// gets a 500 for a condition the route has always meant to answer with a
|
||||
// 409. A retry loop that meets a 500 keeps retrying.
|
||||
if (isP2002(err)) {
|
||||
return apiError("A measurement with this data already exists", 409, {
|
||||
errorCode: "measurement.duplicate_timestamp",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readProfileSummary } from "@/lib/records/profile-summary";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Read-only shared-record profile summary. The underlying fields are bounded
|
||||
* and structured so this endpoint never becomes a Settings or AI surface.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const summary = await readProfileSummary(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "profile.summary.get" },
|
||||
meta: {
|
||||
allergy_count: summary.allergies.length,
|
||||
family_history_count: summary.familyHistory.length,
|
||||
fact_count: summary.facts.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(summary);
|
||||
});
|
||||
@@ -44,6 +44,12 @@ vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
vi.mock("@/hooks/use-disable-coach", () => ({
|
||||
useDisableCoach: () => false,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: {}, isLoading: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: false }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
|
||||
@@ -163,6 +165,8 @@ export default function CoachPageClient() {
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const { user, isLoading } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
|
||||
const coachUnavailable = !flags.coach || disableCoach;
|
||||
|
||||
@@ -170,11 +174,13 @@ export default function CoachPageClient() {
|
||||
// operator master flag OR per-user opt-out hides the Coach entirely.
|
||||
// Send a direct navigator back to the Insights mother page so the
|
||||
// route is never a dead-end.
|
||||
const canRenderOwnCoach = user !== null && !isLoading && !inSharedRecord;
|
||||
|
||||
useEffect(() => {
|
||||
if (coachUnavailable) {
|
||||
if (canRenderOwnCoach && coachUnavailable) {
|
||||
router.replace("/insights");
|
||||
}
|
||||
}, [coachUnavailable, router]);
|
||||
}, [canRenderOwnCoach, coachUnavailable, router]);
|
||||
|
||||
// Reopening the bottom-right FAB drawer is no longer wired to a page
|
||||
// control, but keep the launch context referenced so the lint rule does
|
||||
@@ -182,7 +188,7 @@ export default function CoachPageClient() {
|
||||
// the shared `<AuthShell>`.
|
||||
void launch;
|
||||
|
||||
if (coachUnavailable) return null;
|
||||
if (!canRenderOwnCoach || coachUnavailable) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { CycleView } from "@/components/cycle/cycle-view";
|
||||
|
||||
@@ -22,9 +23,10 @@ import { CycleView } from "@/components/cycle/cycle-view";
|
||||
*/
|
||||
export default function CyclePage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = user?.modules?.cycle !== false;
|
||||
const enabled = inSharedRecord || user?.modules?.cycle !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -24,11 +25,12 @@ export default function IllnessEpisodePage({
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const enabled = user?.modules?.illness === true;
|
||||
const enabled = inSharedRecord || user?.modules?.illness === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { IllnessView } from "@/components/illness/illness-view";
|
||||
|
||||
@@ -20,10 +21,11 @@ import { IllnessView } from "@/components/illness/illness-view";
|
||||
*/
|
||||
export default function IllnessPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
// Born-gated: enabled ONLY when the resolved map says `true`.
|
||||
const enabled = user?.modules?.illness === true;
|
||||
const enabled = inSharedRecord || user?.modules?.illness === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
+15
-4
@@ -17,7 +17,10 @@ import {
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
|
||||
import { useOcrCapability } from "@/components/labs/use-ocr-extract";
|
||||
import {
|
||||
shouldProbeOcrCapability,
|
||||
useOcrCapability,
|
||||
} from "@/components/labs/use-ocr-extract";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -41,7 +44,7 @@ export default function LabsPage() {
|
||||
// Scanning a report is not: it files a document and commits through the OCR
|
||||
// route, and neither is delegated. So a WRITE delegate keeps the manual add
|
||||
// and loses the choice menu around it.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -53,14 +56,22 @@ export default function LabsPage() {
|
||||
// v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows
|
||||
// when the user's configured AI provider can read images (capability probe).
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const ocrCapability = useOcrCapability(isAuthenticated);
|
||||
|
||||
// Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/labs/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = user?.modules?.labs !== false;
|
||||
const enabled = inSharedRecord || user?.modules?.labs !== false;
|
||||
const ocrCapability = useOcrCapability(
|
||||
shouldProbeOcrCapability({
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
labsEnabled: enabled,
|
||||
mounted,
|
||||
canManage,
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
|
||||
@@ -168,13 +168,14 @@ export default function MedicationsPageClient() {
|
||||
// to tapping each card in turn — so withholding it made a caregiver with
|
||||
// five morning tablets tap five times for nothing. The customize page stays
|
||||
// withheld; sharing does not cover it at all.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
// Default-on: an absent key reads as enabled, so the page only hides on an
|
||||
// explicit `false`.
|
||||
const medicationsEnabled = user?.modules?.medications !== false;
|
||||
const medicationsEnabled =
|
||||
inSharedRecord || user?.modules?.medications !== false;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
|
||||
@@ -26,9 +27,10 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
*/
|
||||
export default function MentalWellbeingPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = user?.modules?.mentalHealth === true;
|
||||
const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -37,7 +37,7 @@ export default function MoodPage() {
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = user?.modules?.mood !== false;
|
||||
const enabled = inSharedRecord || user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProfileSummaryPage } from "@/components/records/profile-summary";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileSummaryPage />;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRecordPreloadReady } from "@/components/providers";
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
setRecordScope,
|
||||
setRefusedRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
describe("isRecordPreloadReady", () => {
|
||||
it("waits for the account response before warming record reads", () => {
|
||||
__resetRecordScopeForTests();
|
||||
|
||||
expect(
|
||||
isRecordPreloadReady({ isLoading: true, accountAccessStatus: "valid" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isRecordPreloadReady({
|
||||
isLoading: false,
|
||||
accountAccessStatus: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only a resolved own-record response with an own-record scope", () => {
|
||||
__resetRecordScopeForTests();
|
||||
setRecordScope(null);
|
||||
|
||||
expect(
|
||||
isRecordPreloadReady({
|
||||
isLoading: false,
|
||||
accountAccessStatus: "valid",
|
||||
activeAccountId: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
setRecordScope("shared-record");
|
||||
expect(
|
||||
isRecordPreloadReady({
|
||||
isLoading: false,
|
||||
accountAccessStatus: "valid",
|
||||
activeAccountId: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a shared-record response only after its exact scope is mirrored", () => {
|
||||
__resetRecordScopeForTests();
|
||||
setRecordScope("shared-record");
|
||||
|
||||
expect(
|
||||
isRecordPreloadReady({
|
||||
isLoading: false,
|
||||
accountAccessStatus: "valid",
|
||||
activeAccountId: "shared-record",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
setRecordScope(null);
|
||||
expect(
|
||||
isRecordPreloadReady({
|
||||
isLoading: false,
|
||||
accountAccessStatus: "valid",
|
||||
activeAccountId: "shared-record",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never opens a preload window for an explicitly refused record", () => {
|
||||
__resetRecordScopeForTests();
|
||||
setRefusedRecordScope();
|
||||
|
||||
expect(
|
||||
isRecordPreloadReady({
|
||||
isLoading: false,
|
||||
accountAccessStatus: "invalid",
|
||||
activeAccountId: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,16 @@ const FATHER = {
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
const MANAGED_PROFILE = {
|
||||
accountId: "managed-profile",
|
||||
username: "managed-profile",
|
||||
displayName: "Managed profile",
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
sections: null,
|
||||
recordKind: "managed" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
@@ -130,6 +140,31 @@ describe("<LookingAfterCard>", () => {
|
||||
expect(html).not.toContain("Can view and add");
|
||||
});
|
||||
|
||||
it("keeps MANAGE and managed profile labels separate", () => {
|
||||
const html = render({
|
||||
accounts: [MANAGED_PROFILE],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('data-access-level="manage"');
|
||||
expect(html).toContain('data-record-kind="managed"');
|
||||
expect(html).toContain("Can read the record, add to it, and change");
|
||||
expect(html).toContain("Managed profile");
|
||||
});
|
||||
|
||||
it("names the record button with its action, access, and kind", () => {
|
||||
const html = render({
|
||||
accounts: [MANAGED_PROFILE],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
|
||||
expect(html).toMatch(
|
||||
/aria-label="Open Managed profile's record; Can read the record, add to it, and change or remove what is in it; Managed profile;/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no health data, only a doorway", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER],
|
||||
|
||||
@@ -7,6 +7,11 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAccountOnceMounted } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
resolveRecordPresentation,
|
||||
type RecordPresentation,
|
||||
} from "@/lib/navigation/record-presentation";
|
||||
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
@@ -77,42 +82,61 @@ export function LookingAfterCard() {
|
||||
{t("recordSharing.lookingAfter.description")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(entry.accountId)}
|
||||
aria-label={t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
})}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
{accounts.map((entry) => {
|
||||
const presentation = resolveRecordPresentation(entry);
|
||||
const level = levelLabel(entry, presentation, t);
|
||||
const kind = recordKindLabel(entry, t);
|
||||
const scope = scopeLabel(entry, t);
|
||||
|
||||
return (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
entry.accountId,
|
||||
sharedRecordLandingHref(entry.sections),
|
||||
)
|
||||
}
|
||||
aria-label={[
|
||||
t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
}),
|
||||
level,
|
||||
kind,
|
||||
scope,
|
||||
].join("; ")}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{level}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{kind}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scope}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{recordKindLabel(entry, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scopeLabel(entry, t)}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -132,12 +156,13 @@ export function LookingAfterCard() {
|
||||
*/
|
||||
function levelLabel(
|
||||
entry: AccountAccessEntry,
|
||||
presentation: RecordPresentation,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entry.level === "manage") {
|
||||
return t("recordSharing.row.canManage");
|
||||
}
|
||||
return entry.canWrite
|
||||
return presentation.access === "view-and-add"
|
||||
? t("recordSharing.lookingAfter.levelWrite")
|
||||
: t("recordSharing.lookingAfter.levelRead");
|
||||
}
|
||||
|
||||
@@ -118,7 +118,20 @@ describe("<DocumentCard>", () => {
|
||||
expect(html).toContain("befund.docx");
|
||||
});
|
||||
|
||||
it("renders the preview thumbnail image when hasThumbnail is set", () => {
|
||||
it("never emits a browser-issued /api subresource for the thumbnail", () => {
|
||||
// v1.37.0 — this used to assert `src="/api/documents/inbound/…/thumbnail"`
|
||||
// in the markup, and that contract is deliberately gone. A `<img src>` is
|
||||
// issued by the BROWSER, so it cannot carry the record-session assertion,
|
||||
// and the fence refuses an unasserted request on any session that has been
|
||||
// inside a shared record — a thumbnail that silently stops rendering for
|
||||
// good. The bytes now come through the app transport as an object URL
|
||||
// (`useFencedObjectUrl`), which means nothing paints on a server render.
|
||||
//
|
||||
// The assertion is inverted rather than deleted: the markup must contain no
|
||||
// `/api/` reference at all. `src/__tests__/record-fence-headerless-transport-guard.test.ts`
|
||||
// holds the same property across the whole client tree, and
|
||||
// `src/hooks/__tests__/use-fenced-object-url.test.ts` proves the request
|
||||
// really does carry the headers.
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ hasThumbnail: true })}
|
||||
@@ -128,9 +141,11 @@ describe("<DocumentCard>", () => {
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-thumbnail"');
|
||||
expect(html).toContain('src="/api/documents/inbound/doc-1/thumbnail"');
|
||||
expect(html).toContain('loading="lazy"');
|
||||
expect(html).not.toContain("/api/documents");
|
||||
// Until the blob lands the card shows its kind icon, exactly as a card
|
||||
// with no thumbnail does — never a broken image.
|
||||
expect(html).not.toContain('data-slot="document-thumbnail"');
|
||||
expect(html).toContain("lucide-scan-line");
|
||||
});
|
||||
|
||||
it("shows the kind icon (no thumbnail image) when hasThumbnail is false", () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The detail sheet fetches the original once, and only when it will show it.
|
||||
*
|
||||
* Two regressions this pins, both introduced by the fix that moved these
|
||||
* controls off a bare `/api/…` href and neither visible in any rendered
|
||||
* assertion:
|
||||
*
|
||||
* * **Two eager fetches per open.** `InlinePreview` loaded the original and
|
||||
* the sheet-level download href loaded it AGAIN, so opening a document
|
||||
* decrypted and held two copies of it — including for a non-inline
|
||||
* document, where the old href cost nothing until somebody clicked. The
|
||||
* vault meets large scans.
|
||||
* * **A download control that could not be pressed and would not say so.**
|
||||
* `href="#"` plus `aria-disabled` announces "unavailable" and still
|
||||
* activates, and it ignored the failure flag entirely, so a permanently
|
||||
* unfetchable original left a control that silently did nothing.
|
||||
*
|
||||
* Asserted on source shape rather than by rendering: the project's component
|
||||
* convention is SSR-only (no DOM, no testing-library), and both properties are
|
||||
* about which code paths EXIST — how many loaders there are, and whether the
|
||||
* disabled state is real. A render could not see either.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SHEET = readFileSync(
|
||||
join(process.cwd(), "src/components/documents/document-detail-sheet.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("the detail sheet's original-document transport", () => {
|
||||
it("has exactly one eager loader for the original", () => {
|
||||
const eager = [...SHEET.matchAll(/useFencedObjectUrl\(/g)];
|
||||
// Non-zero proof first: a sheet that fetched nothing would satisfy a bare
|
||||
// "not more than one" check.
|
||||
expect(eager.length).toBe(1);
|
||||
});
|
||||
|
||||
it("only fetches eagerly for a document it is going to render inline", () => {
|
||||
// The whole point of the single loader: a non-inline document — a large
|
||||
// scan, a download-only format — fetches and decrypts nothing on open.
|
||||
expect(SHEET).toMatch(
|
||||
/useFencedObjectUrl\(\s*\n?\s*doc && doc\.servingClass === "inline"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the blob down rather than letting the preview fetch its own", () => {
|
||||
expect(SHEET).toMatch(/<InlinePreview\s+src=\{inlineSrc\}/);
|
||||
// And the preview declares it takes bytes, not an id to fetch from.
|
||||
expect(SHEET).toMatch(/function InlinePreview\(\{\s*\n\s*src,/);
|
||||
expect(SHEET).not.toMatch(
|
||||
/function InlinePreview\([\s\S]{0,400}documentId/,
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads at click time when the bytes are not already in hand", () => {
|
||||
expect(SHEET).toContain("createFencedBlobLoader()");
|
||||
expect(SHEET).toMatch(
|
||||
/const onClick = async \(\) => \{[\s\S]{0,200}readyUrl/,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives the download a real disabled state that reflects failure", () => {
|
||||
// `aria-disabled` on an anchor is an announcement, not a behaviour.
|
||||
expect(SHEET).not.toMatch(/aria-disabled=/);
|
||||
expect(SHEET).not.toMatch(/href=\{originalHref/);
|
||||
expect(SHEET).toMatch(/disabled=\{blocked \|\| pending\}/);
|
||||
expect(SHEET).toMatch(/const blocked = unavailable \|\| failed;/);
|
||||
// And it says why, with the existing unavailable messaging.
|
||||
expect(SHEET).toMatch(
|
||||
/blocked\s*\n?\s*\?\s*t\("documents\.detail\.loadError"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("revokes what it fetched", () => {
|
||||
// A decrypted health document left in an object URL lives as long as the
|
||||
// tab does, and nothing in the UI ever shows it.
|
||||
expect(SHEET).toMatch(
|
||||
/useEffect\(\(\) => \(\) => loaderRef\.current\?\.dispose\(\), \[\]\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
@@ -75,6 +76,16 @@ export function DocumentCard({
|
||||
// A preview thumbnail that fails to load (still rendering, decrypt error,
|
||||
// 404) falls back to the kind icon — never a broken image.
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
// v1.37.0 — fetched through the app transport rather than issued by the
|
||||
// browser as a subresource, so it carries the record-session assertion. The
|
||||
// null path means "this card has no thumbnail, or one already failed", which
|
||||
// is what keeps the timeline from firing a request per card for documents
|
||||
// that have nothing to show.
|
||||
const { url: thumbnailUrl, failed: thumbnailFailed } = useFencedObjectUrl(
|
||||
document.hasThumbnail && !thumbFailed
|
||||
? `/api/documents/inbound/${document.id}/thumbnail`
|
||||
: null,
|
||||
);
|
||||
|
||||
// Touch long-press selects instead of opening; the click that the
|
||||
// browser fires after the release is swallowed once.
|
||||
@@ -128,24 +139,26 @@ export function DocumentCard({
|
||||
>
|
||||
<CardContent className="flex h-full flex-col gap-2 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
{document.hasThumbnail && !thumbFailed ? (
|
||||
// Leading-edge preview tile. `loading="lazy"` + the timeline's
|
||||
// virtualization means only cards near the viewport fetch their
|
||||
// thumbnail. Decorative (alt="") — the title beside it names the
|
||||
// document; an authed same-origin subresource, never cached
|
||||
// cross-user. onError falls back to the kind icon.
|
||||
{thumbnailUrl && !thumbnailFailed ? (
|
||||
// Leading-edge preview tile. Decorative (alt="") — the title
|
||||
// beside it names the document. The laziness that `loading="lazy"`
|
||||
// used to provide now lives in the hook's null path: a card with no
|
||||
// thumbnail, or one whose thumbnail already failed, issues no
|
||||
// request at all. onError falls back to the kind icon.
|
||||
<span
|
||||
data-slot="document-thumbnail"
|
||||
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
|
||||
>
|
||||
{/* Authed, private (no-store) same-origin subresource — next/image
|
||||
would try to proxy/optimize it, which is wrong for a PHI blob
|
||||
we deliberately never cache. */}
|
||||
{/* An object URL over bytes the app transport already fetched,
|
||||
not a `/api/…` subresource: the browser issues a subresource
|
||||
itself and so cannot carry the record-session assertion, which
|
||||
the fence refuses on any session that has been inside a shared
|
||||
record. `next/image` is wrong here for the older reason too —
|
||||
it would proxy a PHI blob we deliberately never cache. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/documents/inbound/${document.id}/thumbnail`}
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setThumbFailed(true)}
|
||||
className="size-full object-cover"
|
||||
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
createFencedBlobLoader,
|
||||
useFencedObjectUrl,
|
||||
} from "@/hooks/use-fenced-object-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
@@ -94,18 +98,119 @@ type PatchInput = {
|
||||
episodeIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Download the stored original.
|
||||
*
|
||||
* v1.37.0 — a real button, not an anchor wearing `aria-disabled`.
|
||||
*
|
||||
* Two things were wrong with the anchor. `aria-disabled` tells a screen reader
|
||||
* the control is unavailable and stops nothing: `href="#"` is still activated
|
||||
* by a click and by Enter, so a person got a jump to the top of the page
|
||||
* instead of a file. And it ignored the failure flag entirely, so a document
|
||||
* whose original could not be fetched left a control that looked fine and
|
||||
* silently did nothing, for good.
|
||||
*
|
||||
* It also fetches at CLICK time unless the bytes are already in hand. The
|
||||
* sheet loads the original eagerly only for documents it is going to render
|
||||
* inline; for everything else — a large scan, a download-only format — nothing
|
||||
* is fetched or decrypted until somebody asks for it, which is what the plain
|
||||
* `href` used to cost and what the first fix accidentally gave up.
|
||||
*/
|
||||
function DocumentDownloadButton({
|
||||
documentId,
|
||||
filename,
|
||||
readyUrl,
|
||||
unavailable,
|
||||
slot,
|
||||
...buttonProps
|
||||
}: {
|
||||
documentId: string;
|
||||
filename: string | null;
|
||||
/** Already-fetched bytes to reuse (the inline preview's), or null. */
|
||||
readyUrl: string | null;
|
||||
/** A prior fetch of these bytes failed for good. */
|
||||
unavailable: boolean;
|
||||
slot: string;
|
||||
} & React.ComponentProps<typeof Button>) {
|
||||
const { t } = useTranslations();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const loaderRef = useRef<ReturnType<typeof createFencedBlobLoader> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => () => loaderRef.current?.dispose(), []);
|
||||
|
||||
const save = (url: string) => {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename ?? "document";
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
if (readyUrl) {
|
||||
save(readyUrl);
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setFailed(false);
|
||||
// One loader for the lifetime of the button: loading again revokes the
|
||||
// previous object URL, and unmounting revokes the live one. Without that a
|
||||
// decrypted health document stays in the tab's memory until it closes.
|
||||
loaderRef.current ??= createFencedBlobLoader();
|
||||
const result = await loaderRef.current.load(
|
||||
`/api/documents/inbound/${documentId}/original`,
|
||||
);
|
||||
setPending(false);
|
||||
if (result.kind === "loaded") save(result.url);
|
||||
else if (result.kind === "failed") setFailed(true);
|
||||
};
|
||||
|
||||
const blocked = unavailable || failed;
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
data-slot={slot}
|
||||
disabled={blocked || pending}
|
||||
aria-label={t("documents.detail.download")}
|
||||
title={blocked ? t("documents.detail.loadError") : undefined}
|
||||
onClick={() => void onClick()}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{blocked
|
||||
? t("documents.detail.loadError")
|
||||
: t("documents.detail.download")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline preview for Class A documents; skeleton until the blob paints. */
|
||||
function InlinePreview({
|
||||
documentId,
|
||||
src,
|
||||
failed,
|
||||
mimeType,
|
||||
title,
|
||||
}: {
|
||||
documentId: string;
|
||||
/**
|
||||
* v1.37.0 — the object URL, fetched ONCE by the sheet and handed down.
|
||||
*
|
||||
* The bytes come through the app transport because it carries the
|
||||
* record-session assertion and a browser-issued `src="/api/…"` cannot. What
|
||||
* changed since: this component used to fetch them itself while the sheet
|
||||
* fetched the SAME original again for the download link, so opening a
|
||||
* document decrypted and held two copies of it — and a large scan is a large
|
||||
* copy. One loader now, lifted to the sheet.
|
||||
*/
|
||||
src: string | null;
|
||||
failed: boolean;
|
||||
mimeType: string;
|
||||
title: string;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const src = `/api/documents/inbound/${documentId}/original`;
|
||||
const isPdf = mimeType === "application/pdf";
|
||||
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
|
||||
// frame's `load` event — stranding keyboard events inside the embedded
|
||||
@@ -144,12 +249,23 @@ function InlinePreview({
|
||||
|
||||
return (
|
||||
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
|
||||
{!loaded ? (
|
||||
{failed ? (
|
||||
// The preview could not be fetched — refused, offline, or still
|
||||
// rendering server-side. A permanent skeleton would read as "loading
|
||||
// forever"; the sheet's own metadata and the download control below
|
||||
// still work.
|
||||
<p
|
||||
data-slot="document-preview-unavailable"
|
||||
className="text-muted-foreground py-8 text-center text-sm"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
) : !loaded || src === null ? (
|
||||
<Skeleton
|
||||
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
|
||||
/>
|
||||
) : null}
|
||||
{isPdf ? (
|
||||
{src === null || failed ? null : isPdf ? (
|
||||
// No `sandbox` attribute by design: Chromium force-downloads PDFs
|
||||
// in sandboxed frames instead of rendering them. The serve
|
||||
// response itself carries `default-src 'none'` +
|
||||
@@ -487,7 +603,20 @@ export function DocumentDetailSheet({
|
||||
|
||||
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
|
||||
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
|
||||
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
|
||||
// v1.37.0 — the ONE eager fetch of the original, and only for a document
|
||||
// that is going to render it.
|
||||
//
|
||||
// A `<iframe src="/api/…">` carries no record-session assertion, so the
|
||||
// fence refuses it once the session has been inside a shared record; the
|
||||
// bytes therefore come through the app transport as an object URL. The
|
||||
// narrow path is the point: a non-inline document (a large scan, a
|
||||
// download-only format) fetched nothing until somebody clicked, and it must
|
||||
// stay that way — this used to fetch for every document on open, twice.
|
||||
const { url: inlineSrc, failed: inlineFailed } = useFencedObjectUrl(
|
||||
doc && doc.servingClass === "inline"
|
||||
? `/api/documents/inbound/${doc.id}/original`
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -584,19 +713,14 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
<DocumentDownloadButton
|
||||
documentId={doc.id}
|
||||
filename={doc.filename}
|
||||
readyUrl={inlineSrc}
|
||||
unavailable={inlineFailed}
|
||||
variant="outline"
|
||||
asChild
|
||||
data-slot="document-download"
|
||||
aria-label={t("documents.detail.download")}
|
||||
>
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.download")}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
slot="document-download"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -620,7 +744,8 @@ export function DocumentDetailSheet({
|
||||
<div className="space-y-6">
|
||||
{doc.servingClass === "inline" ? (
|
||||
<InlinePreview
|
||||
documentId={doc.id}
|
||||
src={inlineSrc}
|
||||
failed={inlineFailed}
|
||||
mimeType={doc.mimeType}
|
||||
title={title}
|
||||
/>
|
||||
@@ -636,12 +761,15 @@ export function DocumentDetailSheet({
|
||||
{t("documents.detail.previewUnavailable")} ·{" "}
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<Button asChild size="sm" className="mt-1">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
</Button>
|
||||
<DocumentDownloadButton
|
||||
documentId={doc.id}
|
||||
filename={doc.filename}
|
||||
readyUrl={null}
|
||||
unavailable={false}
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
slot="document-download-inline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -184,14 +184,15 @@ export function DocumentsView() {
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
// the vault reads and nothing more: no upload path, no selection, no bulk
|
||||
// bar, no corpus backfill.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManage, inSharedRecord, sections } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const coachLaunch = useCoachLaunch();
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
const moduleEnabled =
|
||||
inSharedRecord || user?.modules?.inboundDocuments === true;
|
||||
|
||||
// UX redirect only — the API routes enforce the gate server-side.
|
||||
useEffect(() => {
|
||||
@@ -377,7 +378,10 @@ export function DocumentsView() {
|
||||
);
|
||||
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
const episodes = useIllnessEpisodes(
|
||||
true,
|
||||
!inSharedRecord || sections === null || sections.includes("illness"),
|
||||
);
|
||||
|
||||
// Content-search coverage: fire the corpus backfill when indexing is
|
||||
// available and some documents are not yet searchable. The list search
|
||||
|
||||
@@ -25,9 +25,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
/** Newest-first episode list. */
|
||||
export function useIllnessEpisodes(includeResolved = true) {
|
||||
export function useIllnessEpisodes(includeResolved = true, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.illnessEpisodes(includeResolved),
|
||||
enabled,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=${includeResolved}`,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldProbeOcrCapability } from "../use-ocr-extract";
|
||||
|
||||
describe("shouldProbeOcrCapability", () => {
|
||||
it("runs the owner-only capability probe only when the scan control can render", () => {
|
||||
const ownerControl = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
labsEnabled: true,
|
||||
mounted: true,
|
||||
canManage: true,
|
||||
};
|
||||
|
||||
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, canManage: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }),
|
||||
).toBe(false);
|
||||
expect(shouldProbeOcrCapability({ ...ownerControl, mounted: false })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,23 @@ export interface OcrCommitResult extends OcrCommitResponseDto {
|
||||
inserted: LabResultDto[];
|
||||
}
|
||||
|
||||
/** The capability route resolves the caller's own provider configuration. */
|
||||
export function shouldProbeOcrCapability({
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
labsEnabled,
|
||||
mounted,
|
||||
canManage,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
labsEnabled: boolean;
|
||||
mounted: boolean;
|
||||
canManage: boolean;
|
||||
}): boolean {
|
||||
return isAuthenticated && !isLoading && labsEnabled && mounted && canManage;
|
||||
}
|
||||
|
||||
/** Capability probe — refetched when the scan dialog opens. */
|
||||
export function useOcrCapability(enabled: boolean) {
|
||||
return useQuery<OcrCapabilityDto>({
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
* "removed".
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -51,6 +53,13 @@ import type {
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
const mockAuthLoadingRef = { value: false };
|
||||
const mockAccountAccessStatusRef = {
|
||||
value: "valid" as "absent" | "valid" | "invalid",
|
||||
};
|
||||
const mockRecordSessionPhaseRef = {
|
||||
value: "ready" as "ready" | "blocking" | "resolving",
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -62,15 +71,24 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
accountAccessStatus: mockAccountAccessStatusRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isAuthUnknown: false,
|
||||
isLoading: false,
|
||||
isLoading: mockAuthLoadingRef.value,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-session-transition", () => ({
|
||||
useRecordSessionTransition: () => ({
|
||||
id: "cross-tab-switch",
|
||||
phase: mockRecordSessionPhaseRef.value,
|
||||
expectedScope: "record-a",
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPathRef = { value: "/" };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -137,9 +155,14 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
function render(access: AccountAccess, pathname = "/"): string {
|
||||
function render(
|
||||
access: AccountAccess,
|
||||
pathname = "/",
|
||||
accountAccessStatus: "absent" | "valid" | "invalid" = "valid",
|
||||
): string {
|
||||
mockAccessRef.value = access;
|
||||
mockPathRef.value = pathname;
|
||||
mockAccountAccessStatusRef.value = accountAccessStatus;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -169,6 +192,46 @@ const DOORS = [
|
||||
] as const;
|
||||
|
||||
describe("<AuthShell> — the owner-only doors", () => {
|
||||
it("holds protected children until record capabilities resolve", () => {
|
||||
mockAuthLoadingRef.value = true;
|
||||
try {
|
||||
const html = render(OWN_RECORD, "/medications");
|
||||
|
||||
expect(html).toContain('data-slot="record-scope-hydration-gate"');
|
||||
expect(html).not.toContain("sentinel-page");
|
||||
expect(html).not.toContain("sentinel-coach-fab");
|
||||
} finally {
|
||||
mockAuthLoadingRef.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("holds protected children and owner-only doors while a peer tab changes the session", () => {
|
||||
mockRecordSessionPhaseRef.value = "blocking";
|
||||
try {
|
||||
const html = render(OWN_RECORD, "/labs");
|
||||
|
||||
expect(html).toContain('data-slot="record-scope-hydration-gate"');
|
||||
expect(html).not.toContain("sentinel-page");
|
||||
for (const slot of DOORS) {
|
||||
expect(html).not.toContain(slot);
|
||||
}
|
||||
} finally {
|
||||
mockRecordSessionPhaseRef.value = "ready";
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delay public routes for record capability resolution", () => {
|
||||
mockAuthLoadingRef.value = true;
|
||||
try {
|
||||
const html = render(OWN_RECORD, "/auth/login");
|
||||
|
||||
expect(html).toContain("sentinel-page");
|
||||
expect(html).not.toContain('data-slot="record-scope-hydration-gate"');
|
||||
} finally {
|
||||
mockAuthLoadingRef.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("mounts all three in the caller's own record", () => {
|
||||
// The half that makes the rest mean something. Without it, a shell that
|
||||
// had simply dropped the Coach and the tour for everybody would satisfy
|
||||
@@ -179,6 +242,18 @@ describe("<AuthShell> — the owner-only doors", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an invalid record block without mounting owner-only doors or children", () => {
|
||||
const html = render(OWN_RECORD, "/", "invalid");
|
||||
|
||||
expect(html).toContain("sentinel-unavailable");
|
||||
expect(html).not.toContain("sentinel-page");
|
||||
for (const slot of DOORS) {
|
||||
expect(html, `${slot} must not paint for a refused record`).not.toContain(
|
||||
slot,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["read", OWNER_READ],
|
||||
["write", OWNER_WRITE],
|
||||
@@ -217,3 +292,48 @@ describe("<AuthShell> — the owner-only doors", () => {
|
||||
expect(html).not.toContain("sentinel-unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — a refusal must never render behind the hydration gate.
|
||||
*
|
||||
* The two branches look interchangeable and are not. `RecordScopeHydrationGate`
|
||||
* is a bare spinner with no controls, which is correct for a transition that
|
||||
* ends on its own. A refusal does not end on its own — `/api/auth/me` reports
|
||||
* the same disagreement on every boot — so behind the gate it renders as a
|
||||
* permanent "Loading…" with no way out, which is what an audit found on the
|
||||
* grant-expiry path.
|
||||
*
|
||||
* Asserted on source order rather than by rendering, because the shell's
|
||||
* branches are early returns: whichever `if` comes first wins, and that is
|
||||
* exactly the fact worth freezing. Break it by moving the `accessRefused`
|
||||
* block back below the gate.
|
||||
*/
|
||||
describe("the refusal door is reachable", () => {
|
||||
const SHELL = readFileSync(
|
||||
join(process.cwd(), "src/components/layout/auth-shell.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("checks accessRefused before the hydration gate", () => {
|
||||
const refusal = SHELL.indexOf("if (accessRefused) {");
|
||||
const gate = SHELL.indexOf("<RecordScopeHydrationGate");
|
||||
// Non-zero proof: a renamed branch must fail here rather than agree with
|
||||
// two -1s.
|
||||
expect(refusal).toBeGreaterThan(-1);
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(refusal).toBeLessThan(gate);
|
||||
});
|
||||
|
||||
it("gives the refusal door an actual way out", () => {
|
||||
// A door with no handle is the same wedge wearing different paint.
|
||||
const door = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/layout/shared-record-unavailable.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(door).toContain("shared-record-unavailable-leave");
|
||||
expect(door).toMatch(/switchAccount\.mutate\(null\)/);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,14 @@ describe("the destination list under a switch", () => {
|
||||
|
||||
it("changes nothing for a session in its own record", () => {
|
||||
const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href);
|
||||
expect(own).toEqual(NAV_DESTINATIONS.map((d) => d.href));
|
||||
expect(own).toEqual(
|
||||
NAV_DESTINATIONS.filter((d) => !d.sharedRecordOnly).map((d) => d.href),
|
||||
);
|
||||
expect(own).not.toContain("/profile");
|
||||
});
|
||||
|
||||
it("offers the read-only profile summary only inside a shared record", () => {
|
||||
expect(hrefsInSharedRecord()).toContain("/profile");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Reconciling a moved record context without firing the reads it is holding.
|
||||
*
|
||||
* Both defects this pins were found in a real browser rather than reasoned
|
||||
* about, and both look harmless in the source:
|
||||
*
|
||||
* * `queryClient.clear()` while the shell is still mounted drops every entry,
|
||||
* and a mounted observer whose entry disappears refetches at once. The tab
|
||||
* that had just been told to hold issued `/api/dashboard/snapshot` and
|
||||
* `/api/medications` under the very context it could not prove;
|
||||
* * an unfiltered `cancelQueries()` cancels the `/api/auth/me` a previous
|
||||
* reconcile started. A stale tab produces one 409 per errored query, so
|
||||
* each new trigger killed the request that would have ended the hold and
|
||||
* the tab never recovered.
|
||||
*
|
||||
* Run against a real `QueryClient`, for the reason `grant-loss.test.ts` gives:
|
||||
* "the effect calls subscribe" is a claim about source, and this is a claim
|
||||
* about what a cache full of live observers actually does.
|
||||
*
|
||||
* Mutation checks, run: put the `removeQueries` back before the `/me` await →
|
||||
* "does not refetch a record query it is holding" goes red. Drop the in-flight
|
||||
* guard → "does not cancel the /me a previous reconcile started" goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
|
||||
import { createRecordFenceReconciler } from "@/components/layout/record-session-fence-bridge";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { __resetRecordSessionTransitionForTests } from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
function client(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity, staleTime: Infinity },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A record cell with a live observer, exactly as a mounted page holds one. */
|
||||
async function mountedRecordQuery(
|
||||
qc: QueryClient,
|
||||
fetcher: () => Promise<unknown>,
|
||||
) {
|
||||
const queryKey = ["dashboard", "snapshot"];
|
||||
// A real `QueryObserver` with a live subscriber, which is what a mounted page
|
||||
// holds. Without the subscription the query is inactive, removal can never
|
||||
// trigger a refetch, and this whole file would pass vacuously.
|
||||
const observer = new QueryObserver(qc, { queryKey, queryFn: fetcher });
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
await vi.waitFor(() =>
|
||||
expect(observer.getCurrentResult().data).toBeDefined(),
|
||||
);
|
||||
return { queryKey, unsubscribe };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRecordSessionTransitionForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetRecordSessionTransitionForTests();
|
||||
});
|
||||
|
||||
describe("createRecordFenceReconciler", () => {
|
||||
it("keeps the record entries alive until the account payload has answered", async () => {
|
||||
// The sequencing IS the fix, and it is what this asserts directly.
|
||||
//
|
||||
// The defect was `queryClient.clear()` before the `/me` await: a mounted
|
||||
// observer whose entry disappears refetches, so the tab that had just been
|
||||
// told to hold fired its record reads under the context it could not
|
||||
// prove. A node test has no React render loop, so it cannot observe that
|
||||
// refetch — asserting "the fetcher was not called again" here would be a
|
||||
// check that cannot fail. What it CAN observe, deterministically, is the
|
||||
// ordering the refetch depends on: the entry must still exist while `/me`
|
||||
// is in flight, and be gone once it has answered.
|
||||
const qc = client();
|
||||
const { queryKey, unsubscribe } = await mountedRecordQuery(
|
||||
qc,
|
||||
async () => ({
|
||||
value: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
let releaseMe!: () => void;
|
||||
const meGate = new Promise<void>((resolve) => {
|
||||
releaseMe = resolve;
|
||||
});
|
||||
let presentWhileInFlight: boolean | null = null;
|
||||
const refetchAccount = vi.fn(async () => {
|
||||
// Sampled from INSIDE the request, which is the only moment that matters:
|
||||
// this is precisely when a mounted observer would react to a removal.
|
||||
presentWhileInFlight =
|
||||
qc.getQueryCache().find({ queryKey }) !== undefined;
|
||||
await meGate;
|
||||
return { id: "u-1" };
|
||||
});
|
||||
|
||||
createRecordFenceReconciler(qc, refetchAccount)();
|
||||
await vi.waitFor(() => expect(refetchAccount).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(presentWhileInFlight).toBe(true);
|
||||
|
||||
releaseMe();
|
||||
await vi.waitFor(() =>
|
||||
expect(qc.getQueryCache().find({ queryKey })).toBeUndefined(),
|
||||
);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("removes the stale record entries once the account payload lands", async () => {
|
||||
// The positive control for the assertion above: a reconciler that simply
|
||||
// never removed anything would pass it.
|
||||
const qc = client();
|
||||
const { queryKey, unsubscribe } = await mountedRecordQuery(
|
||||
qc,
|
||||
async () => ({
|
||||
value: 1,
|
||||
}),
|
||||
);
|
||||
unsubscribe();
|
||||
|
||||
const refetchAccount = vi.fn(async () => ({ id: "u-1" }));
|
||||
createRecordFenceReconciler(qc, refetchAccount)();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(qc.getQueryCache().find({ queryKey })).toBeUndefined(),
|
||||
);
|
||||
// The account cell itself survives — it is the one thing that releases the
|
||||
// hold, and removing it would be removing the answer.
|
||||
expect(
|
||||
qc.getQueryCache().find({ queryKey: queryKeys.authMe() }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not cancel the /me a previous reconcile started", async () => {
|
||||
const qc = client();
|
||||
let releaseMe!: () => void;
|
||||
const meGate = new Promise<void>((resolve) => {
|
||||
releaseMe = resolve;
|
||||
});
|
||||
const refetchAccount = vi.fn(async () => {
|
||||
await meGate;
|
||||
return { id: "u-1" };
|
||||
});
|
||||
|
||||
const reconcile = createRecordFenceReconciler(qc, refetchAccount);
|
||||
// A burst, which is what a stale tab produces: one 409 per errored query.
|
||||
reconcile();
|
||||
reconcile();
|
||||
reconcile();
|
||||
reconcile();
|
||||
|
||||
// One request, not four, and not zero — a reconciler that restarted on
|
||||
// every trigger would cancel the request that ends the hold.
|
||||
expect(refetchAccount).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseMe();
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
qc.getQueryCache().find({ queryKey: queryKeys.authMe() })?.state.data,
|
||||
).toEqual({ id: "u-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a fresh trigger once the previous reconcile settled", async () => {
|
||||
// The in-flight guard must be a latch, not a one-shot: a later, genuine
|
||||
// disagreement has to be reconciled too.
|
||||
const qc = client();
|
||||
const refetchAccount = vi.fn(async () => ({ id: "u-1" }));
|
||||
const reconcile = createRecordFenceReconciler(qc, refetchAccount);
|
||||
|
||||
reconcile();
|
||||
await vi.waitFor(() => expect(refetchAccount).toHaveBeenCalledTimes(1));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
reconcile();
|
||||
await vi.waitFor(() => expect(refetchAccount).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("stays held when the account payload cannot be fetched", async () => {
|
||||
const qc = client();
|
||||
const refetchAccount = vi.fn(async () => {
|
||||
throw new TypeError("offline");
|
||||
});
|
||||
const reconcile = createRecordFenceReconciler(qc, refetchAccount);
|
||||
|
||||
reconcile();
|
||||
await vi.waitFor(() => expect(refetchAccount).toHaveBeenCalledTimes(1));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// The latch released, so a retry is possible — being stuck offline must not
|
||||
// also mean being stuck unable to try again.
|
||||
reconcile();
|
||||
await vi.waitFor(() => expect(refetchAccount).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,31 @@ describe("<SharedRecordBanner>", () => {
|
||||
expect(html).not.toContain("read it, not change it");
|
||||
});
|
||||
|
||||
it("names MANAGE and a managed profile without reducing either to add access", () => {
|
||||
const managedProfile = {
|
||||
...OWNER,
|
||||
accountId: "managed-profile",
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
recordKind: "managed" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
const html = render(
|
||||
{
|
||||
accounts: [managedProfile],
|
||||
active: managedProfile,
|
||||
canSwitch: true,
|
||||
},
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-access-level="manage"');
|
||||
expect(html).toContain('data-record-kind="managed"');
|
||||
expect(html).toContain("Managed profile");
|
||||
expect(html).toContain("change or remove what is in it");
|
||||
expect(html).not.toContain("add to it, but not change");
|
||||
});
|
||||
|
||||
it("uses the warning register, not a subtle tint", () => {
|
||||
// Somebody who stops noticing the banner logs their own reading into
|
||||
// another person's record. The visual register is part of the contract.
|
||||
@@ -144,6 +169,19 @@ describe("<SharedRecordBanner>", () => {
|
||||
expect(html).toContain("bg-warning/15");
|
||||
expect(html).toContain("border-warning/40");
|
||||
});
|
||||
|
||||
it("keeps the warning-banner context copy at the foreground contrast floor", () => {
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: OWNER, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
const context = html.match(
|
||||
/<span[^>]*data-slot="shared-record-banner-context"[^>]*>/,
|
||||
)?.[0];
|
||||
|
||||
expect(context).toContain("text-foreground");
|
||||
expect(context).not.toContain("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AccountSwitcherMenuItems>", () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
@@ -64,7 +66,7 @@ export function AccountSwitcherMenuItems() {
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="account-switcher-menu"
|
||||
className="w-60"
|
||||
className="max-h-(--radix-dropdown-menu-content-available-height) w-60 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
data-slot="account-switcher-own"
|
||||
@@ -79,32 +81,62 @@ export function AccountSwitcherMenuItems() {
|
||||
<span className="text-primary ml-auto text-xs">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{access.accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(account.accountId)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
{/* Meta, so muted: what the grant lets this person do, in the
|
||||
words the server resolved rather than a level code. */}
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{account.canWrite
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly")}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{access.accounts.map((account) => {
|
||||
const presentation = resolveRecordPresentation(account);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
data-account-username={account.username}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
account.accountId,
|
||||
sharedRecordLandingHref(account.sections),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{accessLabel(presentation.access, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{recordKindLabel(presentation.recordKind, t)}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(
|
||||
access: ReturnType<typeof resolveRecordPresentation>["access"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (access === "manage") return t("recordSharing.row.canManage");
|
||||
return access === "view-and-add"
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
return recordKind === "managed"
|
||||
? t("recordSharing.lookingAfter.kindManaged")
|
||||
: t("recordSharing.lookingAfter.kindShared");
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
@@ -19,6 +20,7 @@ import { DemoBanner } from "./demo-banner";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { SharedRecordBanner } from "./shared-record-banner";
|
||||
import { SharedRecordUnavailable } from "./shared-record-unavailable";
|
||||
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
import { TopBar } from "./top-bar";
|
||||
|
||||
@@ -57,6 +59,7 @@ export function AuthShell({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const recordSessionTransition = useRecordSessionTransition();
|
||||
|
||||
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
|
||||
@@ -78,7 +81,13 @@ export function AuthShell({
|
||||
// decides what to mount and the surfaces beneath it decide what to offer,
|
||||
// and the two disagreeing is the whole class of defect this hook exists to
|
||||
// close.
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
const {
|
||||
inSharedRecord,
|
||||
recordKind,
|
||||
sections,
|
||||
accessRefused,
|
||||
recordSessionPending,
|
||||
} = useRecordCapabilities();
|
||||
// A managed profile has a deliberately small Settings surface of its own.
|
||||
// Let that route family reach its record-aware gate; adult shared records
|
||||
// stay in the generic refusal branch even at MANAGE.
|
||||
@@ -91,7 +100,7 @@ export function AuthShell({
|
||||
// — every route behind these pages refuses on its own.
|
||||
const outsideSharedRecord =
|
||||
inSharedRecord &&
|
||||
!isDestinationInSharedRecord(pathname) &&
|
||||
!isDestinationInSharedRecord(pathname, sections) &&
|
||||
!isManagedRecordSettingsPath;
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
@@ -225,19 +234,48 @@ export function AuthShell({
|
||||
);
|
||||
}
|
||||
|
||||
// Admin pages stay behind the auth gate while `/api/auth/me` is in
|
||||
// flight: the role is unknown until the payload lands, and mounting
|
||||
// `/admin/*` children early would fire admin queries that 403 for a
|
||||
// non-admin before the redirect effect can move them away.
|
||||
if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) {
|
||||
// A refused record context comes FIRST, ahead of the hydration gate.
|
||||
//
|
||||
// Both used to be checked in the other order, and it mattered: the gate is a
|
||||
// bare spinner with no controls, which is right for a transition that ends on
|
||||
// its own and wrong for a state that does not. A refusal — a malformed access
|
||||
// block, or a selector pointed at a record whose grant has lapsed — reports
|
||||
// the same thing on every `/api/auth/me`, so behind the gate it renders as a
|
||||
// permanent "Loading…" with no way out. Ahead of it, the same state renders
|
||||
// the refusal door, which carries the button back to one's own record.
|
||||
//
|
||||
// Safe against the loading race by construction: every input to
|
||||
// `accessRefused` comes from a resolved account payload, so it cannot be true
|
||||
// while `isLoading` is.
|
||||
// A present but malformed access block is not the older-server case. The
|
||||
// server may still be switched, so mounting children here could fetch target
|
||||
// data under owner controls. Offer only the actor-scoped way out.
|
||||
if (accessRefused) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center" role="status">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
||||
<div
|
||||
data-slot="invalid-record-access-refusal"
|
||||
className="flex min-h-dvh flex-col"
|
||||
>
|
||||
<MaintainershipBanner />
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 py-10 md:px-6">
|
||||
<SharedRecordUnavailable />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every protected route waits for `/api/auth/me`, not only Settings and
|
||||
// Admin. The payload resolves the active record as well as identity, so a
|
||||
// child mounted before it could issue an actor-scoped read before the shell
|
||||
// knows that a switched record must be refused or scoped differently.
|
||||
if (
|
||||
isLoading ||
|
||||
recordSessionTransition.phase !== "ready" ||
|
||||
recordSessionPending
|
||||
) {
|
||||
return <RecordScopeHydrationGate label={t("nav.loadingScreen")} />;
|
||||
}
|
||||
|
||||
// Auth RESOLVED as unauthenticated — hold a spinner while the
|
||||
// redirect effect above replaces the route with /auth/login.
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
@@ -249,16 +287,6 @@ export function AuthShell({
|
||||
);
|
||||
}
|
||||
|
||||
// While `/api/auth/me` is still in flight the shell renders the full
|
||||
// app chrome + children immediately (the chrome components own their
|
||||
// null-user skeletons, pages own their data skeletons). This takes the
|
||||
// auth round-trip off the first-paint critical path: page-level
|
||||
// queries fire in parallel with `/api/auth/me` instead of behind it.
|
||||
// `src/proxy.ts` has already refused cookie-less requests to
|
||||
// protected routes, so the unauthenticated-flash window is limited to
|
||||
// expired/invalid sessions — those resolve into the redirect branch
|
||||
// above as soon as the 401 lands.
|
||||
|
||||
// Onboarding page — minimal shell, no sidebar/nav
|
||||
if (isOnboardingPage) {
|
||||
return (
|
||||
|
||||
@@ -97,8 +97,12 @@ export function BottomNav() {
|
||||
// v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from
|
||||
// the same resolved payload, so the two bars cannot disagree about what a
|
||||
// delegate is offered.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const {
|
||||
inSharedRecord: sharedRecord,
|
||||
sections,
|
||||
canAdd,
|
||||
canManage,
|
||||
} = useRecordCapabilities();
|
||||
const canCapture = canAdd || canManage;
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
@@ -111,8 +115,9 @@ export function BottomNav() {
|
||||
modules: user?.modules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
}),
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
);
|
||||
|
||||
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
|
||||
@@ -127,11 +132,11 @@ export function BottomNav() {
|
||||
// v1.36.0 — the fixed slot asks the shared destination model
|
||||
// whether sharing covers it, rather than carrying its own answer:
|
||||
// Insights is an AI surface and drops out under a switch.
|
||||
(!sharedRecord || isDestinationInSharedRecord(item.href)) &&
|
||||
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
|
||||
(!item.requiresModule ||
|
||||
(mounted && user?.modules?.[item.requiresModule] !== false)),
|
||||
),
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
);
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Activity,
|
||||
Bell,
|
||||
Brain,
|
||||
ClipboardList,
|
||||
Droplets,
|
||||
FileScan,
|
||||
FlaskConical,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* v1.17.1 — the single navigation information-model.
|
||||
@@ -75,6 +78,8 @@ export interface NavDestination {
|
||||
* owner gave for their own use, so those surfaces stay owner-only in v1.
|
||||
*/
|
||||
sharedRecord?: boolean;
|
||||
/** A record-only view, intentionally absent from the actor's own nav. */
|
||||
sharedRecordOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,6 +173,14 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
tourId: "nav-labs",
|
||||
requiresModule: "labs",
|
||||
},
|
||||
{
|
||||
href: "/profile",
|
||||
sharedRecord: true,
|
||||
sharedRecordOnly: true,
|
||||
tKey: "nav.profile",
|
||||
icon: ClipboardList,
|
||||
tourId: "nav-profile",
|
||||
},
|
||||
// v1.18.1 — the illness/condition journal sits in the clinical spine
|
||||
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
|
||||
// opt-in `illness` key from the resolved module map, so the entry is
|
||||
@@ -305,8 +318,20 @@ function isNavDestinationVisible(
|
||||
modules: ModuleVisibilityMap | undefined,
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
): boolean {
|
||||
if (sharedRecord && d.sharedRecord !== true) return false;
|
||||
if (
|
||||
sharedRecord &&
|
||||
(d.sharedRecord !== true ||
|
||||
!isSharedRecordPathPresentable(d.href, sections))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// A shared record's server-resolved scope decides which health-domain doors
|
||||
// exist. The actor's module preferences describe their own dashboard and
|
||||
// must not hide a domain the target record explicitly granted.
|
||||
if (sharedRecord) return true;
|
||||
if (d.sharedRecordOnly) return false;
|
||||
if (!d.requiresModule) return true;
|
||||
if (!mounted) return false;
|
||||
return modules?.[d.requiresModule] !== false;
|
||||
@@ -325,12 +350,11 @@ function isNavDestinationVisible(
|
||||
* `false`: a surface nobody has classified is not one to open inside somebody
|
||||
* else's record.
|
||||
*/
|
||||
export function isDestinationInSharedRecord(pathname: string): boolean {
|
||||
return NAV_DESTINATIONS.some((d) => {
|
||||
if (!d.sharedRecord) return false;
|
||||
if (d.href === "/") return pathname === "/";
|
||||
return pathname === d.href || pathname.startsWith(`${d.href}/`);
|
||||
});
|
||||
export function isDestinationInSharedRecord(
|
||||
pathname: string,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
): boolean {
|
||||
return isSharedRecordPathPresentable(pathname, sections);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,9 +372,10 @@ export function visibleNavDestinations(
|
||||
modules: ModuleVisibilityMap | undefined,
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
): NavDestination[] {
|
||||
return NAV_DESTINATIONS.filter((d) =>
|
||||
isNavDestinationVisible(d, modules, mounted, sharedRecord),
|
||||
isNavDestinationVisible(d, modules, mounted, sharedRecord, sections),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,11 +414,14 @@ export function mobileMoreHubDestinations(opts: {
|
||||
mounted?: boolean;
|
||||
/** v1.36.0 — acting on somebody else's record. Defaults to own. */
|
||||
sharedRecord?: boolean;
|
||||
/** Server-resolved scope for the active shared record. */
|
||||
sections?: readonly ShareDomain[] | null;
|
||||
}): MobileMoreHubEntry[] {
|
||||
return visibleNavDestinations(
|
||||
opts.modules,
|
||||
opts.mounted ?? true,
|
||||
opts.sharedRecord ?? false,
|
||||
opts.sections ?? null,
|
||||
)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Holds protected route trees until `/api/auth/me` has resolved the active
|
||||
* record and its capabilities. Mounting a child before this point can issue a
|
||||
* request for the actor's record before the shared-record scope is known.
|
||||
*/
|
||||
export function RecordScopeHydrationGate({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="record-scope-hydration-gate"
|
||||
className="flex h-dvh items-center justify-center"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { onRecordFenceMismatch } from "@/lib/api/record-fence";
|
||||
import { fetchMe } from "@/hooks/use-auth";
|
||||
import { isRecordSessionChanged } from "@/hooks/use-account-switch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { holdForRecordSessionReconcile } from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
/**
|
||||
* v1.37.0 — hold and reconcile when the server says this browser's record
|
||||
* context has moved.
|
||||
*
|
||||
* Two signals mean the same thing and land in the same place:
|
||||
*
|
||||
* * a 409 `sharing.session.changed`, which the fence raises when the asserted
|
||||
* context does not match the session row;
|
||||
* * a 2xx whose ECHOED context contradicts the adopted one — a response that
|
||||
* was in flight across a switch, or a service-worker replay of one cached
|
||||
* before it.
|
||||
*
|
||||
* The response to both is the same and it is NOT the grant-loss response. This
|
||||
* browser does not leave the record: it holds every tab, re-reads
|
||||
* `/api/auth/me` network-only, adopts whatever context the server actually
|
||||
* holds, and releases. `sharing.session.changed` is deliberately absent from
|
||||
* the set `subscribeToGrantLoss` fires on — handing a reconcilable
|
||||
* disagreement to a bridge that hard-navigates out of the record would turn a
|
||||
* one-frame hold into an eviction.
|
||||
*
|
||||
* A sibling of `SharedRecordGrantLossBridge` rather than a branch inside it,
|
||||
* for the reason that file gives for existing at all: the two answer different
|
||||
* questions ("you have lost this record" versus "we disagree about which record
|
||||
* this is"), and a single subscriber with a mode flag is how they start
|
||||
* answering each other's.
|
||||
*/
|
||||
|
||||
/** Is this the account payload's cell? */
|
||||
function isAccountQuery(queryKey: readonly unknown[]): boolean {
|
||||
const authMe = queryKeys.authMe() as readonly unknown[];
|
||||
return (
|
||||
queryKey.length === authMe.length &&
|
||||
queryKey.every((part, i) => part === authMe[i])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconciliation itself, as a plain function over a `QueryClient`.
|
||||
*
|
||||
* Extracted from the effect body for the reason `subscribeToGrantLoss` is: a
|
||||
* test can drive a real cache through it and check what happened, which is the
|
||||
* only way to tell a reconciler that holds from one that quietly refetches the
|
||||
* record it is supposed to be holding.
|
||||
*
|
||||
* ## The two things the first version got wrong
|
||||
*
|
||||
* **It cleared the cache while the shell was still mounted.**
|
||||
* `queryClient.clear()` drops every entry, and a mounted observer whose entry
|
||||
* disappears refetches immediately — so the tab that had just been told to hold
|
||||
* fired `/api/dashboard/snapshot` and `/api/medications` under the very context
|
||||
* it could not prove. The removal therefore waits until AFTER `/api/auth/me`
|
||||
* has answered: by then the hold has unmounted the protected children, so there
|
||||
* is no observer left to trigger anything.
|
||||
*
|
||||
* **It cancelled every in-flight query, including the `/me` a previous
|
||||
* reconcile had started.** Under a burst of 409s — which is exactly what a
|
||||
* stale tab produces, one per errored query — each new reconcile cancelled the
|
||||
* request that would have ended the hold, and the tab never recovered. Two
|
||||
* fixes, both needed: the cancellation excludes the account cell, and a
|
||||
* reconcile already in flight swallows a second trigger rather than restarting.
|
||||
*/
|
||||
export function createRecordFenceReconciler(
|
||||
queryClient: QueryClient,
|
||||
refetchAccount: () => Promise<unknown> = fetchMe,
|
||||
): () => void {
|
||||
let inFlight = false;
|
||||
|
||||
return () => {
|
||||
// A burst of 409s is one signal, not many. Restarting on each would cancel
|
||||
// the request that ends the hold and livelock the tab.
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
|
||||
// Hold FIRST. This is what unmounts the protected children, and everything
|
||||
// below depends on them being gone.
|
||||
holdForRecordSessionReconcile();
|
||||
|
||||
// Cancel record traffic, never the account payload: the `/me` below is the
|
||||
// only thing that can release the hold.
|
||||
void queryClient.cancelQueries({
|
||||
predicate: (query) => !isAccountQuery(query.queryKey),
|
||||
});
|
||||
|
||||
void queryClient
|
||||
.fetchQuery({
|
||||
queryKey: queryKeys.authMe(),
|
||||
queryFn: refetchAccount,
|
||||
// NETWORK-ONLY, and this is load-bearing rather than a precaution. The
|
||||
// app's default `staleTime` is not zero, so a plain `fetchQuery` would
|
||||
// hand back the cached account payload — the one carrying the context
|
||||
// this browser has just been told is wrong — and release the hold
|
||||
// without ever asking the server. The whole reconciliation would then
|
||||
// be a no-op that looked like a success.
|
||||
staleTime: 0,
|
||||
})
|
||||
.then(() => {
|
||||
// Now, and not before: the children are unmounted, so removing their
|
||||
// entries cannot remount an observer that refetches.
|
||||
queryClient.removeQueries({
|
||||
predicate: (query) => !isAccountQuery(query.queryKey),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline, or the server is down. The hold stays, which is the correct
|
||||
// place to be stuck: nothing renders under a context this browser
|
||||
// cannot prove, and the next successful `/me` releases it.
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function RecordSessionFenceBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const reconcile = createRecordFenceReconciler(queryClient);
|
||||
|
||||
// The in-flight / cached-response arm, raised by the transport wrapper.
|
||||
const disposeMismatch = onRecordFenceMismatch(reconcile);
|
||||
|
||||
// The 409 arm. Watching the query cache rather than asking ~200 call sites
|
||||
// to handle a status code, for the reason the grant-loss bridge gives:
|
||||
// a per-call-site handler is a rule the next feature forgets.
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type !== "updated") return;
|
||||
if (event.query.state.status !== "error") return;
|
||||
if (!isRecordSessionChanged(event.query.state.error)) return;
|
||||
reconcile();
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeMismatch();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Eye, Loader2, LogOut } from "lucide-react";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
@@ -14,7 +15,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
* are switched will log their own blood pressure into somebody else's record,
|
||||
* and there is no undo for a reading that was never theirs. So the banner
|
||||
* states three things in one line — that this is not your record, whose it is,
|
||||
* and that you can only read it — and carries the way out beside them.
|
||||
* and what sharing level applies — and carries the way out beside them.
|
||||
*
|
||||
* ## Why it looks like this
|
||||
*
|
||||
@@ -28,9 +29,9 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
* somebody stops seeing after the third switch, and the failure mode here is
|
||||
* a person who has stopped noticing.
|
||||
*
|
||||
* The name is `text-foreground` and the qualifier `text-muted-foreground`, per
|
||||
* the two-tier text rule — the person's name is content, the read-only note is
|
||||
* meta.
|
||||
* The name and qualifier both use `text-foreground`: the warning wash is too
|
||||
* dark for the muted token at this text size, while the foreground token keeps
|
||||
* the context legible at the same contrast floor as the name.
|
||||
*
|
||||
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
|
||||
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
|
||||
@@ -59,12 +60,15 @@ export function SharedRecordBanner() {
|
||||
if (!active) return null;
|
||||
|
||||
const name = accountLabel(active);
|
||||
const presentation = resolveRecordPresentation(active);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="shared-record-banner"
|
||||
data-account-id={active.accountId}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
className="bg-warning/15 border-warning/40 flex items-start gap-3 border-b px-3 py-2 text-xs sm:items-center"
|
||||
>
|
||||
<Eye
|
||||
@@ -75,18 +79,12 @@ export function SharedRecordBanner() {
|
||||
<span className="text-foreground font-medium">
|
||||
{t("recordSharing.banner.viewing", { name })}
|
||||
</span>{" "}
|
||||
{/* Resolved server-side, and it says something at BOTH levels now.
|
||||
v1.36.1 is the release that made `canWrite` true, and until this
|
||||
line changed the write case rendered nothing at all: a delegate who
|
||||
could add to somebody's health record was told only whose record it
|
||||
was. That matters more than the read case, not less, and the rest of
|
||||
the product leans on it — a control the record refuses is absent
|
||||
rather than disabled, on the stated grounds that the banner names
|
||||
the mode globally. It has to actually do that. */}
|
||||
<span className="text-muted-foreground">
|
||||
{active.canWrite
|
||||
? t("recordSharing.banner.canAdd")
|
||||
: t("recordSharing.banner.readOnly")}
|
||||
<span
|
||||
data-slot="shared-record-banner-context"
|
||||
className="text-foreground"
|
||||
>
|
||||
{recordKindLabel(presentation.recordKind, t)}.{" "}
|
||||
{accessLabel(presentation.access, t)}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
@@ -109,3 +107,22 @@ export function SharedRecordBanner() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(
|
||||
access: ReturnType<typeof resolveRecordPresentation>["access"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (access === "manage") return t("recordSharing.row.canManage");
|
||||
return access === "view-and-add"
|
||||
? t("recordSharing.banner.canAdd")
|
||||
: t("recordSharing.banner.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
return recordKind === "managed"
|
||||
? t("recordSharing.lookingAfter.kindManaged")
|
||||
: t("recordSharing.lookingAfter.kindShared");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -306,10 +307,11 @@ export function SidebarNav() {
|
||||
// v1.36.0 — while this browser is acting on somebody else's record, the nav
|
||||
// shows only what sharing covers. The server refuses the rest regardless;
|
||||
// dropping the entries spares a delegate a click that ends in a 403.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
const { inSharedRecord: sharedRecord, sections } = useRecordCapabilities();
|
||||
const visibleNavItems = useMemo(
|
||||
() => visibleNavDestinations(user?.modules, mounted, sharedRecord),
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
() =>
|
||||
visibleNavDestinations(user?.modules, mounted, sharedRecord, sections),
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
);
|
||||
// Collapsed = the user's stored choice, or — with no stored choice — a
|
||||
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
|
||||
|
||||
@@ -171,6 +171,7 @@ describe("<ResearchModeAcknowledgmentDialog>", () => {
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ data: { enabled: true } }),
|
||||
// The apiFetch wrapper reads the envelope via `text()`.
|
||||
text: async () => JSON.stringify({ data: { enabled: true } }),
|
||||
|
||||
@@ -399,6 +399,7 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
|
||||
function okResponse(id: string) {
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id } }),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id } })),
|
||||
};
|
||||
@@ -454,7 +455,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okResponse("evt-a"))
|
||||
.mockResolvedValueOnce({ ok: false, json: vi.fn(), text: vi.fn() });
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
headers: new Headers(),
|
||||
json: vi.fn(),
|
||||
text: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const queryClient = fakeQueryClient();
|
||||
|
||||
@@ -509,7 +515,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
|
||||
it("reports total failure without invalidating (nothing changed server-side)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ ok: false, json: vi.fn(), text: vi.fn() }),
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
headers: new Headers(),
|
||||
json: vi.fn(),
|
||||
text: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const queryClient = fakeQueryClient();
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-99" } }),
|
||||
// The apiFetch wrapper reads the envelope via `text()`.
|
||||
text: vi
|
||||
@@ -154,6 +155,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
|
||||
text: vi
|
||||
.fn()
|
||||
@@ -186,6 +188,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
|
||||
text: vi
|
||||
.fn()
|
||||
@@ -212,6 +215,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
|
||||
text: vi
|
||||
.fn()
|
||||
@@ -243,6 +247,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt" } }),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "evt" } })),
|
||||
});
|
||||
@@ -285,6 +290,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -311,6 +317,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn().mockResolvedValue({ data: {} }),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ data: {} })),
|
||||
}),
|
||||
@@ -334,6 +341,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
|
||||
it("posts a backdated takenAt against the medication's intake route and returns true", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue(""),
|
||||
});
|
||||
@@ -367,6 +375,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
|
||||
it("threads scheduledFor (the chosen slot) so the write routes through the canonical slot upsert", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue(""),
|
||||
});
|
||||
@@ -394,6 +403,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
|
||||
it("omits takenAt on a skipped log and shows the skipped toast", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue(""),
|
||||
});
|
||||
@@ -445,6 +455,7 @@ describe("runUndoIntake — shared soft-delete", () => {
|
||||
it("reverts via DELETE and confirms on success", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: vi.fn().mockResolvedValue(""),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
+119
-10
@@ -22,9 +22,23 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
|
||||
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import {
|
||||
discardDashboardSnapshotPreload,
|
||||
prefetchDashboardSnapshot,
|
||||
} from "@/lib/queries/use-dashboard-snapshot";
|
||||
import {
|
||||
getRecordSessionTransition,
|
||||
subscribeToRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
getRecordScope,
|
||||
subscribeToRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
restorePersistedQueryCache,
|
||||
startPersistingQueryCache,
|
||||
@@ -168,15 +182,60 @@ function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
// the router commits to "/" instead of waiting for the dashboard page
|
||||
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
|
||||
// The proxy has already enforced auth + onboarding for "/" before any
|
||||
// client code runs here, so the prefetch cannot fire for an
|
||||
// unauthenticated visitor; a racing edge case just yields a swallowed
|
||||
// 401 prefetch and the mounted cell re-resolves normally.
|
||||
// client code runs here. It does not, however, resolve the active shared
|
||||
// record: that verdict arrives from `/api/auth/me`. Do not begin a record read
|
||||
// until that answer and the local cache scope agree.
|
||||
export function isRecordPreloadReady({
|
||||
isLoading,
|
||||
accountAccessStatus,
|
||||
activeAccountId = null,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
accountAccessStatus: AccountAccessStatus | undefined;
|
||||
activeAccountId?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
!isLoading &&
|
||||
(accountAccessStatus === "absent" || accountAccessStatus === "valid") &&
|
||||
getRecordScope() === activeAccountId
|
||||
);
|
||||
}
|
||||
|
||||
function subscribeToClientRecordScope(onStoreChange: () => void): () => void {
|
||||
return subscribeToRecordScope(onStoreChange);
|
||||
}
|
||||
|
||||
function getClientRecordScope(): string | null {
|
||||
return getRecordScope();
|
||||
}
|
||||
|
||||
function getServerRecordScope(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function DashboardSnapshotPreloader() {
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
const { user, isLoading } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
const recordScope = useSyncExternalStore(
|
||||
subscribeToClientRecordScope,
|
||||
getClientRecordScope,
|
||||
getServerRecordScope,
|
||||
);
|
||||
const ready =
|
||||
transition.phase === "ready" &&
|
||||
isRecordPreloadReady({
|
||||
isLoading,
|
||||
accountAccessStatus: user?.accountAccessStatus,
|
||||
activeAccountId: user?.accountAccess?.active?.accountId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const controller = new AbortController();
|
||||
if (pathname === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
prefetchDashboardSnapshot(queryClient, controller.signal);
|
||||
}
|
||||
// v1.16.7 — same waterfall cut for the medications page: its list
|
||||
// query (which carries the per-medication `nextDueAt` the due cells
|
||||
@@ -185,9 +244,12 @@ function DashboardSnapshotPreloader() {
|
||||
// download; the nav links additionally prefetch on hover/touch
|
||||
// intent, so this is the fallback for direct loads + reloads.
|
||||
if (pathname === "/medications") {
|
||||
prefetchMedicationsList(queryClient);
|
||||
prefetchMedicationsList(queryClient, controller.signal);
|
||||
}
|
||||
}, [pathname, queryClient]);
|
||||
// A transition to an unresolved, refused, or different record scope must
|
||||
// abort a preloader before its response can be adopted by the next route.
|
||||
return () => controller.abort();
|
||||
}, [pathname, queryClient, ready, recordScope, transition.phase]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -195,9 +257,13 @@ function DashboardSnapshotPreloader() {
|
||||
|
||||
function QueryVisibilityRefreshBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const transition = useRecordSessionTransition();
|
||||
useEffect(
|
||||
() => subscribeToMeaningfulVisibilityRefresh(queryClient),
|
||||
[queryClient],
|
||||
() =>
|
||||
transition.phase === "ready"
|
||||
? subscribeToMeaningfulVisibilityRefresh(queryClient)
|
||||
: undefined,
|
||||
[queryClient, transition.phase],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -211,10 +277,16 @@ function QueryVisibilityRefreshBridge() {
|
||||
// empty skeletons. Build-version + age gated; cleared on logout.
|
||||
function QueryPersistenceBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const transition = useRecordSessionTransition();
|
||||
useEffect(() => {
|
||||
if (transition.phase !== "ready") return;
|
||||
let stop: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => {
|
||||
void restorePersistedQueryCache(
|
||||
queryClient,
|
||||
SHELL_VERSION,
|
||||
() => !cancelled,
|
||||
).finally(() => {
|
||||
if (!cancelled) {
|
||||
stop = startPersistingQueryCache(queryClient, SHELL_VERSION);
|
||||
}
|
||||
@@ -223,7 +295,42 @@ function QueryPersistenceBridge() {
|
||||
cancelled = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [queryClient, transition.phase]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tab-local reads immediately whenever the browser-wide transition
|
||||
* store enters its hold. The AuthShell consumes the same store synchronously,
|
||||
* so protected children unmount while this bridge cancels their observers.
|
||||
*/
|
||||
function RecordSessionTransitionBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const clearForTransition = () => {
|
||||
if (getRecordSessionTransition().phase === "ready") return;
|
||||
discardDashboardSnapshotPreload();
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
};
|
||||
// A tab opened while another tab is already switching needs the same
|
||||
// cache purge even though it has no future broadcast to receive.
|
||||
clearForTransition();
|
||||
return subscribeToRecordSessionTransition(clearForTransition);
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
transition.phase === "resolving" ||
|
||||
(transition.phase === "ready" && transition.id !== null)
|
||||
) {
|
||||
void refetch();
|
||||
}
|
||||
}, [refetch, transition.id, transition.phase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -279,6 +386,7 @@ export function Providers({
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
<QueryPersistenceBridge />
|
||||
<RecordSessionTransitionBridge />
|
||||
<QueryVisibilityRefreshBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
<OfflineMutationToaster />
|
||||
@@ -287,6 +395,7 @@ export function Providers({
|
||||
subscriber notices the stable errorCode and puts the browser back
|
||||
in its own account, rather than asking every call site to. */}
|
||||
<SharedRecordGrantLossBridge />
|
||||
<RecordSessionFenceBridge />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ProfileSummary } from "../profile-summary";
|
||||
|
||||
describe("ProfileSummary", () => {
|
||||
it("renders bounded profile information without mutation controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ProfileSummary
|
||||
summary={{
|
||||
allergies: [{ substance: "Pollen", category: "OTHER" }],
|
||||
familyHistory: [
|
||||
{ condition: "Hypertension", relationship: "PARENT" },
|
||||
],
|
||||
facts: [{ label: "Smoking", value: "Never" }],
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="profile-summary"');
|
||||
expect(html).toContain("Pollen");
|
||||
expect(html).toContain("Hypertension");
|
||||
expect(html).toContain("Smoking");
|
||||
expect(html).not.toContain("<button");
|
||||
expect(html).not.toContain("<form");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ProfileSummaryPayload } from "@/lib/records/profile-summary";
|
||||
|
||||
function profileFactLabel(label: string, t: (key: string) => string): string {
|
||||
const keys: Record<string, string> = {
|
||||
SMOKING_STATUS: "records.profileFacts.smokingLabel",
|
||||
ALCOHOL_PATTERN: "records.profileFacts.alcoholLabel",
|
||||
SHIFT_SCHEDULE: "records.profileFacts.shiftLabel",
|
||||
};
|
||||
return keys[label] ? t(keys[label]) : label;
|
||||
}
|
||||
|
||||
function profileFactValue(
|
||||
label: string,
|
||||
value: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const supported = new Set([
|
||||
"SMOKING_STATUS",
|
||||
"ALCOHOL_PATTERN",
|
||||
"SHIFT_SCHEDULE",
|
||||
]);
|
||||
return supported.has(label)
|
||||
? t(`records.profileFacts.values.${label}.${value}`)
|
||||
: value;
|
||||
}
|
||||
|
||||
/** Bounded profile facts, intentionally free of mutation controls. */
|
||||
export function ProfileSummary({
|
||||
summary,
|
||||
}: {
|
||||
summary: ProfileSummaryPayload;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="profile-summary">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.allergies.length ? (
|
||||
<ul className="space-y-2" data-slot="profile-summary-allergies">
|
||||
{summary.allergies.map((allergy) => (
|
||||
<li key={`${allergy.substance}-${allergy.category}`}>
|
||||
<span className="font-medium">{allergy.substance}</span>{" "}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(`records.allergies.category.${allergy.category}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.allergies.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.familyHistory.length ? (
|
||||
<ul
|
||||
className="space-y-2"
|
||||
data-slot="profile-summary-family-history"
|
||||
>
|
||||
{summary.familyHistory.map((entry) => (
|
||||
<li key={`${entry.condition}-${entry.relationship}`}>
|
||||
<span className="font-medium">{entry.condition}</span>{" "}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(`records.family.relationship.${entry.relationship}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.family.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.profileFacts.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.facts.length ? (
|
||||
<dl className="space-y-2" data-slot="profile-summary-facts">
|
||||
{summary.facts.map((fact) => (
|
||||
<div key={fact.label} className="flex justify-between gap-4">
|
||||
<dt>{profileFactLabel(fact.label, t)}</dt>
|
||||
<dd className="text-muted-foreground text-right">
|
||||
{profileFactValue(fact.label, fact.value, t)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.profileFacts.notRecorded")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileSummaryPage() {
|
||||
const { t } = useTranslations();
|
||||
const summary = useQuery({
|
||||
queryKey: queryKeys.profileSummary(),
|
||||
queryFn: () => apiGet<ProfileSummaryPayload>("/api/profile/summary"),
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<PageHeader title={t("settings.sections.anamnesis.title")} />
|
||||
{summary.isPending ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : null}
|
||||
{summary.isError ? (
|
||||
<QueryErrorCard onRetry={() => void summary.refetch()} />
|
||||
) : null}
|
||||
{summary.data ? <ProfileSummary summary={summary.data} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -84,8 +84,10 @@ describe("managed record settings forms", () => {
|
||||
),
|
||||
});
|
||||
|
||||
for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) {
|
||||
expect(modules).toContain(`name="${module}"`);
|
||||
for (const moduleKey of Object.keys(
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
)) {
|
||||
expect(modules).toContain(`name="${moduleKey}"`);
|
||||
}
|
||||
for (const metric of ALL_METRICS) {
|
||||
expect(thresholds).toContain(`value="${metric}"`);
|
||||
|
||||
@@ -297,7 +297,15 @@ export function GrantInviteCard() {
|
||||
</Label>
|
||||
<DateField
|
||||
id="grant-invite-expires"
|
||||
// `data-slot` rides `{...rest}` onto `DateField`'s HIDDEN mirror
|
||||
// input — the one that carries the ISO value for a native submit.
|
||||
// A browser test that targeted it found an invisible element and a
|
||||
// `fill()` that typed into nothing. `data-testid` is the
|
||||
// primitive's own affordance for the VISIBLE overlay, which is the
|
||||
// control a person actually types into, so the browser test uses
|
||||
// that and the slot stays where every other caller expects it.
|
||||
data-slot="grant-invite-expires"
|
||||
data-testid="grant-invite-expires-input"
|
||||
value={expiresOn}
|
||||
min={tomorrowIso(ownerTimezone)}
|
||||
onChange={setExpiresOn}
|
||||
|
||||
@@ -80,7 +80,12 @@ export function ManagedIntegrationStatus() {
|
||||
|
||||
if (statusQuery.isLoading || !statusQuery.data) {
|
||||
return (
|
||||
<SettingsCard aria-busy="true" aria-live="polite">
|
||||
<SettingsCard
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
data-record-id={recordId ?? undefined}
|
||||
data-record-settings-family="integrations"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
@@ -91,7 +96,11 @@ export function ManagedIntegrationStatus() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="managed-integration-status-title">
|
||||
<SettingsCard
|
||||
aria-labelledby="managed-integration-status-title"
|
||||
data-record-id={recordId ?? undefined}
|
||||
data-record-settings-family="integrations"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
|
||||
@@ -984,7 +984,11 @@ export function ManagedRecordSettingsSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-busy={query.isLoading}>
|
||||
<SettingsCard
|
||||
aria-busy={query.isLoading}
|
||||
data-record-id={recordId ?? undefined}
|
||||
data-record-settings-family={family}
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t(`settings.sharedRecord.managedSettings.${family}.title`)}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
@@ -430,13 +431,28 @@ export function SettingsShell({
|
||||
// the two passes disagreeing.
|
||||
const hydrated = useMounted();
|
||||
const modules = user?.modules;
|
||||
const visibleSections = SETTINGS_SECTIONS.filter(
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const allVisibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
!hydrated ||
|
||||
!section.moduleGate ||
|
||||
modules?.[section.moduleGate] !== false,
|
||||
);
|
||||
const activeSection = visibleSections.find(
|
||||
|
||||
// The server has already resolved which record is active. In a managed
|
||||
// profile, only guardian settings are a navigation destination; the section
|
||||
// gate remains responsible for refusing any direct URL that is not here.
|
||||
// Keep `allVisibleSections` for the active heading so a direct unavailable
|
||||
// link identifies itself before the gate explains why it cannot open.
|
||||
const visibleSections =
|
||||
activeRecord?.recordKind === "managed"
|
||||
? allVisibleSections.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind ===
|
||||
"managed-guardian",
|
||||
)
|
||||
: allVisibleSections;
|
||||
const activeSection = allVisibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
|
||||
@@ -539,7 +555,11 @@ export function SettingsShell({
|
||||
// producing visibly more top/bottom whitespace on Settings/Admin
|
||||
// pages than on Dashboard/Insights/Measurements.
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<div
|
||||
className="mx-auto w-full max-w-screen-xl"
|
||||
data-active-record-id={activeRecord?.accountId}
|
||||
data-record-kind={activeRecord?.recordKind}
|
||||
>
|
||||
{/* v1.18.6.1 — on mobile the heading sits above the chip strip; on
|
||||
desktop it is rendered inside the grid (row 1 / content column) so
|
||||
it does not paint twice. */}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
getRecordScope,
|
||||
isReadingSharedRecord,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiFetchRaw: vi.fn(),
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { fetchMe, NO_ACCOUNT_ACCESS } from "../use-auth";
|
||||
|
||||
const response = {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
const canonicalEntry = {
|
||||
accountId: "record-a",
|
||||
username: "record-a",
|
||||
displayName: "Record A",
|
||||
access: "read",
|
||||
level: "read",
|
||||
recordKind: "shared",
|
||||
sections: ["labs"],
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
__resetRecordScopeForTests();
|
||||
});
|
||||
|
||||
describe("fetchMe account-access resolution", () => {
|
||||
it("keeps a genuinely absent block compatible with an older server", async () => {
|
||||
apiGet.mockResolvedValue(response);
|
||||
|
||||
const user = await fetchMe();
|
||||
|
||||
expect(user.accountAccessStatus).toBe("absent");
|
||||
expect(user.accountAccess).toEqual(NO_ACCOUNT_ACCESS);
|
||||
expect(getRecordScope()).toBeNull();
|
||||
expect(isReadingSharedRecord()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a valid published block as the active record scope", async () => {
|
||||
apiGet.mockResolvedValue({
|
||||
...response,
|
||||
accountAccess: {
|
||||
accounts: [canonicalEntry],
|
||||
active: canonicalEntry,
|
||||
recordKind: "shared",
|
||||
canSwitch: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await fetchMe();
|
||||
|
||||
expect(user.accountAccessStatus).toBe("valid");
|
||||
expect(user.accountAccess?.active).toEqual(canonicalEntry);
|
||||
expect(getRecordScope()).toBe("record-a");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an explicitly undefined block", undefined],
|
||||
["a null block", null],
|
||||
[
|
||||
"a divergent active entry",
|
||||
{
|
||||
accounts: [canonicalEntry],
|
||||
active: {
|
||||
...canonicalEntry,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
},
|
||||
recordKind: "shared",
|
||||
canSwitch: true,
|
||||
},
|
||||
],
|
||||
])(
|
||||
"refuses %s without restoring an owner cache scope",
|
||||
async (_name, block) => {
|
||||
apiGet.mockResolvedValue({ ...response, accountAccess: block });
|
||||
|
||||
const user = await fetchMe();
|
||||
|
||||
expect(user.accountAccessStatus).toBe("invalid");
|
||||
expect(user.accountAccess).toEqual(NO_ACCOUNT_ACCESS);
|
||||
expect(getRecordScope()).not.toBeNull();
|
||||
expect(isReadingSharedRecord()).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Reaching a fenced route from the DOM without a headerless request.
|
||||
*
|
||||
* Two properties, both of which go wrong quietly:
|
||||
*
|
||||
* * the bytes travel through the app transport, so the request carries the
|
||||
* record-session assertion. A plain `fetch` here would reintroduce the
|
||||
* exact class `record-fence-headerless-transport-guard.test.ts` exists to
|
||||
* catch, and nothing visible would change until somebody entered a shared
|
||||
* record;
|
||||
* * every object URL is revoked. Getting that wrong leaks a decrypted health
|
||||
* document into the tab for as long as the tab lives, and the UI never
|
||||
* shows it. The hard case is a response that lands after the caller has
|
||||
* moved on — a fast scroll through the vault timeline changes the path far
|
||||
* more often than the network answers.
|
||||
*
|
||||
* The unit suite runs in `node` with no DOM, so the React hook cannot be
|
||||
* rendered here. That is why the lifecycle rule lives in
|
||||
* `createFencedBlobLoader` rather than inside the effect: an untested
|
||||
* revocation rule is exactly the kind that is wrong.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createFencedBlobLoader } from "@/hooks/use-fenced-object-url";
|
||||
import {
|
||||
__resetRecordFenceForTests,
|
||||
adoptRecordFenceState,
|
||||
} from "@/lib/api/record-fence";
|
||||
|
||||
const created: string[] = [];
|
||||
const revoked: string[] = [];
|
||||
let counter = 0;
|
||||
|
||||
/** A deferred blob response, so a load can be left in flight deliberately. */
|
||||
function pendingResponse() {
|
||||
let settle!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return {
|
||||
settle,
|
||||
fetch: vi.fn(async () => {
|
||||
await gate;
|
||||
return new Response(new Blob(["bytes"]));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
revoked.length = 0;
|
||||
counter = 0;
|
||||
__resetRecordFenceForTests();
|
||||
vi.stubGlobal("URL", {
|
||||
createObjectURL: vi.fn(() => {
|
||||
const url = `blob:mock/${counter++}`;
|
||||
created.push(url);
|
||||
return url;
|
||||
}),
|
||||
revokeObjectURL: vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
__resetRecordFenceForTests();
|
||||
});
|
||||
|
||||
describe("createFencedBlobLoader", () => {
|
||||
it("carries the record-session assertion on the request", async () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: "owner-1" });
|
||||
const fetchMock = vi.fn(async () => new Response(new Blob(["bytes"])));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await createFencedBlobLoader().load(
|
||||
"/api/documents/inbound/doc-1/thumbnail",
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
const headers = new Headers(init.headers);
|
||||
// The whole point: a browser-issued `<img src>` cannot send these, which is
|
||||
// why the bytes come through the transport instead.
|
||||
expect(headers.get("x-healthlog-record-epoch")).toBe("4");
|
||||
expect(headers.get("x-healthlog-record-scope")).toBe("owner-1");
|
||||
});
|
||||
|
||||
it("hands back an object URL once the blob lands", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["bytes"]))),
|
||||
);
|
||||
|
||||
const result = await createFencedBlobLoader().load("/api/x/y");
|
||||
|
||||
expect(result).toEqual({ kind: "loaded", url: "blob:mock/0" });
|
||||
expect(created).toEqual(["blob:mock/0"]);
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it("revokes the live URL on dispose", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["bytes"]))),
|
||||
);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
await loader.load("/api/x/y");
|
||||
loader.dispose();
|
||||
|
||||
expect(revoked).toEqual(["blob:mock/0"]);
|
||||
});
|
||||
|
||||
it("revokes a response that lands AFTER dispose instead of storing it", async () => {
|
||||
// The leak that has no visible symptom: the component unmounted, nothing
|
||||
// will ever render this, and without the check it stays alive for the life
|
||||
// of the tab.
|
||||
const { settle, fetch } = pendingResponse();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
const inFlight = loader.load("/api/x/y");
|
||||
loader.dispose();
|
||||
settle();
|
||||
|
||||
expect(await inFlight).toEqual({ kind: "superseded" });
|
||||
expect(created).toEqual(["blob:mock/0"]);
|
||||
expect(revoked).toEqual(["blob:mock/0"]);
|
||||
});
|
||||
|
||||
it("revokes a response superseded by a newer load", async () => {
|
||||
const first = pendingResponse();
|
||||
vi.stubGlobal("fetch", first.fetch);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
const stale = loader.load("/api/x/1");
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["newer"]))),
|
||||
);
|
||||
const fresh = await loader.load("/api/x/2");
|
||||
first.settle();
|
||||
|
||||
expect(await stale).toEqual({ kind: "superseded" });
|
||||
expect(fresh).toEqual({ kind: "loaded", url: "blob:mock/0" });
|
||||
// The superseded one was revoked; the live one was not.
|
||||
expect(revoked).toEqual(["blob:mock/1"]);
|
||||
expect(created).toEqual(["blob:mock/0", "blob:mock/1"]);
|
||||
});
|
||||
|
||||
it("revokes the previous URL when the same loader loads again", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["bytes"]))),
|
||||
);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
await loader.load("/api/x/1");
|
||||
await loader.load("/api/x/2");
|
||||
|
||||
expect(created).toEqual(["blob:mock/0", "blob:mock/1"]);
|
||||
expect(revoked).toEqual(["blob:mock/0"]);
|
||||
});
|
||||
|
||||
it("reports a refusal as failed rather than leaving the caller loading", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("no", { status: 403 })),
|
||||
);
|
||||
|
||||
const result = await createFencedBlobLoader().load("/api/x/y");
|
||||
|
||||
expect(result).toEqual({ kind: "failed" });
|
||||
// Nothing was created, so nothing needs revoking.
|
||||
expect(created).toEqual([]);
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a transport-level throw as failed too", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new TypeError("offline");
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await createFencedBlobLoader().load("/api/x/y")).toEqual({
|
||||
kind: "failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,10 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
recordContextIsUnproven,
|
||||
resolveRecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
|
||||
const READ_ONLY: AccountAccessEntry = {
|
||||
@@ -72,6 +75,19 @@ describe("resolveRecordCapabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed published block instead of treating it as own record", () => {
|
||||
expect(resolveRecordCapabilities(null, true)).toEqual({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: null,
|
||||
sections: [],
|
||||
recordKind: "shared",
|
||||
accessRefused: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a read-only delegate adds nothing", () => {
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
|
||||
inSharedRecord: true,
|
||||
@@ -142,3 +158,91 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the record context has to be PROVABLE before the controls appear.
|
||||
*
|
||||
* `/api/auth/me` publishes the same question from two angles: `accountAccess.active`
|
||||
* is the re-decided answer (it survived a live-grant pass), and `recordSession.scope`
|
||||
* is the raw selector 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 would otherwise
|
||||
* make 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` exists to end.
|
||||
*/
|
||||
describe("an unprovable record context withholds every control", () => {
|
||||
it("holds when the raw selector names a record the grant no longer opens", () => {
|
||||
expect(
|
||||
recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null),
|
||||
).toBe(true);
|
||||
const held = resolveRecordCapabilities(null, false, false, true);
|
||||
expect(held.canAdd).toBe(false);
|
||||
expect(held.canManage).toBe(false);
|
||||
// REFUSED, not pending. `recordSessionPending` renders a bare spinner with
|
||||
// no controls, which is right for a switch in flight and a wedge here: this
|
||||
// state reports the same thing on every `/api/auth/me`, so it never ends on
|
||||
// its own. `accessRefused` renders the door with the way back out.
|
||||
expect(held.accessRefused).toBe(true);
|
||||
expect(held.recordSessionPending).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes an EXPIRED grant to the refusal door, not the spinner", () => {
|
||||
// The second trigger, and the one that arrives without anybody doing
|
||||
// anything: revocation clears the selector inside its transaction, expiry
|
||||
// does NOT. So the session stays pointed at a record whose grant has
|
||||
// lapsed, `/api/auth/me` resolves `active: null` because the entry no
|
||||
// longer survives the live-grant pass, and `recordSession.scope` still
|
||||
// names the owner. Every delegable read 403s from here.
|
||||
const expired = recordContextIsUnproven(
|
||||
{ epoch: 3, scope: "acct-owner" },
|
||||
null,
|
||||
);
|
||||
expect(expired).toBe(true);
|
||||
const capabilities = resolveRecordCapabilities(null, false, false, expired);
|
||||
expect(capabilities.accessRefused).toBe(true);
|
||||
expect(capabilities.recordSessionPending).toBeUndefined();
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
});
|
||||
|
||||
it("holds when the resolved entry names a record the selector has left", () => {
|
||||
// The mirror image: the grant resolved, but the session is back on its own
|
||||
// record. Painting the owner's controls here would be the reverse mix-up.
|
||||
expect(recordContextIsUnproven({ epoch: 4, scope: null }, READ_ONLY)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("agrees when both answers name the same record", () => {
|
||||
expect(
|
||||
recordContextIsUnproven(
|
||||
{ epoch: 3, scope: READ_ONLY.accountId },
|
||||
READ_ONLY,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(recordContextIsUnproven({ epoch: 0, scope: null }, null)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not hold when there is no context to cross-check", () => {
|
||||
// Null is the Bearer transport (no session row, no switch state) and
|
||||
// undefined is a server image that predates the field. Neither is a
|
||||
// disagreement, and neither is a reason to blank the app for everybody.
|
||||
expect(recordContextIsUnproven(null, null)).toBe(false);
|
||||
expect(recordContextIsUnproven(undefined, READ_ONLY)).toBe(false);
|
||||
// The positive control for the two above: without it, a
|
||||
// `recordContextIsUnproven` that always returned false would pass them.
|
||||
expect(recordContextIsUnproven({ epoch: 1, scope: "someone" }, null)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves every existing arm untouched when the two agree", () => {
|
||||
// The fourth argument defaults to false, so every case above this block
|
||||
// means exactly what it meant before the fence existed.
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual(
|
||||
resolveRecordCapabilities(READ_ONLY, false, false, false),
|
||||
);
|
||||
});
|
||||
});
|
||||
+136
-29
@@ -3,20 +3,22 @@
|
||||
/**
|
||||
* v1.36.0 — entering and leaving somebody else's record, from the browser.
|
||||
*
|
||||
* The switch is three acts in a fixed order, and the order is the whole
|
||||
* safety argument:
|
||||
* The switch is four acts in a fixed browser-coordination order:
|
||||
*
|
||||
* 1. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Nothing local changes until it answers, so a switch
|
||||
* the server refuses leaves the browser exactly where it was.
|
||||
* 2. **Empty every cache that survives a reload.** The IndexedDB query
|
||||
* 1. **Hold every tab.** Publish a cross-tab `blocking` transition and clear
|
||||
* local reads before the POST can change the shared server session.
|
||||
* 2. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Any client-visible failure stays held until a
|
||||
* fresh `/api/auth/me` resolves the record the server actually kept; it
|
||||
* never restores stale caches.
|
||||
* 3. **Empty every cache that survives a reload.** The IndexedDB query
|
||||
* snapshot and the service worker's `healthlog-data-*` cache both answer
|
||||
* reads from disk. Left in place, the first paint on the other side can
|
||||
* be the PREVIOUS record's numbers under a banner naming the new one —
|
||||
* and nothing in the page's own code would be wrong, because the answer
|
||||
* would not have come from the page. Awaited, not fired: a wipe racing
|
||||
* the navigation is a wipe that sometimes does not happen.
|
||||
* 3. **Reload the document.** Not `router.refresh()`, not a query
|
||||
* 4. **Reload the document.** Not `router.refresh()`, not a query
|
||||
* invalidation. A hard navigation is what discards the in-memory cache,
|
||||
* every React state tree holding a rendered value, and every in-flight
|
||||
* request that was issued against the old record. The reload IS the cache
|
||||
@@ -35,10 +37,19 @@ import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
adoptRecordFenceState,
|
||||
currentRecordFenceState,
|
||||
} from "@/lib/api/record-fence";
|
||||
import { fetchMe } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
beginRecordSessionTransition,
|
||||
commitRecordSessionTransition,
|
||||
resolveUnknownRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
/**
|
||||
* The two stable sharing error codes, as the envelope publishes them
|
||||
@@ -82,6 +93,20 @@ export function isSharingNotPermitted(error: unknown): boolean {
|
||||
return errorCodeOf(error) === SHARING_NOT_PERMITTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — the record-session fence's 409. Not a refusal of access: another
|
||||
* tab moved the selector first, or this tab's context is older than the row.
|
||||
* The response is to reconcile through `/api/auth/me` and try again, NOT to
|
||||
* leave the record — which is why it is deliberately absent from the set
|
||||
* `subscribeToGrantLoss` fires on below.
|
||||
*/
|
||||
export const SHARING_SESSION_CHANGED = "sharing.session.changed";
|
||||
|
||||
/** True when the server refused because this session's record context moved. */
|
||||
export function isRecordSessionChanged(error: unknown): boolean {
|
||||
return errorCodeOf(error) === SHARING_SESSION_CHANGED;
|
||||
}
|
||||
|
||||
interface SwitchResponse {
|
||||
actingAs: {
|
||||
accountId: string;
|
||||
@@ -89,23 +114,66 @@ interface SwitchResponse {
|
||||
displayName: string | null;
|
||||
access: "READ" | "WRITE";
|
||||
} | null;
|
||||
}
|
||||
|
||||
async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
|
||||
return apiPost<SwitchResponse>("/api/account/switch", { accountId });
|
||||
/**
|
||||
* The context this session is now in. Null when the request carried no
|
||||
* `expectedEpoch` — which a fence-aware client never does, so in this bundle
|
||||
* it is always present on a 200.
|
||||
*/
|
||||
recordSession: { epoch: number; scope: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the switch: stamp the cache scope, empty the disk layers, reload.
|
||||
* Ask the server to move the selector, naming the epoch this browser believes
|
||||
* it is on, and adopt whatever context comes back.
|
||||
*
|
||||
* The epoch turns the write into a compare-and-set: two tabs pressing the
|
||||
* switcher at the same moment resolve to one monotonic outcome and the loser
|
||||
* gets a 409 instead of silently overwriting the winner. Adopting the response
|
||||
* is what lets the very next request prove its context — this endpoint and
|
||||
* `/api/auth/me` are the only two a client may adopt from.
|
||||
*/
|
||||
async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
|
||||
const response = await apiPost<SwitchResponse>("/api/account/switch", {
|
||||
accountId,
|
||||
expectedEpoch: currentRecordFenceState()?.epoch ?? 0,
|
||||
});
|
||||
adoptRecordFenceState(response.recordSession);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch, with one reconcile-and-retry.
|
||||
*
|
||||
* A lost compare-and-set is not a failure the person at the keyboard caused or
|
||||
* can act on: another tab of the same browser moved first. Surfacing that as a
|
||||
* failed toast would make the switcher look broken while nothing was wrong. So
|
||||
* a 409 reconciles through a network-only `/api/auth/me` — which re-adopts the
|
||||
* true epoch — and retries exactly once. A SECOND 409 means the browser is
|
||||
* genuinely racing itself faster than it can settle, and that does surface,
|
||||
* because retrying forever would spin rather than tell anybody.
|
||||
*/
|
||||
async function postSwitchWithReconcile(
|
||||
accountId: string | null,
|
||||
): Promise<SwitchResponse> {
|
||||
try {
|
||||
return await postSwitch(accountId);
|
||||
} catch (error) {
|
||||
if (!isRecordSessionChanged(error)) throw error;
|
||||
await fetchMe();
|
||||
return postSwitch(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a server-confirmed switch: empty the disk layers, reload.
|
||||
*
|
||||
* Extracted from the mutation so the grant-loss reset can reuse the exact same
|
||||
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
|
||||
* whose order is the safety property.
|
||||
*/
|
||||
async function landOnRecord(accountId: string | null): Promise<never> {
|
||||
setRecordScope(accountId);
|
||||
async function landOnRecord(destination = "/"): Promise<never> {
|
||||
await clearOfflineCachesForRecordSwitch();
|
||||
window.location.assign("/");
|
||||
window.location.assign(destination);
|
||||
// The document is being replaced. Nothing after this runs, and the promise
|
||||
// deliberately never settles so no caller paints a "done" state over a page
|
||||
// that is on its way out.
|
||||
@@ -124,15 +192,35 @@ export function useAccountSwitch() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationKey: queryKeys.accountSwitchMutation(),
|
||||
mutationFn: async (accountId: string | null) => {
|
||||
await postSwitch(accountId);
|
||||
mutationFn: async ({
|
||||
accountId,
|
||||
destination,
|
||||
}: {
|
||||
accountId: string | null;
|
||||
destination: string;
|
||||
}) => {
|
||||
// Publish the browser-wide hold before the server sees the session
|
||||
// change. Every recipient blocks its shell and clears local reads before
|
||||
// it reconciles the committed scope through a fresh `/me`.
|
||||
const transitionId = beginRecordSessionTransition(accountId);
|
||||
// The in-memory cache holds the record we are leaving. Clearing it here
|
||||
// as well as reloading costs nothing and closes the window in which a
|
||||
// failed navigation would leave the old entries reachable.
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
return landOnRecord(accountId);
|
||||
try {
|
||||
await postSwitchWithReconcile(accountId);
|
||||
} catch (error) {
|
||||
// A client error cannot prove that the server left the session alone:
|
||||
// its response may have been lost after the server committed. Keep all
|
||||
// tabs held until a fresh `/me` resolves the record the server kept.
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
throw error;
|
||||
}
|
||||
commitRecordSessionTransition(transitionId, accountId);
|
||||
return landOnRecord(destination);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
@@ -142,6 +230,16 @@ export function useAccountSwitch() {
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
/** Preserve the established own-record and exit call sites. */
|
||||
mutate: (accountId: string | null) =>
|
||||
mutation.mutate({ accountId, destination: "/" }),
|
||||
/** Enter a scoped record directly at a server-presentable destination. */
|
||||
switchTo: (accountId: string, destination: string) =>
|
||||
mutation.mutate({ accountId, destination }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,14 +247,15 @@ export function useAccountSwitch() {
|
||||
*
|
||||
* The switcher-reset the contract asks for: on `sharing.access.denied` the
|
||||
* session is still stamped with an account it may no longer act on, and every
|
||||
* delegable read from here on is a 403. Clearing the stamp puts the browser
|
||||
* back in its own account rather than leaving somebody staring at a wall of
|
||||
* refusals under a banner that says they are somewhere they are not.
|
||||
* delegable read from here on is a 403. When the server confirms the reset,
|
||||
* its own-record scope replaces the stamp rather than leaving somebody
|
||||
* staring at refusals under a banner that says they are somewhere they are
|
||||
* not.
|
||||
*
|
||||
* `POST /api/account/switch` with `null` is an actor surface and always
|
||||
* succeeds — it is the way out, by design. If even that fails (offline, server
|
||||
* down) the reload still happens: the stamp survives, but so does the honest
|
||||
* refusal, and the alternative is a page that neither recovers nor says why.
|
||||
* down), the browser keeps the transition unresolved and reloads. A fresh
|
||||
* `/api/auth/me`, not a local scope guess, decides what may render next.
|
||||
*/
|
||||
/**
|
||||
* Watch the cache for the moment the server stops honouring the switch.
|
||||
@@ -185,12 +284,20 @@ export function subscribeToGrantLoss(
|
||||
}
|
||||
|
||||
export function useLeaveSharedRecordOnGrantLoss() {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(async () => {
|
||||
const transitionId = beginRecordSessionTransition(null);
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
try {
|
||||
await postSwitch(null);
|
||||
await postSwitchWithReconcile(null);
|
||||
commitRecordSessionTransition(transitionId, null);
|
||||
} catch {
|
||||
/* the reload below is the part that matters */
|
||||
// The grant-loss path deliberately still reloads, but it cannot claim
|
||||
// an own-record scope when the POST outcome is unknown. The next `/me`
|
||||
// is the only authority that may release this resolving hold.
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
}
|
||||
await landOnRecord(null);
|
||||
}, []);
|
||||
await landOnRecord();
|
||||
}, [queryClient]);
|
||||
}
|
||||
+78
-25
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
|
||||
import { adoptRecordFenceState } from "@/lib/api/record-fence";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -23,8 +24,16 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
setRecordScope,
|
||||
setRefusedRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
settleRecordSessionTransition,
|
||||
settleRefusedRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
|
||||
|
||||
/**
|
||||
* v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back
|
||||
@@ -38,6 +47,9 @@ export const NO_ACCOUNT_ACCESS: AccountAccess = {
|
||||
canSwitch: false,
|
||||
};
|
||||
|
||||
/** Whether the account-access block was absent, valid, or refused as malformed. */
|
||||
export type AccountAccessStatus = "absent" | "valid" | "invalid";
|
||||
|
||||
/**
|
||||
* v1.18.6 — resume point for the module tour as the client sees it on
|
||||
* `/api/auth/me`. Mirrors the server-side `TourProgress` shape.
|
||||
@@ -192,6 +204,28 @@ export interface AuthUser {
|
||||
* the empty one — no accounts, no active record, no switcher.
|
||||
*/
|
||||
accountAccess?: AccountAccess;
|
||||
/**
|
||||
* The parser outcome for `accountAccess`. An absent block is the only
|
||||
* owner-compatible fallback: it is how older server images answered before
|
||||
* sharing existed. A present malformed block stays explicitly refused so a
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +286,7 @@ export function isAuthVerdictUnknown(error: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
export async function fetchMe(): Promise<AuthUser> {
|
||||
// v1.16.4 — routed through the typed wrapper; a non-OK /me (401)
|
||||
// throws `ApiError`, which `useAuth` treats as "not authenticated"
|
||||
// exactly like the old hand-rolled throw.
|
||||
@@ -299,8 +333,29 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
// whatever the switch flow stamped before its reload, the block is what the
|
||||
// resolver will actually honour on the next request, so the mirror tracks it
|
||||
// rather than the client's memory of what it asked for.
|
||||
const accountAccess = coerceAccountAccess(data.accountAccess);
|
||||
setRecordScope(accountAccess.active?.accountId ?? null);
|
||||
// v1.37.0 — adopt the record-session context BEFORE anything releases a
|
||||
// hold. `/api/auth/me` is one of exactly two responses a client may learn
|
||||
// its context from, and a hold released onto an unadopted context would put
|
||||
// the shell back on screen with every subsequent request still asserting
|
||||
// `bootstrap`. Adoption first means a released hold is always released onto
|
||||
// a context the next request can prove.
|
||||
adoptRecordFenceState(data.recordSession);
|
||||
const { accountAccess, status: accountAccessStatus } =
|
||||
resolveAccountAccess(data);
|
||||
if (accountAccessStatus === "invalid") {
|
||||
setRefusedRecordScope();
|
||||
} else {
|
||||
setRecordScope(accountAccess.active?.accountId ?? null);
|
||||
}
|
||||
// A cross-tab switch holds the shell until this server-resolved payload
|
||||
// confirms the record the transition committed. The malformed branch has
|
||||
// already installed its refusal sentinel, so it may release only into the
|
||||
// refusal door rather than being mistaken for the caller's own record.
|
||||
if (accountAccessStatus === "invalid") {
|
||||
settleRefusedRecordSessionTransition();
|
||||
} else {
|
||||
settleRecordSessionTransition(accountAccess.active?.accountId ?? null);
|
||||
}
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
@@ -328,34 +383,32 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
? data.moduleAvailability
|
||||
: {},
|
||||
accountAccess,
|
||||
accountAccessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sharing block off a payload that may not carry one.
|
||||
*
|
||||
* Fails CLOSED, unlike the module maps above: an older server image (or a
|
||||
* malformed block) resolves to no accounts, no active record, and no switcher.
|
||||
* The module gates fail open because a missing map must never blank the nav;
|
||||
* this one fails closed because the affordance it drives asks the server to
|
||||
* open somebody else's health record, and an affordance offered on a server
|
||||
* that cannot honour it is worse than one that is missing.
|
||||
* A genuinely missing field is the pre-sharing server contract and remains an
|
||||
* own-record fallback. Once a server publishes the field, every other value —
|
||||
* including `undefined` as an own property and `null` — is a malformed block,
|
||||
* not permission to fall back to the caller's own presentation.
|
||||
*/
|
||||
function coerceAccountAccess(value: unknown): AccountAccess {
|
||||
if (value === null || typeof value !== "object") return NO_ACCOUNT_ACCESS;
|
||||
const block = value as Partial<AccountAccess>;
|
||||
if (!Array.isArray(block.accounts)) return NO_ACCOUNT_ACCESS;
|
||||
return {
|
||||
accounts: block.accounts,
|
||||
active: block.active ?? null,
|
||||
recordKind:
|
||||
block.recordKind === "self" ||
|
||||
block.recordKind === "shared" ||
|
||||
block.recordKind === "managed"
|
||||
? block.recordKind
|
||||
: "self",
|
||||
canSwitch: block.canSwitch === true,
|
||||
};
|
||||
function resolveAccountAccess(value: object): {
|
||||
accountAccess: AccountAccess;
|
||||
status: AccountAccessStatus;
|
||||
} {
|
||||
if (!Object.hasOwn(value, "accountAccess")) {
|
||||
return { accountAccess: NO_ACCOUNT_ACCESS, status: "absent" };
|
||||
}
|
||||
|
||||
const accountAccess = parseAccountAccess(
|
||||
(value as { accountAccess?: unknown }).accountAccess,
|
||||
);
|
||||
return accountAccess
|
||||
? { accountAccess, status: "valid" }
|
||||
: { accountAccess: NO_ACCOUNT_ACCESS, status: "invalid" };
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.37.0 — reach a record-fenced route from the DOM without a headerless
|
||||
* request.
|
||||
*
|
||||
* `<img src>`, `<iframe src>` and `<a href download>` are issued by the browser
|
||||
* itself. They never pass through `src/lib/api/api-fetch.ts`, so they carry no
|
||||
* record-session assertion — and once a session has entered or left a shared
|
||||
* record the fence refuses an unasserted request with 403
|
||||
* `sharing.access.denied`, permanently, because nothing about those three will
|
||||
* ever start sending a header. The visible symptom is a thumbnail that stops
|
||||
* rendering for good after somebody visits a shared record.
|
||||
*
|
||||
* So the bytes are fetched through the app transport, which attaches the
|
||||
* assertion and validates the echo, and the DOM is handed an object URL
|
||||
* instead. The shape is not new here: `document-ai-transport.ts` already
|
||||
* reaches `/api/documents/inbound/{id}/original` this way to build a `File` for
|
||||
* in-browser OCR. `src/__tests__/record-fence-headerless-transport-guard.test.ts`
|
||||
* is what keeps a future `src="/api/…"` from reintroducing the class.
|
||||
*
|
||||
* ## Why the loader is separate from the hook
|
||||
*
|
||||
* Revocation is the part worth being careful about: getting it wrong leaks a
|
||||
* decrypted health document into the tab's memory for as long as the tab lives,
|
||||
* and nothing in the UI ever shows it. The unit suite runs in `node` with no
|
||||
* DOM and no testing-library, so a hook cannot be rendered here — and an
|
||||
* untested revocation rule is exactly the kind that is wrong. The rule
|
||||
* therefore lives in {@link createFencedBlobLoader}, which is a plain object
|
||||
* with `load` and `dispose`, fully exercised in
|
||||
* `src/hooks/__tests__/use-fenced-object-url.test.ts`. The hook below is the
|
||||
* thin React lifecycle shell over it.
|
||||
*/
|
||||
export interface FencedObjectUrl {
|
||||
/** The object URL, or null while loading, disabled, or failed. */
|
||||
url: string | null;
|
||||
/** The fetch finished and did not produce bytes. Render the fallback. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** What one load produced. */
|
||||
export type FencedBlobResult =
|
||||
{ kind: "loaded"; url: string } | { kind: "failed" } | { kind: "superseded" };
|
||||
|
||||
/**
|
||||
* A one-slot object-URL holder.
|
||||
*
|
||||
* Holds at most one live URL. Loading again revokes the previous one; a
|
||||
* response that lands after `dispose()` or after a newer `load()` is revoked
|
||||
* immediately rather than stored, which is the case a fast scroll through the
|
||||
* vault timeline produces constantly — the path changes far more often than the
|
||||
* network answers.
|
||||
*/
|
||||
export function createFencedBlobLoader() {
|
||||
let current: string | null = null;
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
|
||||
const release = () => {
|
||||
if (current !== null) {
|
||||
URL.revokeObjectURL(current);
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async load(path: string): Promise<FencedBlobResult> {
|
||||
const mine = ++generation;
|
||||
release();
|
||||
let url: string | null = null;
|
||||
try {
|
||||
const res = await apiFetchRaw(path);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
url = URL.createObjectURL(blob);
|
||||
} catch {
|
||||
// Every failure reads the same to the caller — refused, offline, gone,
|
||||
// or a context mismatch the transport discarded. The caller renders its
|
||||
// fallback either way, and the fence's own reconciliation handles the
|
||||
// one case that is recoverable.
|
||||
return disposed || mine !== generation
|
||||
? { kind: "superseded" }
|
||||
: { kind: "failed" };
|
||||
}
|
||||
if (disposed || mine !== generation) {
|
||||
// Nobody is going to render this. Revoke it here or it lives as long
|
||||
// as the tab does.
|
||||
URL.revokeObjectURL(url);
|
||||
return { kind: "superseded" };
|
||||
}
|
||||
current = url;
|
||||
return { kind: "loaded", url };
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useFencedObjectUrl(path: string | null): FencedObjectUrl {
|
||||
// The resolved path is stored ALONGSIDE the result, and the return value is
|
||||
// derived by comparing it against the current one. That is what keeps this
|
||||
// free of a synchronous `setState` inside the effect: while the path has just
|
||||
// changed and the effect has not run yet, the answer is "loading" because the
|
||||
// stored result belongs to a different path — not because something reset it.
|
||||
// Without that, a fast scroll through the vault timeline would paint one
|
||||
// card's thumbnail on the next card for a frame.
|
||||
const [resolved, setResolved] = useState<{
|
||||
path: string | null;
|
||||
url: string | null;
|
||||
failed: boolean;
|
||||
}>({ path: null, url: null, failed: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (path === null) return;
|
||||
const loader = createFencedBlobLoader();
|
||||
void loader.load(path).then((result) => {
|
||||
if (result.kind === "superseded") return;
|
||||
setResolved({
|
||||
path,
|
||||
url: result.kind === "loaded" ? result.url : null,
|
||||
failed: result.kind === "failed",
|
||||
});
|
||||
});
|
||||
return () => loader.dispose();
|
||||
}, [path]);
|
||||
|
||||
if (path === null || resolved.path !== path) {
|
||||
return { url: null, failed: false };
|
||||
}
|
||||
return { url: resolved.url, failed: resolved.failed };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
@@ -71,6 +72,10 @@ import {
|
||||
* search a button in gets a sentence.
|
||||
*/
|
||||
export interface RecordCapabilities {
|
||||
/** A tab is awaiting a fresh `/me` after another tab changed the session. */
|
||||
recordSessionPending?: boolean;
|
||||
/** A present account-access block was malformed and must not mount a record. */
|
||||
accessRefused?: boolean;
|
||||
/** Is this browser acting on somebody else's record right now. */
|
||||
inSharedRecord: boolean;
|
||||
/** The grant's resolved level for the record on screen. */
|
||||
@@ -111,7 +116,31 @@ export interface RecordCapabilities {
|
||||
*/
|
||||
export function resolveRecordCapabilities(
|
||||
active: AccountAccessEntry | null | undefined,
|
||||
accessRefused = false,
|
||||
recordSessionPending = false,
|
||||
contextUnproven = false,
|
||||
): RecordCapabilities {
|
||||
if (accessRefused || recordSessionPending || contextUnproven) {
|
||||
return {
|
||||
// An unprovable context is a REFUSAL, not a transient hold, and the
|
||||
// difference is whether there is a way out. `recordSessionPending`
|
||||
// renders `RecordScopeHydrationGate` — a bare spinner with no controls,
|
||||
// correct while a switch is genuinely in flight because it ends on its
|
||||
// own. This state does not end on its own: `/api/auth/me` reports the
|
||||
// same disagreement on every boot, so a spinner here is a wedge with no
|
||||
// exit. `accessRefused` renders `SharedRecordUnavailable`, which carries
|
||||
// a "leave this record" button that posts `switch(null)`.
|
||||
accessRefused: accessRefused || contextUnproven || undefined,
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: null,
|
||||
sections: [],
|
||||
recordKind: "shared",
|
||||
};
|
||||
}
|
||||
if (!active) {
|
||||
return {
|
||||
inSharedRecord: false,
|
||||
@@ -154,9 +183,45 @@ 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();
|
||||
return resolveRecordCapabilities(user?.accountAccess?.active);
|
||||
const transition = useRecordSessionTransition();
|
||||
return resolveRecordCapabilities(
|
||||
user?.accountAccess?.active,
|
||||
user?.accountAccessStatus === "invalid",
|
||||
transition.phase !== "ready",
|
||||
recordContextIsUnproven(user?.recordSession, user?.accountAccess?.active),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getRecordSessionTransition,
|
||||
subscribeToRecordSessionTransition,
|
||||
type RecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
const SERVER_TRANSITION: RecordSessionTransition = {
|
||||
id: null,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
};
|
||||
|
||||
function getServerRecordSessionTransition(): RecordSessionTransition {
|
||||
return SERVER_TRANSITION;
|
||||
}
|
||||
|
||||
/** The tab-local view of a browser-wide record-session transition. */
|
||||
export function useRecordSessionTransition(): RecordSessionTransition {
|
||||
return useSyncExternalStore(
|
||||
subscribeToRecordSessionTransition,
|
||||
getRecordSessionTransition,
|
||||
getServerRecordSessionTransition,
|
||||
);
|
||||
}
|
||||
@@ -148,19 +148,36 @@ function user(
|
||||
};
|
||||
}
|
||||
|
||||
/** Sign the delegate in over the cookie transport. */
|
||||
/**
|
||||
* Sign the delegate in over the cookie transport.
|
||||
*
|
||||
* 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,
|
||||
role: "USER" | "ADMIN" = "USER",
|
||||
recordEpoch = 0,
|
||||
): void {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId,
|
||||
recordEpoch,
|
||||
},
|
||||
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. */
|
||||
@@ -649,6 +666,7 @@ describe("the cookie-only helpers stay out of reach of a switch", () => {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: OWNER,
|
||||
recordEpoch: 0,
|
||||
},
|
||||
user: {
|
||||
...user(DELEGATE),
|
||||
@@ -896,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();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,11 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
// v1.37.0 — the record-session fence stamps the response echo on the
|
||||
// request-scoped wide event. There is no event outside a request scope and
|
||||
// the fence handles that with an optional call, so `undefined` is the honest
|
||||
// stub rather than a fake builder.
|
||||
getEvent: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
@@ -674,3 +679,158 @@ describe("withIdempotency — record-scoped cells", () => {
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the record-session fence, above the replay cache.
|
||||
*
|
||||
* The property is a PLACEMENT, and a status-code assertion cannot see it: a
|
||||
* wrapper that read the cell, replayed nothing, released its claim and then
|
||||
* returned 409 would look identical from outside. `findCached` and `claimKey`
|
||||
* are module-private, so the instrument is one layer lower and falsifiable: the
|
||||
* two Prisma calls they make must receive zero calls for a refused request and
|
||||
* non-zero for a passing one.
|
||||
*
|
||||
* The first draft of this plan proposed asserting "no row exists for the target
|
||||
* cell" instead. That assertion cannot fail: `releaseClaim` deletes the pending
|
||||
* row whenever the handler throws, so it would pass with the fence placed after
|
||||
* `claimKey` too.
|
||||
*
|
||||
* Break it by moving the fence call below `findCached` in `withIdempotency`:
|
||||
* the `findUnique` zero-call assertion fails.
|
||||
*/
|
||||
describe("the record-session fence sits above the replay cache", () => {
|
||||
function fencedSession(recordEpoch: number, actingAsUserId: string | null) {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: {
|
||||
id: "s-1",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
actingAsUserId,
|
||||
recordEpoch,
|
||||
},
|
||||
user: { id: "u-1" },
|
||||
} as never);
|
||||
}
|
||||
|
||||
function asserts(epoch: string | null, scope: string | null) {
|
||||
vi.mocked(headers).mockResolvedValue({
|
||||
get: vi.fn((name: string) => {
|
||||
if (name.toLowerCase() === "x-healthlog-record-epoch") return epoch;
|
||||
if (name.toLowerCase() === "x-healthlog-record-scope") return scope;
|
||||
return null;
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("refuses a stale claim without reading or claiming a cell", async () => {
|
||||
fencedSession(4, "owner-1");
|
||||
asserts("3", "owner-1");
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
|
||||
const res = await wrapped(
|
||||
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
|
||||
// The placement, stated as the only thing that can prove it.
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
// And the side effect the whole wrapper exists to run once never ran.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POSITIVE CONTROL: a matching claim reads the cell and claims it", async () => {
|
||||
// Without this the assertion above would pass for a wrapper that never
|
||||
// touched the cache at all.
|
||||
fencedSession(4, "owner-1");
|
||||
asserts("4", "owner-1");
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
|
||||
const res = await wrapped(
|
||||
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets an unfenced client through to the route's own refusal", async () => {
|
||||
// `unfenced-client` is deliberately NOT refused here: the route owns the
|
||||
// 403 a pre-fence bundle recovers from, together with its annotation and
|
||||
// audit posture. The wrapper still files the cell, because the handler is
|
||||
// about to answer.
|
||||
fencedSession(4, "owner-1");
|
||||
asserts(null, null);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json(
|
||||
{ data: null, error: "Account access denied" },
|
||||
{ status: 403 },
|
||||
),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
|
||||
const res = await wrapped(
|
||||
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a never-switched session byte-identical", async () => {
|
||||
fencedSession(0, null);
|
||||
asserts(null, null);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
|
||||
const res = await wrapped(
|
||||
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the Bearer transport byte-identical", async () => {
|
||||
// No session row at all, so no context to be stale about — and a fence
|
||||
// header on this transport is neither read nor honoured.
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
userId: "u-bearer",
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
vi.mocked(headers).mockResolvedValue({
|
||||
get: vi.fn((name: string) => {
|
||||
if (name.toLowerCase() === "authorization")
|
||||
return `Bearer hlk_${"a".repeat(64)}`;
|
||||
if (name.toLowerCase() === "x-healthlog-record-epoch") return "1";
|
||||
if (name.toLowerCase() === "x-healthlog-record-scope") return "owner-1";
|
||||
return null;
|
||||
}),
|
||||
} as never);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
|
||||
const res = await wrapped(
|
||||
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
type GrantNeed,
|
||||
} from "./sharing/grants";
|
||||
import type { ShareScope } from "./sharing/scope";
|
||||
import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract";
|
||||
import {
|
||||
assertRecordSessionFence,
|
||||
attachRecordContextEcho,
|
||||
} from "./sharing/record-session-fence";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
@@ -139,6 +144,38 @@ export class SharingNotPermittedError extends SharingAuthError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — "the record this session is on is not the record you asserted."
|
||||
*
|
||||
* A third sibling rather than a branch, so it rides the existing
|
||||
* `SharingAuthError` arm below with no new serialisation code. The status is
|
||||
* the one thing that differs and it differs on purpose: 409, not 403, because
|
||||
* this is not a refusal of access. The caller may well be entitled to both
|
||||
* records. What failed is agreement about WHICH one, and the instruction the
|
||||
* code carries is "reconcile through `/api/auth/me` and try again", not "you
|
||||
* have lost this record".
|
||||
*
|
||||
* That distinction is load-bearing for deploy compatibility: the currently
|
||||
* shipped bundle reacts to `sharing.access.denied` by leaving the record and
|
||||
* hard-navigating, which is exactly the wrong response to a transient
|
||||
* disagreement and exactly the right one for a bundle too old to reconcile. See
|
||||
* `src/lib/sharing/record-session-fence-contract.ts` for why the fence hands
|
||||
* each bundle the code it can act on.
|
||||
*
|
||||
* The body carries `meta.errorCode` and nothing else — no current epoch, no
|
||||
* current scope. A client may learn its context from exactly two responses
|
||||
* (`GET /api/auth/me` and `POST /api/account/switch`), and putting the truth in
|
||||
* this refusal would open a third path, one that arrives on the very requests
|
||||
* whose context is in doubt.
|
||||
*/
|
||||
export class RecordSessionChangedError extends SharingAuthError {
|
||||
readonly errorCode = RECORD_FENCE_ERROR_CODE;
|
||||
constructor() {
|
||||
super(409, "The record this session is on has changed");
|
||||
this.name = "RecordSessionChangedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an API route handler with Wide Event logging.
|
||||
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
|
||||
@@ -366,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;
|
||||
});
|
||||
};
|
||||
@@ -393,6 +452,16 @@ export type AuthContext = {
|
||||
* below reads it.
|
||||
*/
|
||||
readonly actingAsUserId?: string | null;
|
||||
/**
|
||||
* v1.37.0 — how many times this session's record selector has moved. The
|
||||
* record-session fence compares it against what the request asserted.
|
||||
*
|
||||
* Optional for the same reason as `actingAsUserId` above, and `undefined`
|
||||
* reads as `0` — which is the fence's exemption, so a hand-built test
|
||||
* context keeps meaning "a session that never switched". Read only by the
|
||||
* fence.
|
||||
*/
|
||||
readonly recordEpoch?: number;
|
||||
};
|
||||
user: User;
|
||||
/**
|
||||
@@ -788,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") {
|
||||
@@ -859,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") {
|
||||
|
||||
@@ -39,6 +39,18 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** The record-session assertion the transport attached to a `fetch` call. */
|
||||
function recordFenceHeadersOf(call: unknown[]): {
|
||||
epoch: string | null;
|
||||
scope: string | null;
|
||||
} {
|
||||
const headers = new Headers((call[1] as RequestInit | undefined)?.headers);
|
||||
return {
|
||||
epoch: headers.get("x-healthlog-record-epoch"),
|
||||
scope: headers.get("x-healthlog-record-scope"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("apiFetch", () => {
|
||||
it("unwraps the data payload from the envelope", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
@@ -48,8 +60,17 @@ describe("apiFetch", () => {
|
||||
const data = await apiFetch<{ id: string }>("/api/measurements/m1");
|
||||
|
||||
expect(data).toEqual({ id: "m1" });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", {
|
||||
signal: expect.any(AbortSignal),
|
||||
// v1.37.0 — `headers` is now always present: the transport attaches the
|
||||
// record-session assertion to every same-origin request, so no call site
|
||||
// has to classify itself as record traffic. `objectContaining` rather than
|
||||
// an exact object so the assertion stays about what THIS test is for.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/measurements/m1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(recordFenceHeadersOf(fetchMock.mock.calls[0])).toEqual({
|
||||
epoch: "bootstrap",
|
||||
scope: "bootstrap",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,10 +83,13 @@ describe("apiFetch", () => {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/version", {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/version",
|
||||
expect.objectContaining({
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves undefined for a 204 response", async () => {
|
||||
@@ -242,10 +266,13 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiGet("/api/foo");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "GET",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/foo",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -272,10 +299,17 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiPost("/api/foo");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "POST",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/foo",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
// Still no body and no Content-Type — the fence headers are the only ones.
|
||||
const [, bodylessInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(bodylessInit.body).toBeUndefined();
|
||||
expect(new Headers(bodylessInit.headers).get("Content-Type")).toBeNull();
|
||||
});
|
||||
|
||||
it("merges caller headers with the JSON Content-Type", async () => {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The browser's half of the record-session fence: what it asserts on a request,
|
||||
* and what it does with the context a response echoes back.
|
||||
*
|
||||
* The two halves of the response rule are worth stating as opposites, because
|
||||
* getting either backwards is a live defect:
|
||||
*
|
||||
* * a MISMATCHING echo is discarded — the answer is about a record this
|
||||
* browser has left, and serving it paints one person's numbers under
|
||||
* another person's name;
|
||||
* * an ABSENT echo is served normally — it means the response resolved no
|
||||
* record scope, which is true of every public route, actor surface, admin
|
||||
* surface and static response. `/api/version` is polled on a timer through
|
||||
* `apiGet`, so a discard-on-absence rule would put the browser into a
|
||||
* permanent hold-and-reconcile loop.
|
||||
*
|
||||
* Break it by making `validateResponseContext` discard on absence: the
|
||||
* `/api/version`-shaped case fails, and no hold is entered.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_FENCE_BOOTSTRAP,
|
||||
RECORD_SCOPE_HEADER,
|
||||
RECORD_SCOPE_SELF,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
import {
|
||||
RecordContextMismatchError,
|
||||
__resetRecordFenceForTests,
|
||||
adoptRecordFenceState,
|
||||
currentAssertion,
|
||||
currentRecordFenceState,
|
||||
onRecordFenceMismatch,
|
||||
validateResponseContext,
|
||||
withRecordFenceHeaders,
|
||||
} from "@/lib/api/record-fence";
|
||||
|
||||
const OWNER = "owner-account-id";
|
||||
|
||||
function respond(headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify({ data: null, error: null }), {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** What `/api/version` looks like on the wire: 2xx, no record echo. */
|
||||
function publicResponse(): Response {
|
||||
return respond({ "content-type": "application/json" });
|
||||
}
|
||||
|
||||
function echoing(epoch: number, scope: string | null): Response {
|
||||
return respond({
|
||||
[RECORD_EPOCH_HEADER]: String(epoch),
|
||||
[RECORD_SCOPE_HEADER]: scope ?? RECORD_SCOPE_SELF,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRecordFenceForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetRecordFenceForTests();
|
||||
});
|
||||
|
||||
describe("what the browser asserts on a request", () => {
|
||||
it("sends the bootstrap sentinel before anything has been adopted", () => {
|
||||
expect(currentAssertion()).toEqual({
|
||||
epoch: RECORD_FENCE_BOOTSTRAP,
|
||||
scope: RECORD_FENCE_BOOTSTRAP,
|
||||
});
|
||||
const init = withRecordFenceHeaders();
|
||||
const headers = new Headers(init.headers);
|
||||
// Present, not absent. Header-absent means exactly one thing on the wire —
|
||||
// a bundle that predates the fence — and a fence-aware client must never
|
||||
// be mistaken for one.
|
||||
expect(headers.get(RECORD_EPOCH_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP);
|
||||
expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP);
|
||||
});
|
||||
|
||||
it("sends the adopted context once /me has answered", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const headers = new Headers(withRecordFenceHeaders().headers);
|
||||
expect(headers.get(RECORD_EPOCH_HEADER)).toBe("4");
|
||||
expect(headers.get(RECORD_SCOPE_HEADER)).toBe(OWNER);
|
||||
});
|
||||
|
||||
it("spells one's own record as the self sentinel", () => {
|
||||
adoptRecordFenceState({ epoch: 2, scope: null });
|
||||
const headers = new Headers(withRecordFenceHeaders().headers);
|
||||
expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF);
|
||||
});
|
||||
|
||||
it("keeps caller headers and does not overwrite an explicit assertion", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const init = withRecordFenceHeaders({
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
[RECORD_EPOCH_HEADER]: "1",
|
||||
},
|
||||
});
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(headers.get(RECORD_EPOCH_HEADER)).toBe("1");
|
||||
});
|
||||
|
||||
it("un-adopts on a null context rather than keeping a stale one", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
adoptRecordFenceState(null);
|
||||
expect(currentRecordFenceState()).toBeNull();
|
||||
expect(currentAssertion().epoch).toBe(RECORD_FENCE_BOOTSTRAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the browser does with an echoed context", () => {
|
||||
it("FENCE-AC-06 serves a response carrying no echo, and enters no hold", () => {
|
||||
// The `/api/version` case, named because it is the concrete thing a
|
||||
// discard-on-absence rule breaks: it is polled on a timer.
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
const res = publicResponse();
|
||||
expect(validateResponseContext(res)).toBe(res);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves a response whose echo agrees", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
const res = echoing(4, OWNER);
|
||||
expect(validateResponseContext(res)).toBe(res);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FENCE-AC-06 discards a response delayed across a switch, and enters the hold", () => {
|
||||
// Acceptance case 6: the response was already past the fence when the
|
||||
// switch committed, so the server served it honestly under the old context
|
||||
// and said so. The browser is on epoch 5 now.
|
||||
adoptRecordFenceState({ epoch: 5, scope: null });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
expect(() => validateResponseContext(echoing(4, OWNER))).toThrow(
|
||||
RecordContextMismatchError,
|
||||
);
|
||||
expect(hold).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discards on a matching epoch whose scope disagrees", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
expect(() => validateResponseContext(echoing(4, "somebody-else"))).toThrow(
|
||||
RecordContextMismatchError,
|
||||
);
|
||||
expect(hold).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves an echoing response while still unadopted", () => {
|
||||
// Nothing to contradict: the request itself asserted `bootstrap`, so the
|
||||
// server already decided whether it could be served at all.
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
const res = echoing(0, null);
|
||||
expect(validateResponseContext(res)).toBe(res);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not raise an ApiError, because nothing about the exchange failed", () => {
|
||||
adoptRecordFenceState({ epoch: 5, scope: null });
|
||||
try {
|
||||
validateResponseContext(echoing(4, OWNER));
|
||||
throw new Error("expected a throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(RecordContextMismatchError);
|
||||
// A caller branching on `err.status` must not see a status that was
|
||||
// never refused — the response was a 200.
|
||||
expect((err as { status?: number }).status).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases the mismatch handler when its disposer runs", () => {
|
||||
adoptRecordFenceState({ epoch: 5, scope: null });
|
||||
const hold = vi.fn();
|
||||
const dispose = onRecordFenceMismatch(hold);
|
||||
dispose();
|
||||
|
||||
expect(() => validateResponseContext(echoing(4, OWNER))).toThrow(
|
||||
RecordContextMismatchError,
|
||||
);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,10 @@
|
||||
*/
|
||||
|
||||
import { readError } from "./read-error";
|
||||
import {
|
||||
validateResponseContext,
|
||||
withRecordFenceHeaders,
|
||||
} from "./record-fence";
|
||||
|
||||
/** HTTP-level failure from an `/api/...` route — non-OK status. */
|
||||
export class ApiError extends Error {
|
||||
@@ -113,7 +117,9 @@ export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, withDefaultTimeout(init));
|
||||
const res = validateResponseContext(
|
||||
await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))),
|
||||
);
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const envelope = await parseEnvelope<T, unknown>(res);
|
||||
return (envelope === undefined ? undefined : envelope.data) as T;
|
||||
@@ -127,7 +133,9 @@ export async function apiFetchEnvelope<
|
||||
T = unknown,
|
||||
M = Record<string, unknown>,
|
||||
>(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> {
|
||||
const res = await fetch(path, withDefaultTimeout(init));
|
||||
const res = validateResponseContext(
|
||||
await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))),
|
||||
);
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const envelope = await parseEnvelope<T, M>(res);
|
||||
return {
|
||||
@@ -142,11 +150,20 @@ export async function apiFetchEnvelope<
|
||||
* Performs no `.ok` check, no envelope unwrap, and applies no default
|
||||
* timeout (SSE streams legitimately outlive any fixed window).
|
||||
*/
|
||||
export function apiFetchRaw(
|
||||
export async function apiFetchRaw(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(path, init);
|
||||
// The record-session assertion is attached HERE and not at the ~25 call
|
||||
// sites, several of which are record traffic (the Coach SSE stream, the
|
||||
// document inbound upload, blob exports). A call site that had to classify
|
||||
// itself would be wrong the first time a route changed mode.
|
||||
//
|
||||
// The echo is validated from headers only — the body is never touched, so an
|
||||
// SSE stream and a blob download reach their reader intact.
|
||||
return validateResponseContext(
|
||||
await fetch(path, withRecordFenceHeaders(init)),
|
||||
);
|
||||
}
|
||||
|
||||
function withJsonBody(
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* v1.37.0 — the browser's half of the record-session fence.
|
||||
*
|
||||
* Every same-origin API request says which record it believes it is addressing,
|
||||
* and every response that resolved a record scope says which one it was
|
||||
* actually served under. This module holds both halves: the adopted context,
|
||||
* the request headers built from it, and the response check.
|
||||
*
|
||||
* ─── Where the context comes from ─────────────────────────────────────────
|
||||
*
|
||||
* Exactly two responses may be ADOPTED from: `GET /api/auth/me` and
|
||||
* `POST /api/account/switch`. Both are actor surfaces that resolve canonical
|
||||
* session state, and both carry the context in the response BODY. Every other
|
||||
* response's echoed context is used to VALIDATE and never to adopt — which is
|
||||
* also why the 409 refusal carries only an error code and no epoch: a refusal
|
||||
* arriving on a request whose context is in doubt is the worst possible third
|
||||
* source of truth.
|
||||
*
|
||||
* ─── Why it is in memory only ─────────────────────────────────────────────
|
||||
*
|
||||
* Not mirrored to storage. `/api/auth/me` runs on every boot and is the single
|
||||
* bootstrap, so a storage mirror would be a second answer that can go stale
|
||||
* across a deploy — and the `bootstrap` sentinel already makes the
|
||||
* pre-adoption window safe by producing a 409 rather than a served request.
|
||||
*
|
||||
* ─── The validation rule is MISMATCH-ONLY ─────────────────────────────────
|
||||
*
|
||||
* A response is discarded only when it carries an echo that CONTRADICTS the
|
||||
* adopted context. A response carrying NO echo is returned untouched, because
|
||||
* absence means "this response resolved no record scope" — which is exactly
|
||||
* true for public routes, actor surfaces, admin, `/me` itself, and static
|
||||
* responses.
|
||||
*
|
||||
* The concrete thing a discard-on-absence rule breaks:
|
||||
* `src/components/version-poller.tsx` polls the PUBLIC `/api/version` through
|
||||
* `apiGet` on a timer. That response resolves no record and echoes nothing, so
|
||||
* a discard-on-absence client would enter the safe hold and fire a network-only
|
||||
* `/me` on every poll cycle, forever. Do not "tighten" this rule.
|
||||
*
|
||||
* The case mismatch-only appears to give up — a service-worker disk-cache hit
|
||||
* that predates the fence and therefore carries no echo — is closed by two
|
||||
* other things, and it is worth being exact about which one does what:
|
||||
*
|
||||
* * `public/sw.js` names its caches by the app release and its `activate`
|
||||
* handler deletes every `healthlog-(static|pages|data)-*` cache not in the
|
||||
* current set, so the deploy that ships the fence evicts every pre-fence
|
||||
* cached response before one can be served. Executed in
|
||||
* `e2e/v137-record-session-fence-offline.spec.ts`;
|
||||
* * a POST-fence cached record response is stored under a `Vary` on the two
|
||||
* fence headers (`attachRecordContextEcho`), so `cache.match()` will not
|
||||
* hand it to a request asserting a different context — or none. That is
|
||||
* what covers the EXTERNAL switch, where this browser never ran its own
|
||||
* cache wipe and the previous record's entries are still on disk. Also
|
||||
* executed in that spec, and it fails when the `Vary` is removed.
|
||||
*
|
||||
* The client-side discard below is the third layer, over responses that reach
|
||||
* it. It is not what protects the disk.
|
||||
*/
|
||||
|
||||
import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_FENCE_BOOTSTRAP,
|
||||
RECORD_SCOPE_HEADER,
|
||||
recordScopeHeaderValue,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
|
||||
/**
|
||||
* The context this browser has adopted, or null before `/api/auth/me` has told
|
||||
* it one.
|
||||
*
|
||||
* Imported from the contract module and NOT from `record-session-fence.ts` or
|
||||
* `acting-carrier.ts`: those pull `next/headers` and Prisma, which typechecks
|
||||
* cleanly in a client module and fails `pnpm build`.
|
||||
*/
|
||||
export interface AdoptedRecordContext {
|
||||
epoch: number;
|
||||
scope: string | null;
|
||||
}
|
||||
|
||||
let adopted: AdoptedRecordContext | null = null;
|
||||
|
||||
/** What a mismatching response should trigger. Installed by the app shell. */
|
||||
let onContextMismatch: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Adopt the context published by `GET /api/auth/me` or
|
||||
* `POST /api/account/switch`.
|
||||
*
|
||||
* `null` is a real value with a real meaning — the Bearer transport, or a
|
||||
* server image that predates the field — and it un-adopts, returning the client
|
||||
* to the `bootstrap` sentinel rather than leaving a stale context in place.
|
||||
*/
|
||||
export function adoptRecordFenceState(
|
||||
context: AdoptedRecordContext | null | undefined,
|
||||
): void {
|
||||
adopted = context ?? null;
|
||||
}
|
||||
|
||||
/** What this browser currently believes. Read by the capability gate. */
|
||||
export function currentRecordFenceState(): AdoptedRecordContext | null {
|
||||
return adopted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the handler a contradicting response triggers.
|
||||
*
|
||||
* A callback rather than a direct import of the transition store, so this
|
||||
* module stays a transport concern and the app shell owns what "hold and
|
||||
* reconcile" means. Returns a disposer.
|
||||
*/
|
||||
export function onRecordFenceMismatch(handler: () => void): () => void {
|
||||
onContextMismatch = handler;
|
||||
return () => {
|
||||
if (onContextMismatch === handler) onContextMismatch = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal — deterministic module state for unit tests. */
|
||||
export function __resetRecordFenceForTests(): void {
|
||||
adopted = null;
|
||||
onContextMismatch = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two header values to send on this request.
|
||||
*
|
||||
* Before adoption both are the literal `bootstrap`. That is a PRESENT header,
|
||||
* which is the whole point: the server's header-absent arm means exactly "a
|
||||
* bundle that predates the fence" and answers it with the 403 such a bundle
|
||||
* recovers from, so a fence-aware client must never reach it. On a session that
|
||||
* never entered a shared record the server's exemption serves `bootstrap`
|
||||
* normally, so an ordinary sign-in sees no refusal during boot.
|
||||
*/
|
||||
export function currentAssertion(): { epoch: string; scope: string } {
|
||||
if (adopted === null) {
|
||||
return { epoch: RECORD_FENCE_BOOTSTRAP, scope: RECORD_FENCE_BOOTSTRAP };
|
||||
}
|
||||
return {
|
||||
epoch: String(adopted.epoch),
|
||||
scope: recordScopeHeaderValue(adopted.scope),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the assertion to a request.
|
||||
*
|
||||
* Applied inside `apiFetch` / `apiFetchEnvelope` / `apiFetchRaw` rather than at
|
||||
* call sites, so no caller has to classify its own request as record traffic —
|
||||
* a classification that would be wrong the first time a route changed mode. A
|
||||
* caller that set the headers itself keeps them: an explicit value wins, the
|
||||
* same posture `withDefaultTimeout` takes for `signal`.
|
||||
*/
|
||||
export function withRecordFenceHeaders(init?: RequestInit): RequestInit {
|
||||
const headers = new Headers(init?.headers);
|
||||
const assertion = currentAssertion();
|
||||
if (!headers.has(RECORD_EPOCH_HEADER)) {
|
||||
headers.set(RECORD_EPOCH_HEADER, assertion.epoch);
|
||||
}
|
||||
if (!headers.has(RECORD_SCOPE_HEADER)) {
|
||||
headers.set(RECORD_SCOPE_HEADER, assertion.scope);
|
||||
}
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard a response served under a record context this browser is no longer
|
||||
* on.
|
||||
*
|
||||
* Returns the response untouched when it carries no echo, or when the echo
|
||||
* agrees. Throws when it contradicts — and triggers the safe hold on the way
|
||||
* out, so the caller's error path and the browser's reconciliation are one
|
||||
* event rather than two that can disagree.
|
||||
*
|
||||
* Read from headers only. Never consume the body here: `apiFetchRaw` hands the
|
||||
* response to SSE readers and blob downloads that need the stream intact.
|
||||
*/
|
||||
export function validateResponseContext(res: Response): Response {
|
||||
const echoedEpoch = res.headers.get(RECORD_EPOCH_HEADER);
|
||||
const echoedScope = res.headers.get(RECORD_SCOPE_HEADER);
|
||||
|
||||
// No echo: this response resolved no record scope. True, safe, and the
|
||||
// common case — see the version-poller note above.
|
||||
if (echoedEpoch === null || echoedScope === null) return res;
|
||||
|
||||
// Not adopted yet: there is nothing to contradict. The request itself
|
||||
// asserted `bootstrap`, so the server already decided whether to serve it.
|
||||
if (adopted === null) return res;
|
||||
|
||||
const expected = currentAssertion();
|
||||
if (echoedEpoch === expected.epoch && echoedScope === expected.scope) {
|
||||
return res;
|
||||
}
|
||||
|
||||
onContextMismatch?.();
|
||||
throw new RecordContextMismatchError();
|
||||
}
|
||||
|
||||
/**
|
||||
* A response arrived describing a record this browser has left.
|
||||
*
|
||||
* Deliberately not an `ApiError`: nothing about the HTTP exchange failed, and a
|
||||
* caller branching on `err.status` must not see a status that was never
|
||||
* refused. What happened is that the answer is about somebody else's record,
|
||||
* and the only safe thing to do with it is to drop it.
|
||||
*/
|
||||
export class RecordContextMismatchError extends Error {
|
||||
constructor() {
|
||||
super("Response describes a record this session has left");
|
||||
this.name = "RecordContextMismatchError";
|
||||
}
|
||||
}
|
||||
@@ -191,9 +191,33 @@ export async function getUnswitchedSession(): Promise<Awaited<
|
||||
* unswitched own-record request. The grant check happens later, inside the
|
||||
* handler, on every request.
|
||||
*/
|
||||
export async function readClaimedActingAccount(): Promise<
|
||||
string | null | undefined
|
||||
> {
|
||||
/**
|
||||
* v1.37.0 — and, on the same read, what that request's session actually holds.
|
||||
*
|
||||
* This replaced a narrower `readClaimedActingAccount()` that returned only the
|
||||
* claim. The idempotency wrapper — still the one caller — now needs a second
|
||||
* fact: the session's own record context, so the record-session fence can be
|
||||
* evaluated above the replay cache. Widening the existing function rather than
|
||||
* adding a second one is what keeps that from costing a second `getSession()`
|
||||
* on every idempotent write, and keeps "what does this request say it is acting
|
||||
* as" to one implementation, which is the thing this file exists to ensure.
|
||||
*
|
||||
* The narrow function is gone rather than kept as a shim: it had no caller left,
|
||||
* and a wrapper nobody calls is a second answer waiting for somebody to reach
|
||||
* for it.
|
||||
*/
|
||||
export interface ClaimedRecordContext {
|
||||
/** The claimed record, or `undefined` when the carrier was unsafe to resolve. */
|
||||
claimedRecord: string | null | undefined;
|
||||
/** Which credential the request authenticated with. */
|
||||
transport: "cookie" | "bearer";
|
||||
/** The session's selector-move counter. Always 0 on the Bearer transport. */
|
||||
sessionEpoch: number;
|
||||
/** The account the session is pointed at, or null. Always null on Bearer. */
|
||||
sessionScope: string | null;
|
||||
}
|
||||
|
||||
export async function readClaimedRecordContext(): Promise<ClaimedRecordContext> {
|
||||
const header = await readSelectorHeader();
|
||||
let session: Awaited<ReturnType<typeof getSession>> = null;
|
||||
try {
|
||||
@@ -204,11 +228,17 @@ export async function readClaimedActingAccount(): Promise<
|
||||
// unauthenticated request claims nothing.
|
||||
session = null;
|
||||
}
|
||||
const transport: "cookie" | "bearer" = session ? "cookie" : "bearer";
|
||||
const carrier = decideActingCarrier({
|
||||
transport: session ? "cookie" : "bearer",
|
||||
transport,
|
||||
stamped: session?.session.actingAsUserId ?? null,
|
||||
header,
|
||||
});
|
||||
if (carrier.kind === "misplaced-header") return undefined;
|
||||
return carrierTarget(carrier);
|
||||
return {
|
||||
claimedRecord:
|
||||
carrier.kind === "misplaced-header" ? undefined : carrierTarget(carrier),
|
||||
transport,
|
||||
sessionEpoch: session?.session.recordEpoch ?? 0,
|
||||
sessionScope: session?.session.actingAsUserId ?? null,
|
||||
};
|
||||
}
|
||||
+15
-1
@@ -246,7 +246,12 @@ export async function createSession(
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<{
|
||||
session: { id: string; expiresAt: Date; actingAsUserId: string | null };
|
||||
session: {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
actingAsUserId: string | null;
|
||||
recordEpoch: number;
|
||||
};
|
||||
user: User;
|
||||
} | null> {
|
||||
try {
|
||||
@@ -363,6 +368,15 @@ export async function getSession(): Promise<{
|
||||
// `src/__tests__/acting-account-boundary-guard.test.ts` freezes the set
|
||||
// of files allowed to touch this field.
|
||||
actingAsUserId: session.actingAsUserId,
|
||||
// v1.37.0 — the selector's move counter, projected off the same row for
|
||||
// the same reason. It is what makes the fence's central property true:
|
||||
// the epoch a request is validated against and the `actingAsUserId` the
|
||||
// handler is about to serve under are one row read, so they cannot
|
||||
// straddle a switch that landed between two queries.
|
||||
//
|
||||
// Written by a database trigger only (migration 0302); nothing in the
|
||||
// application assigns it, and the `BEFORE` timing means nothing can.
|
||||
recordEpoch: session.recordEpoch,
|
||||
},
|
||||
user: session.user,
|
||||
};
|
||||
|
||||
+25
-3
@@ -18,9 +18,10 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
readClaimedActingAccount,
|
||||
readClaimedRecordContext,
|
||||
selectorNamesAnAccount,
|
||||
} from "@/lib/auth/acting-carrier";
|
||||
import { refuseStaleRecordSessionClaim } from "@/lib/sharing/record-session-fence";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
@@ -327,7 +328,7 @@ export function isCachableStatus(status: number): boolean {
|
||||
* the handler again and creating duplicate measurements (audit C-4).
|
||||
*
|
||||
* It resolves the CALLER and only the caller — which record they are aiming at
|
||||
* is a separate question, answered by `readClaimedActingAccount()` above and
|
||||
* is a separate question, answered by `readClaimedRecordContext()` above and
|
||||
* folded into the key. Neither answer authorises anything: the handler runs
|
||||
* its own `requireAuth()` / `requireRecordAuth()` either way.
|
||||
*
|
||||
@@ -456,7 +457,28 @@ export function withIdempotency<
|
||||
// A claim only chooses the cache cell. It never carries authority: a
|
||||
// completed delegated cell is checked against the current grant again
|
||||
// immediately before its body can be returned below.
|
||||
const claimedRecord = await readClaimedActingAccount();
|
||||
const claim = await readClaimedRecordContext();
|
||||
|
||||
// v1.37.0 — the record-session fence, evaluated HERE and not one line
|
||||
// lower. A stale request must not learn whether a cell exists, must not
|
||||
// replay a body out of one, and must not insert a claim row — so the
|
||||
// verdict sits above `findCached` and above the two carrier arms below,
|
||||
// which choose the cell. It is the second, independent evaluation of the
|
||||
// same client assertion (the handler's own `requireRecordAuth` is the
|
||||
// other) and nothing is carried between them; see the note on
|
||||
// `refuseStaleRecordSessionClaim`.
|
||||
//
|
||||
// Only `stale` refuses here. An `unfenced-client` verdict falls through so
|
||||
// the route issues the 403 a pre-fence bundle already recovers from, with
|
||||
// the annotation and audit posture that belong to the route.
|
||||
//
|
||||
// A never-switched session and every Bearer request are untouched: both
|
||||
// pass the verdict without a header, which is what keeps every existing
|
||||
// own-record and delegated-cell contract byte-identical.
|
||||
const staleContext = await refuseStaleRecordSessionClaim(claim);
|
||||
if (staleContext) return staleContext;
|
||||
|
||||
const claimedRecord = claim.claimedRecord;
|
||||
if (claimedRecord === undefined) {
|
||||
// The carrier cannot safely name a record. In particular, a selector on
|
||||
// a cookie request is a misplaced claim, not an own-record request.
|
||||
|
||||
@@ -118,6 +118,25 @@ export class WideEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — record the record context this request resolved.
|
||||
*
|
||||
* Merges for the same reason `setActingAs` does. Written by exactly one
|
||||
* caller, the record-session fence, and read back by `apiHandler` to attach
|
||||
* the response echo. `apiHandler` deliberately does NOT derive the context
|
||||
* itself: doing so would put a session read on every public route and make
|
||||
* the echo a second derivation rather than a report of the one that was used.
|
||||
*/
|
||||
setRecordContext(context: { epoch: number; scope: string | null }): this {
|
||||
this.event.auth = { ...(this.event.auth ?? {}), record_context: context };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The record context the fence stamped, or undefined if it never ran. */
|
||||
getRecordContext(): { epoch: number; scope: string | null } | undefined {
|
||||
return this.event.auth?.record_context;
|
||||
}
|
||||
|
||||
setProviderWorkAuthority(authority: ProviderWorkAuthority): this {
|
||||
this.event.auth = {
|
||||
...(this.event.auth ?? {}),
|
||||
|
||||
@@ -80,6 +80,18 @@ export interface WideEvent {
|
||||
delegated_generation?: "suppressed";
|
||||
/** Durable authority copied into provider queue payloads. */
|
||||
provider_work_authority?: ProviderWorkAuthority;
|
||||
/**
|
||||
* v1.37.0 — the record context this request was actually served under, as
|
||||
* the record-session fence read it off the session row.
|
||||
*
|
||||
* Stamped by the fence on every call it makes, pass or refuse, and by
|
||||
* nothing else. `apiHandler` copies it onto two response headers so the
|
||||
* browser can discard a response that resolved a different record than the
|
||||
* one it is now showing. Present exactly on the responses that resolved a
|
||||
* record scope; absent everywhere else, which is what makes "no echo" mean
|
||||
* "nothing about a record was decided here" rather than "unknown".
|
||||
*/
|
||||
record_context?: { epoch: number; scope: string | null };
|
||||
};
|
||||
|
||||
// Business-Daten (was wurde getan)
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user