docs(api): publish the account-access block and the selector header

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 14:50:05 +02:00
1 parent 04d30c2570
commit 883fd2d047
7 files changed
+866 -29

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
/playwright-report
/playwright/.cache
/e2e/setup/storageState.json
/e2e/setup/storageStateOwner.json
# next.js
/.next/
+253 -27
View File
@@ -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
+280
View File
@@ -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<void> {
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<boolean> {
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<void> {
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");
});
});
+140
View File
@@ -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<string> {
return hash(password, {
memoryCost: 19456,
@@ -179,6 +212,77 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[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<void> {
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}`,
);
}
+8 -1
View File
@@ -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";
+175
View File
@@ -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<ZodOpenApiObject["components"]>["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<ZodOpenApiObject["paths"]> = {
"/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"],
+9 -1
View File
@@ -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<ZodOpenApiObject["components"]> = {
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",