From 883fd2d0474120441e497e650ae6b669464089cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 14:50:05 +0200 Subject: [PATCH] docs(api): publish the account-access block and the selector header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions to the contract, all of them things a native client cannot work out on its own. `GET /api/auth/me` enters the spec for the first time, and enters it loose: `accountAccess` is specified in full, the remaining preference fields are declared open rather than half-enumerated. The endpoint has never been in this document and its payload carries thirty-odd keys no contract has ever named; specifying a subset and closing the object would publish something the server does not keep. `X-HealthLog-Account` is published as a reusable parameter with no route referencing it, which is the honest state of the contract — the routes that read another account's record arrive with the surface migration and will reference it as they land. The header is live in the resolver today, and a client needs the description before it can be built against a tagged spec: the value comes from `accountAccess.accounts`, it is a selector and never a permission, and it must never ride the sync engine, device registration, or an auth call. `GET /api/account/activity` carries `retentionDays` in the response and says why: the window is operator-configurable, so a client that assumed 365 would tell somebody their record was never opened when the honest answer is "further back than this reaches". The parameter is defined in the sharing route module rather than the shared components block, so the boundary guard keeps holding the header's name to two files — the resolver that reads it, and the one place that publishes it. --- .gitignore | 1 + docs/api/openapi.yaml | 280 +++++++++++++++++++--- e2e/account-sharing.spec.ts | 280 ++++++++++++++++++++++ e2e/setup/global-setup.ts | 140 +++++++++++ e2e/setup/test-helpers.ts | 9 +- src/lib/openapi/routes/account-sharing.ts | 175 ++++++++++++++ src/lib/openapi/routes/index.ts | 10 +- 7 files changed, 866 insertions(+), 29 deletions(-) create mode 100644 e2e/account-sharing.spec.ts diff --git a/.gitignore b/.gitignore index 9299475b7..e3f55a90f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ /playwright-report /playwright/.cache /e2e/setup/storageState.json +/e2e/setup/storageStateOwner.json # next.js /.next/ diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0a47f82c4..82ed9e9f0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12432,6 +12432,61 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me: + get: + tags: + - Auth + summary: The signed-in account, and what sharing lets it do + description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose + record is open, and the way back out all read it, so a refusal here would strand a switched session. It is + therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches + `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that + wants a record's data sends the selector on the read that needs it, never on this call." + responses: + "200": + description: The account payload. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountPayloadEnvelope" + "401": *a1 + "403": + description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/account/activity: + get: + tags: + - Account sharing + summary: Who opened this record, and when + description: "Every audit row filed under this account that somebody else performed, newest first — one row per delegate + per day with a count, rather than one per request. `retentionDays` rides the response because audit history is + purged on a schedule the operator configures: a view that showed the rows without the window would imply a + completeness it does not have, and clients must state the number the server sends rather than assume the 365-day + default. Not delegable: a grant is not an introduction to the other people in the household." + responses: + "200": + description: The activity, and how far back it can see. + content: + application/json: + schema: + $ref: "#/components/schemas/RecordActivityEnvelope" + "401": *a1 + "403": &a20 + description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another + account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the + account they named. The latter is byte-identical for an account that does not exist and one that granted + nothing — the refusal is not an enumeration oracle. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/account/grants: get: tags: @@ -12446,15 +12501,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": &a20 - description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another - account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the - account they named. The latter is byte-identical for an account that does not exist and one that granted - nothing — the refusal is not an enumeration oracle. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a20 "422": *a3 "429": *a4 post: @@ -36558,6 +36605,186 @@ components: - data - error additionalProperties: false + AccountPayloadEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AccountPayload" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AccountPayload: + type: object + properties: + id: + type: string + username: + type: string + accountAccess: + $ref: "#/components/schemas/AccountAccess" + required: + - id + - username + - accountAccess + 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 + payload still describes the CALLER — their preferences, their modules, their identity — because display + preferences belong to the person at the keyboard rather than to the record they are reading." + AccountAccess: + type: object + properties: + accounts: + type: array + items: + $ref: "#/components/schemas/AccountAccessEntry" + description: Every record this caller may open, newest grant first. + active: + anyOf: + - $ref: "#/components/schemas/AccountAccessEntry" + - 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." + canSwitch: + type: boolean + description: Whether there is anywhere to switch to. Bind it directly. + required: + - accounts + - active + - canSwitch + additionalProperties: false + description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server + publishes what the caller may do and the client renders it. A client that computed either from grant data would + 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: + 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: + anyOf: + - type: string + - type: "null" + access: + type: string + enum: + - read + - write + canWrite: + type: boolean + description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; + never derive it. + required: + - accountId + - username + - displayName + - access + - canWrite + 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." + RecordActivityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RecordActivity" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RecordActivity: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/RecordActivityEntry" + retentionDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by + default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it + beside the rows. + required: + - entries + - retentionDays + additionalProperties: false + description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention. + RecordActivityEntry: + type: object + properties: + id: + type: string + actor: + anyOf: + - $ref: "#/components/schemas/AccountGrantParty" + - type: "null" + description: Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign + key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as + though the owner had done it themselves. + action: + type: string + accesses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: How many times that delegate opened the record on that day, for a coalesced read row. Null for anything + else — a single act has no count, and a 1 there would invite adding up numbers that mean different things. + createdAt: + type: string + required: + - id + - actor + - action + - accesses + - createdAt + additionalProperties: false + AccountGrantParty: + type: object + properties: + id: + type: string + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + required: + - id + - username + - displayName + additionalProperties: false + description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a + way to collect the other party's contact details." AccountGrantListEnvelope: type: object properties: @@ -36647,24 +36874,6 @@ 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." - AccountGrantParty: - type: object - properties: - id: - type: string - username: - type: string - displayName: - anyOf: - - type: string - - type: "null" - required: - - id - - username - - displayName - additionalProperties: false - description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a - way to collect the other party's contact details." AccountGrantState: type: string enum: @@ -37272,6 +37481,23 @@ components: - showEvidenceByDefault - defaultWindow additionalProperties: false + parameters: + AccountSelector: + name: X-HealthLog-Account + in: header + required: false + schema: + type: string + maxLength: 64 + description: "Act on another account's record for THIS request. Bearer transport only — the browser session carries its + switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value + is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the + grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a + selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to + one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the + sync engine, device or notification registration, or any auth/refresh call: those are about the person holding + the token, not the record being read, and the account payload refuses the header outright for exactly that + reason." securitySchemes: bearerAuth: type: http diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts new file mode 100644 index 000000000..437f9346d --- /dev/null +++ b/e2e/account-sharing.spec.ts @@ -0,0 +1,280 @@ +/** + * Account sharing, end to end: invite → accept → switch → banner → revoke. + * + * Two browser contexts, two real accounts, one real database. The owner + * invites, the delegate accepts and opens the record, and the owner takes it + * back — each step through the shipped UI rather than through the API, because + * the half of this feature that can go wrong is the half a person touches. + * + * ## The stale-paint check + * + * The assertion this spec exists for is a NEGATIVE one, and it is deliberately + * not "the new record's data arrives". A check that waits for the new data + * proves nothing about what was on screen while it was arriving, and the + * failure being guarded against is exactly that in-between frame: a dashboard + * hydrated from the previous record's IndexedDB snapshot, painted under a + * banner naming somebody else. + * + * So an init script installs a MutationObserver before any page script runs, + * on every navigation including the full reload the switch performs, and it + * latches the moment the delegate's own marker weight appears in the document. + * The spec then reads the latch. Nothing about it is sampled or timed. + * + * The observer's positive control runs first: with the same script installed, + * the delegate's own measurements page must trip the latch. Without that, "the + * marker never appeared" and "the observer never worked" are the same sentence. + * + * ## What this spec cannot cover, stated rather than implied + * + * - **The service worker.** The Playwright context runs with + * `serviceWorkers: "block"` (see `playwright.config.ts`), so the worker's + * `healthlog-data-*` cache — the other layer that survives a reload — is not + * exercised here at all. The switch wipes it through the same helper the + * IndexedDB snapshot goes through, and that helper is covered in + * `src/lib/pwa/__tests__/query-persister-shared-record.test.ts`. + * - **The owner's data appearing.** No route declares the delegable mode yet, + * so every read inside a shared record is refused by design. That is the + * current, correct state of the tree; the pages a delegate can open show + * their refusal state and, crucially, still show nothing of the delegate's + * own. The positive half of that assertion belongs with the route migration. + */ +import type { BrowserContext, Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + E2E_DELEGATE_MARKER_KG, + E2E_OWNER, + E2E_USER, + OWNER_STORAGE_STATE_PATH, + STORAGE_STATE_PATH, +} from "./setup/test-helpers"; + +const DELEGATE_MARKER = String(E2E_DELEGATE_MARKER_KG); + +/** + * Latch the first appearance of a string anywhere in the document, from before + * the first script of every page load. + * + * A MutationObserver rather than a poll: a poll samples, and the frame this is + * looking for can be one paint long. `characterData` is on because a hydration + * pass can fill an existing text node in place rather than inserting one. + */ +async function watchForText(page: Page, needle: string): Promise { + await page.addInitScript((text: string) => { + const w = window as unknown as { __sawText?: boolean }; + w.__sawText = false; + const seen = () => document.body?.innerText?.includes(text) ?? false; + const check = () => { + if (seen()) w.__sawText = true; + }; + const start = () => { + check(); + new MutationObserver(check).observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } + }, needle); +} + +async function sawText(page: Page): Promise { + return page.evaluate( + () => (window as unknown as { __sawText?: boolean }).__sawText === true, + ); +} + +/** Open the user menu and the switcher inside it. */ +async function openSwitcher(page: Page): Promise { + 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(); +} + +test.describe("account sharing", () => { + // One journey, in order: each step is the precondition of the next, so + // splitting them into independent tests would mean re-running the invitation + // three times against a rate-limited endpoint. + test.describe.configure({ mode: "serial" }); + + let ownerContext: BrowserContext; + let ownerPage: Page; + + test.beforeAll(async ({ browser }) => { + ownerContext = await browser.newContext({ + storageState: OWNER_STORAGE_STATE_PATH, + }); + ownerPage = await ownerContext.newPage(); + }); + + test.afterAll(async () => { + await ownerContext.close(); + }); + + test("the owner invites, and the invitation confers nothing yet", async () => { + await ownerPage.goto("/settings/access"); + + await ownerPage + .locator('[data-slot="grant-invite-identifier"]') + .fill(E2E_USER.username); + await ownerPage.locator('[data-slot="grant-invite-submit"]').click(); + + const row = ownerPage + .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') + .first(); + await expect(row).toBeVisible(); + // Pending, not active. The state is the server's answer, rendered. + await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + }); + + test("the delegate is offered nothing until they accept", async ({ + page, + }) => { + await page.goto("/settings/access"); + + const row = page + .locator('[data-slot="grants-received-list"] [data-slot="grant-row"]') + .first(); + await expect(row).toBeVisible(); + await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + + // No switcher yet: `accountAccess.canSwitch` is false while the only + // grant is pending, and the menu binds that boolean. + await page.getByRole("button", { name: "User menu" }).first().click(); + await expect( + page.locator('[data-slot="account-switcher-trigger"]'), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.locator('[data-slot="grant-accept"]').click(); + await expect(row).toHaveAttribute("data-grant-state", "ACTIVE"); + }); + + test("the switcher appears once the grant is live", async ({ page }) => { + await page.goto("/settings/access"); + await openSwitcher(page); + + const entry = page.locator('[data-slot="account-switcher-entry"]'); + await expect(entry).toHaveCount(1); + await expect(entry).toContainText(E2E_OWNER.username); + // The way home is in the same menu as the way in. + await expect( + page.locator('[data-slot="account-switcher-own"]'), + ).toBeVisible(); + }); + + test("switching shows the banner and never the delegate's own data", async ({ + page, + }) => { + await watchForText(page, DELEGATE_MARKER); + + // Positive control. If the observer cannot see the marker on the page + // that definitely shows it, its silence later means nothing. + await page.goto("/measurements"); + await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible(); + expect(await sawText(page)).toBe(true); + + // Let the offline persister flush the delegate's own dashboard to + // IndexedDB (1 s debounce). That snapshot is the stale-paint source this + // whole assertion is aimed at — without it the check would pass against a + // disk that was empty anyway. + await page.goto("/"); + await page.waitForTimeout(2000); + + await openSwitcher(page); + await page.locator('[data-slot="account-switcher-entry"]').click(); + + // Wait for CONTENT, and for content the assertion is not about: the + // banner naming the owner. A skeleton does not satisfy it, and it says + // nothing about the marker either way. + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(E2E_OWNER.username); + + // The latch was reset by the reload's init script, so this covers the + // whole life of the switched document. + expect(await sawText(page)).toBe(false); + + // And it stays false across a navigation inside the record. + await page.goto("/measurements"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + expect(await sawText(page)).toBe(false); + }); + + test("the nav offers only what sharing covers", async ({ page }) => { + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // The record itself is still there… + await expect(page.locator('a[href="/measurements"]').first()).toBeVisible(); + // …and everything that configures the account around it is not. + await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0); + await expect(page.locator('a[href="/coach"]')).toHaveCount(0); + await expect(page.locator('a[href="/insights"]')).toHaveCount(0); + }); + + test("a surface sharing does not cover says so", async ({ page }) => { + // Reached by bookmark: the nav entry is gone, the URL still resolves. + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable-leave"]'), + ).toBeVisible(); + }); + + test("revoking from the owner's browser drops the delegate out", async ({ + page, + }) => { + await page.goto("/"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // The owner ends it from their own session, in their own context. + await ownerPage.goto("/settings/access"); + await ownerPage.locator('[data-slot="grant-revoke"]').first().click(); + await ownerPage + .getByRole("button", { name: "End access", exact: true }) + .last() + .click(); + await expect( + ownerPage + .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') + .first(), + ).toHaveAttribute("data-grant-state", "REVOKED"); + + // The delegate's next navigation lands back in their own account rather + // than on a wall of refusals: the revoke cleared their acting session in + // the same transaction that stamped the row. + await page.goto("/measurements"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + // Their own record, back — which is also the proof that the page did not + // simply fail to load. + await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible(); + }); + + test("the ended grant stays on the record as history", async () => { + await ownerPage.goto("/settings/access"); + const row = ownerPage + .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') + .first(); + // Revocation never deletes. "Who had access, from when to when, and who + // ended it" is the question the panel exists to answer. + await expect(row).toHaveAttribute("data-grant-state", "REVOKED"); + await expect(row).toContainText("You ended it"); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 247df5c4f..cba818515 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -50,6 +50,39 @@ export const E2E_USER = { role: "ADMIN", } as const; +/** + * v1.36.0 — the SECOND account, so account sharing has two sides to test. + * + * Sharing is the one feature in the product that cannot be exercised by one + * person: somebody has to own a record and somebody else has to be given it. + * This account owns the record; `E2E_USER` is the delegate throughout the + * sharing spec. + */ +export const E2E_OWNER = { + email: "e2e-owner@healthlog.test", + username: "e2e-owner", + password: "Qv7bR2n!Ks9wLd4T", + role: "USER", +} 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 + * writes. + * + * The delegate's number is the whole point of the stale-paint check: after + * switching into the owner's record it must appear NOWHERE, at no moment. A + * spec that only waited for the owner's data to arrive would prove nothing + * about what was on screen in between. + */ +export const E2E_DELEGATE_MARKER_KG = 77.7; +export const E2E_OWNER_MARKER_KG = 63.3; + +export const OWNER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateOwner.json", +); + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -179,6 +212,77 @@ export default async function globalSetup(config: FullConfig): Promise { [cuid(), now, E2E_USER.username], ); + // ── v1.36.0 — the sharing fixture ──────────────────────────────────── + // + // A second account, one distinctive weight in each record, and a clean + // grant table. The reset matters as much as the seed: grants and switch + // stamps outlive a run, and a second run starting with the delegate + // already inside the owner's record would skip the invitation half of + // the journey while still going green. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, + created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + 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`, + [ + cuid(), + E2E_OWNER.username, + E2E_OWNER.email, + await hashPassword(E2E_OWNER.password), + now, + ], + ); + + // Every grant between the pair, and any switch stamp either of them is + // carrying. Deleted rather than revoked: this is fixture hygiene, not a + // lifecycle transition, and a revoked row would still show in the panel. + await pool.query( + `DELETE FROM account_grants + WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1)) + OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`, + [[E2E_USER.username, E2E_OWNER.username]], + ); + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL + WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`, + [[E2E_USER.username, E2E_OWNER.username]], + ); + await pool.query( + `DELETE FROM rate_limits + WHERE key LIKE 'sharing:%'`, + ); + + // One marker weight per record. Re-seeded by delete-then-insert so a + // re-run cannot accumulate duplicates and so an edited constant takes + // effect rather than sitting beside the old value. + for (const [username, kg] of [ + [E2E_USER.username, E2E_DELEGATE_MARKER_KG], + [E2E_OWNER.username, E2E_OWNER_MARKER_KG], + ] as const) { + await pool.query( + `DELETE FROM measurements + WHERE user_id = (SELECT id FROM users WHERE username = $1) + AND external_id = 'e2e-sharing-marker'`, + [username], + ); + await pool.query( + `INSERT INTO measurements + (id, user_id, type, value, unit, source, measured_at, + external_id, created_at, updated_at) + SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3, + 'e2e-sharing-marker', $3, $3 + FROM users u WHERE u.username = $4`, + [cuid(), kg, now, username], + ); + } + // 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 @@ -246,4 +350,40 @@ export default async function globalSetup(config: FullConfig): Promise { console.log( `[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`, ); + + // v1.36.0 — the owner's cookie jar, captured the same way and for the same + // reason: the login endpoint is IP-rate-limited and the sharing spec needs + // both sides of a grant signed in at once. + const ownerCtx = await playwrightRequest.newContext({ baseURL }); + const ownerRes = await ownerCtx.post("/api/auth/login", { + data: { email: E2E_OWNER.username, password: E2E_OWNER.password }, + }); + if (ownerRes.status() !== 200) { + const body = await ownerRes.text(); + throw new Error( + `[e2e/global-setup] owner login failed: HTTP ${ownerRes.status()} — ${body.slice(0, 200)}`, + ); + } + const ownerState = await ownerCtx.storageState(); + ownerState.cookies = ownerState.cookies.filter( + (c) => c.name === "healthlog_session" || c.name === "hl_onboarding", + ); + ownerState.cookies.push({ + name: "healthlog-locale", + value: "en", + domain: base.hostname, + path: "/", + expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, + httpOnly: false, + secure: false, + sameSite: "Lax", + }); + await writeFile( + OWNER_STORAGE_STATE_PATH, + JSON.stringify(ownerState, null, 2), + ); + await ownerCtx.dispose(); + console.log( + `[e2e/global-setup] owner auth state captured to ${OWNER_STORAGE_STATE_PATH}`, + ); } diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index d6af56eb8..35425aa10 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -6,4 +6,11 @@ * specific to the spec-side (e.g. multi-user scenarios that need * fresh logins) have a natural home. */ -export { E2E_USER, STORAGE_STATE_PATH } from "./global-setup"; +export { + E2E_USER, + E2E_OWNER, + E2E_DELEGATE_MARKER_KG, + E2E_OWNER_MARKER_KG, + STORAGE_STATE_PATH, + OWNER_STORAGE_STATE_PATH, +} from "./global-setup"; diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 61e705fde..d8224ce36 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -121,13 +121,188 @@ const switchResponse = z "The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login.", }); +const recordActivityEntry = z + .object({ + id: z.string(), + actor: grantParty + .nullable() + .describe( + "Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as though the owner had done it themselves.", + ), + action: z.string(), + accesses: z + .number() + .int() + .nullable() + .describe( + "How many times that delegate opened the record on that day, for a coalesced read row. Null for anything else — a single act has no count, and a 1 there would invite adding up numbers that mean different things.", + ), + createdAt: z.string(), + }) + .meta({ id: "RecordActivityEntry" }); + +const recordActivityResponse = z + .object({ + entries: z.array(recordActivityEntry), + retentionDays: z + .number() + .int() + .describe( + "How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it beside the rows.", + ), + }) + .meta({ + id: "RecordActivity", + description: + "The owner's record of what somebody else did in their record, bounded by the instance's audit retention.", + }); + const sharingRefusal = { description: "Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted nothing — the refusal is not an enumeration oracle.", content: { "application/json": { schema: errorEnvelope } }, }; +/** + * The per-request account selector, published once. + * + * No route entry references it yet, and that is the honest state of the + * contract: the routes that read a record other than the caller's own arrive + * with the delegable-surface migration, and each one will `$ref` this + * parameter as it lands. Publishing the selector now is what lets a native + * client be built against a tagged spec rather than against a description of + * one — the header is live in the resolver today, and a request that sends it + * to a route which has not declared the mode is refused rather than quietly + * served the caller's own data. + * + * Defined here rather than in the components block so the header's name stays + * in two files, which `src/__tests__/acting-account-boundary-guard.test.ts` + * holds it to: the resolver that reads it, and this one that publishes it. + */ +export const accountSelectorParameter: NonNullable< + NonNullable["parameters"] +>[string] = { + name: "X-HealthLog-Account", + in: "header", + required: false, + schema: { type: "string", maxLength: 64 }, + description: + "Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason.", +}; + +// ── What the client is told it may do ──────────────────────────────────────── + +const accountAccessEntry = z + .object({ + accountId: z + .string() + .describe( + "The account whose record it is — the value POST /api/account/switch takes, and the value the X-HealthLog-Account header carries.", + ), + username: z.string(), + displayName: z.string().nullable(), + access: z.enum(["read", "write"]), + canWrite: z + .boolean() + .describe( + "Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; never derive it.", + ), + }) + .meta({ + id: "AccountAccessEntry", + 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.", + }); + +export const accountAccessBlock = z + .object({ + accounts: z + .array(accountAccessEntry) + .describe("Every record this caller may open, newest grant first."), + active: accountAccessEntry + .nullable() + .describe( + "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.", + ), + canSwitch: z + .boolean() + .describe("Whether there is anywhere to switch to. Bind it directly."), + }) + .meta({ + id: "AccountAccess", + description: + "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would 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.", + }); + +/** + * The account payload, documented for the one field this release adds. + * + * Loose on purpose. `GET /api/auth/me` has never been in this spec, and its + * remaining fields are the account's own preference envelope — thirty-odd keys + * that no contract has ever enumerated. Specifying `accountAccess` and + * declaring the rest open is honest; enumerating a subset and closing the + * object would publish a contract the server does not keep. + */ +const accountPayload = z + .looseObject({ + id: z.string(), + username: z.string(), + accountAccess: accountAccessBlock, + }) + .meta({ + id: "AccountPayload", + 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 payload still describes the CALLER — their preferences, their modules, their identity — because display preferences belong to the person at the keyboard rather than to the record they are reading.", + }); + export const accountSharingPaths: NonNullable = { + "/api/auth/me": { + get: { + tags: ["Auth"], + summary: "The signed-in account, and what sharing lets it do", + description: + "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.", + responses: { + ...stdResponses, + "200": { + description: "The account payload.", + content: { + "application/json": { + schema: dataEnvelope(accountPayload, "AccountPayloadEnvelope"), + }, + }, + }, + "403": { + description: + "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/account/activity": { + get: { + tags: ["Account sharing"], + summary: "Who opened this record, and when", + description: + "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Not delegable: a grant is not an introduction to the other people in the household.", + responses: { + ...stdResponses, + "200": { + description: "The activity, and how far back it can see.", + content: { + "application/json": { + schema: dataEnvelope( + recordActivityResponse, + "RecordActivityEnvelope", + ), + }, + }, + }, + "403": sharingRefusal, + }, + }, + }, "/api/account/grants": { get: { tags: ["Account sharing"], diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 6e09c83bf..c64c16678 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -21,7 +21,10 @@ */ import type { ZodOpenApiObject } from "zod-openapi"; -import { accountSharingPaths } from "./account-sharing"; +import { + accountSelectorParameter, + accountSharingPaths, +} from "./account-sharing"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { allergyPaths } from "./allergies"; import { authPaths } from "./auth"; @@ -149,6 +152,11 @@ export const openApiComponents: NonNullable = { medicationDoseHistoryImportFatalReasonEnum, CoachPrefs: coachPrefsSchema, }, + // v1.36.0 — the per-request account selector, defined in the sharing route + // module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps + // holding the header's name to two files: the resolver that reads it, and + // the one place that publishes it. + parameters: { AccountSelector: accountSelectorParameter }, securitySchemes: { bearerAuth: { type: "http",