Merge commit '38214e472' into work/v137-record-session-fence

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 17:10:56 +02:00
commit a08e82d0be
81 files changed
+3505 -386

No files matched your search

+2 -4
View File
@@ -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/
+234 -1
View File
@@ -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,16 @@ 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",
);
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
@@ -201,6 +266,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 +503,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
@@ -345,6 +567,17 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// 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);
await captureAuthState(
baseURL,
E2E_SCOPE_DELEGATE,
SCOPE_DELEGATE_STORAGE_STATE_PATH,
);
await captureAuthState(
baseURL,
E2E_SCOPE_DELEGATE,
SCOPE_A11Y_STORAGE_STATE_PATH,
);
}
/**
+5
View File
@@ -9,9 +9,14 @@
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,
} from "./global-setup";
+116
View File
@@ -0,0 +1,116 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { SCOPE_DELEGATE_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: SCOPE_DELEGATE_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();
}
});
});
+78 -6
View File
@@ -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();
});
});
+462 -5
View File
@@ -1,11 +1,468 @@
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");
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);
await page.reload();
const payload = await page.evaluate(async () => {
const response = await fetch("/api/measurements");
return { status: response.status, body: await response.json() };
});
expect(payload.status).toBeLessThan(300);
expect(JSON.stringify(payload.body)).toContain("124");
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 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 () => {
const response = await fetch("/api/dashboard/summary");
return response.status;
});
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);
});
});
+1
View File
@@ -341,6 +341,7 @@
},
"cycle": "Zyklus",
"labs": "Laborwerte",
"profile": "Profil",
"vorsorge": "Vorsorge",
"illness": "Krankheit",
"documents": "Dokumente",
+1
View File
@@ -341,6 +341,7 @@
},
"cycle": "Cycle",
"labs": "Labs",
"profile": "Profile",
"vorsorge": "Checkups",
"illness": "Illness",
"documents": "Documents",
+1
View File
@@ -341,6 +341,7 @@
},
"cycle": "Ciclo",
"labs": "Análisis",
"profile": "Perfil",
"vorsorge": "Revisiones",
"illness": "Enfermedad",
"documents": "Documentos",
+1
View File
@@ -341,6 +341,7 @@
},
"cycle": "Cycle",
"labs": "Analyses",
"profile": "Profil",
"vorsorge": "Examens",
"illness": "Maladie",
"documents": "Documents",
+1
View File
@@ -341,6 +341,7 @@
},
"cycle": "Ciclo",
"labs": "Esami",
"profile": "Profilo",
"vorsorge": "Controlli",
"illness": "Malattia",
"documents": "Documenti",
+1
View File
@@ -341,6 +341,7 @@
},
"cycle": "Cykl",
"labs": "Wyniki badań",
"profile": "Profil",
"vorsorge": "Badania",
"illness": "Choroba",
"documents": "Dokumenty",
+8
View File
@@ -83,6 +83,14 @@ export default defineConfig({
},
{
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",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
// `playwright install chromium` instead of also pulling
@@ -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,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" });
});
});
+16 -2
View File
@@ -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/);
}
});
});
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+26
View File
@@ -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 }),
}));
+9 -3
View File
@@ -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
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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
View File
@@ -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" }),
+3 -2
View File
@@ -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();
+3 -1
View File
@@ -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;
+2 -2
View File
@@ -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
+5
View File
@@ -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&#x27;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],
+61 -36
View File
@@ -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");
}
+7 -3
View File
@@ -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
+2 -1
View File
@@ -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,
);
});
});
+17
View File
@@ -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>({
@@ -51,6 +51,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 +69,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 +153,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 +190,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 +240,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],
@@ -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");
});
});
@@ -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>", () => {
+57 -25
View File
@@ -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">&#10003;</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");
}
+37 -22
View File
@@ -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,17 +234,16 @@ 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"))) {
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>
);
// 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
@@ -249,15 +257,22 @@ 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.
// 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
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>
);
}
// Onboarding page — minimal shell, no sidebar/nav
if (isOnboardingPage) {
+10 -5
View File
@@ -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) {
+36 -8
View File
@@ -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>
);
}
+33 -16
View File
@@ -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");
}
+5 -3
View File
@@ -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)
+117 -10
View File
@@ -22,9 +22,22 @@ 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 { 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 +181,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 +243,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 +256,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 +276,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 +294,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 +385,7 @@ export function Providers({
<ThemeProvider>
<I18nProvider initialLocale={initialLocale}>
<QueryPersistenceBridge />
<RecordSessionTransitionBridge />
<QueryVisibilityRefreshBridge />
<DashboardSnapshotPreloader />
<OfflineMutationToaster />
@@ -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");
});
});
+146
View File
@@ -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}"`);
@@ -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`)}
+23 -3
View File
@@ -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);
},
);
});
@@ -72,6 +72,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,
+68 -24
View File
@@ -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
@@ -38,7 +40,11 @@ import { ApiError, apiPost } from "@/lib/api/api-fetch";
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
@@ -96,16 +102,15 @@ async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
}
/**
* Complete the switch: stamp the cache scope, empty the disk layers, reload.
* 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 +129,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 postSwitch(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 +167,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 +184,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 +221,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);
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]);
}
+55 -25
View File
@@ -23,8 +23,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 +46,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 +203,13 @@ 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;
}
/**
@@ -252,7 +270,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 +317,22 @@ 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);
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 +360,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() {
+26 -1
View File
@@ -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,22 @@ export interface RecordCapabilities {
*/
export function resolveRecordCapabilities(
active: AccountAccessEntry | null | undefined,
accessRefused = false,
recordSessionPending = false,
): RecordCapabilities {
if (accessRefused || recordSessionPending) {
return {
accessRefused: accessRefused || undefined,
recordSessionPending: recordSessionPending || undefined,
inSharedRecord: true,
canWrite: false,
canAdd: false,
canManage: false,
level: null,
sections: [],
recordKind: "shared",
};
}
if (!active) {
return {
inSharedRecord: false,
@@ -156,7 +176,12 @@ export function resolveRecordCapabilities(
*/
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",
);
}
/**
@@ -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,
);
}
+45
View File
@@ -0,0 +1,45 @@
import type {
AccountAccessEntry,
AccountRecordKind,
} from "@/lib/sharing/account-access-view";
export type RecordAccessPresentation = "view" | "view-and-add" | "manage";
export interface RecordPresentation {
access: RecordAccessPresentation;
recordKind: AccountRecordKind;
}
/**
* The server publishes both a resolved `canWrite` decision and the canonical
* three-level label. Chrome binds those values here without treating either as
* an authorization input: a handler still resolves the active grant afresh.
*/
export function resolveRecordPresentation(
entry: Pick<AccountAccessEntry, "canWrite" | "level" | "recordKind">,
): RecordPresentation {
const validRecordKind =
entry.recordKind === "shared" || entry.recordKind === "managed";
const validAccess =
(entry.level === "read" && entry.canWrite === false) ||
((entry.level === "write" || entry.level === "manage") &&
entry.canWrite === true);
// `parseAccountAccess` normally makes this branch unreachable. Keep the
// presentation boundary defensive anyway: a stale cache or an untyped
// consumer must never turn contradictory data into a stronger affordance or
// claim that the browser is inside somebody else's record.
if (!validRecordKind || !validAccess) {
return { access: "view", recordKind: "self" };
}
return {
access:
entry.level === "manage"
? "manage"
: entry.canWrite
? "view-and-add"
: "view",
recordKind: entry.recordKind,
};
}
+108
View File
@@ -0,0 +1,108 @@
import {
ENTIRE_RECORD,
SHARE_DOMAINS,
type ShareDomain,
type ShareScope,
} from "@/lib/sharing/scope";
/**
* The browser route families a scoped grant can present.
*
* This is deliberately a presentation inventory, not an authorization table.
* `sections` arrives here as the server-resolved value from `accountAccess`;
* the request handler still evaluates the active grant for every read and
* mutation. Keeping the browser's route families closed lets a new domain
* fail closed in chrome until it has been intentionally classified.
*/
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
measurements: ["/measurements"],
medications: ["/medications"],
labs: ["/labs"],
profile: ["/profile"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
documents: ["/documents"],
} as const satisfies Record<ShareDomain, readonly `/${string}`[]>;
/**
* Routes whose reads span multiple domains. A selected-domain grant never
* opens these, even when it happens to name every currently known domain.
*/
const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const;
interface RouteFamily {
href: string;
scope: ShareScope;
}
const SHARED_RECORD_ROUTE_FAMILIES: readonly RouteFamily[] = [
...WHOLE_RECORD_ROUTE_FAMILIES.map((href): RouteFamily => ({
href,
scope: ENTIRE_RECORD,
})),
...SHARE_DOMAINS.flatMap((domain): RouteFamily[] =>
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href): RouteFamily => ({
href,
scope: domain,
})),
),
];
function matchesRouteFamily(pathname: string, href: string): boolean {
return href === "/"
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`);
}
function includesScope(
sections: readonly ShareDomain[] | null,
scope: ShareScope,
): boolean {
if (sections === null) return true;
return scope !== ENTIRE_RECORD && sections.includes(scope);
}
/**
* Server-resolved navigation paint for the active shared record.
*
* The return value never makes an authorization decision. It only prevents a
* known-unavailable page from mounting its reads after the server has already
* said which record sections the browser is inside.
*/
export function resolveSharedRecordNavigation(
sections: readonly ShareDomain[] | null,
) {
const routeFamilies = SHARED_RECORD_ROUTE_FAMILIES.filter((family) =>
includesScope(sections, family.scope),
);
return {
destinationHrefs: routeFamilies.map((family) => family.href),
allowsPath(pathname: string): boolean {
return routeFamilies.some((family) =>
matchesRouteFamily(pathname, family.href),
);
},
};
}
/** The first scope-presentable location to load after a record switch. */
export function sharedRecordLandingHref(
sections: readonly ShareDomain[] | null,
): string {
return resolveSharedRecordNavigation(sections).destinationHrefs[0] ?? "/";
}
/**
* Does the server-resolved scope make this browser route presentable?
*
* This helper is intentionally named for presentation. It must never be used
* by a route handler or to choose an API request's target record.
*/
export function isSharedRecordPathPresentable(
pathname: string,
sections: readonly ShareDomain[] | null,
): boolean {
return resolveSharedRecordNavigation(sections).allowsPath(pathname);
}
+6 -66
View File
@@ -23,6 +23,7 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { accountAccessBlockSchema } from "@/lib/sharing/account-access-schema";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import {
inviteGrantSchema,
@@ -210,72 +211,11 @@ export const accountSelectorParameter: NonNullable<
// ── 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"])
.describe(
"The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads canonical `level`.",
),
level: z
.enum(["read", "write", "manage"])
.describe(
"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: z
.enum(["self", "shared", "managed"])
.describe(
"Server-resolved presentation metadata for the record. It is never accepted as an authorization input.",
),
sections: z
.array(shareSection)
.nullable()
.describe(
"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: z
.boolean()
.describe(
"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.",
),
})
.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.",
),
recordKind: z
.enum(["self", "shared", "managed"])
.describe(
"The server-resolved kind of the record currently in view. `self` applies when `active` is null.",
),
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.",
});
export const accountAccessBlock = accountAccessBlockSchema.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.
+2 -1
View File
@@ -172,6 +172,7 @@ function currentUserId(queryClient: QueryClient): string | null {
export async function restorePersistedQueryCache(
queryClient: QueryClient,
buildVersion: string,
canHydrate: () => boolean = () => true,
): Promise<void> {
if (typeof indexedDB === "undefined") return;
// v1.36.0 — a browser inside somebody else's record restores nothing. The
@@ -219,7 +220,7 @@ export async function restorePersistedQueryCache(
(q) => cache.get(q.queryHash) === undefined,
),
};
if (filtered.queries.length === 0) return;
if (filtered.queries.length === 0 || !canHydrate()) return;
hydrate(queryClient, filtered);
// Restored queries carry their original `dataUpdatedAt`, so with the
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
import { queryKeys } from "@/lib/query-keys";
import {
__resetRecordScopeForTests,
setRecordScope,
} from "@/lib/query-keys/record-scope";
import { prefetchMedicationsList } from "../prefetch-medications";
function envelopeResponse(data: unknown): Response {
return new Response(JSON.stringify({ data, error: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
afterEach(() => {
__resetRecordScopeForTests();
vi.unstubAllGlobals();
});
describe("prefetchMedicationsList", () => {
it("does not park an aborted shared-record preload in the own-record cache", async () => {
setRecordScope("shared-record");
const controller = new AbortController();
const fetchMock = vi.fn(
(_path: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
prefetchMedicationsList(client, controller.signal);
controller.abort();
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
setRecordScope(null);
expect(client.getQueryData(queryKeys.medications())).toBeUndefined();
expect(
client.getQueryData(queryKeys.medicationComplianceSummary()),
).toBeUndefined();
});
it("passes a lifecycle signal to both record reads", async () => {
const controller = new AbortController();
const fetchMock = vi.fn().mockResolvedValue(envelopeResponse([]));
vi.stubGlobal("fetch", fetchMock);
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
prefetchMedicationsList(client, controller.signal);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
signal: controller.signal,
});
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
signal: controller.signal,
});
});
});
@@ -26,7 +26,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { I18nProvider } from "@/lib/i18n/context";
import { useAchievementsQuery } from "../use-achievements-query";
import {
canFetchAchievements,
useAchievementsQuery,
} from "../use-achievements-query";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
@@ -90,6 +93,13 @@ function withQueryClient(node: ReactNode): string {
}
describe("useAchievementsQuery — consumer collapse", () => {
it("does not start the whole-record poll in a narrowed shared record", () => {
expect(canFetchAchievements({ sections: ["profile"] })).toBe(false);
expect(canFetchAchievements({ sections: [] })).toBe(false);
expect(canFetchAchievements({ sections: null })).toBe(true);
expect(canFetchAchievements(null)).toBe(true);
});
it("fires a single network call when three consumers mount together", async () => {
withQueryClient(
<>
@@ -43,6 +43,10 @@ import {
} from "../use-dashboard-snapshot";
import { retryOnceOnTransientError } from "../retry-transient";
import { queryKeys } from "@/lib/query-keys";
import {
__resetRecordScopeForTests,
setRecordScope,
} from "@/lib/query-keys/record-scope";
import type { QueryClient } from "@tanstack/react-query";
/** Envelope-shaped Response — the snapshot fetch rides `apiGet` now. */
@@ -58,6 +62,7 @@ afterEach(() => {
getQueryDataMock.mockReset();
setQueryDataMock.mockClear();
_resetDashboardSnapshotPreloadForTests();
__resetRecordScopeForTests();
vi.unstubAllGlobals();
});
@@ -257,6 +262,32 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
expect(snap.layout).toEqual(layout);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("drops a parked shared-record response after the record scope changes", async () => {
setRecordScope("shared-record");
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
envelopeResponse({ layout: { owner: true }, tiles: {} }),
)
.mockResolvedValueOnce(
envelopeResponse({ layout: { owner: false }, tiles: {} }),
);
vi.stubGlobal("fetch", fetchMock);
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
prefetchDashboardSnapshot(fakeQueryClient());
setRecordScope(null);
useDashboardSnapshot();
const opts = lastOptsOf(useQueryMock);
const snapshot = await (
opts.queryFn as () => Promise<{ layout: unknown }>
)();
expect(snapshot.layout).toEqual({ owner: false });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
function lastOptsOf(mock: typeof useQueryMock): Record<string, unknown> {
+6 -3
View File
@@ -29,15 +29,18 @@ import { apiGet } from "@/lib/api/api-fetch";
*/
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
export function prefetchMedicationsList(queryClient: QueryClient): void {
export function prefetchMedicationsList(
queryClient: QueryClient,
signal?: AbortSignal,
): void {
void queryClient.prefetchQuery({
queryKey: queryKeys.medications(),
queryFn: () => apiGet("/api/medications"),
queryFn: () => apiGet("/api/medications", { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
void queryClient.prefetchQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: () => apiGet("/api/medications/compliance"),
queryFn: () => apiGet("/api/medications/compliance", { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
}
+15 -2
View File
@@ -5,6 +5,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
import type {
AchievementMetrics,
AchievementProgress,
@@ -70,6 +71,17 @@ function fetchAchievements(): Promise<AchievementsPayload> {
return apiGet<AchievementsPayload>("/api/gamification/achievements");
}
/**
* Achievements aggregate more than one record section, so a narrowed grant
* never reaches the endpoint. Keep its shell-level poll dormant instead of
* turning a correctly refused background read into an apparent grant loss.
*/
export function canFetchAchievements(
active: Pick<AccountAccessEntry, "sections"> | null | undefined,
): boolean {
return active === null || active === undefined || active.sections === null;
}
/**
* Shared hook. Returns the unwrapped `data` payload typed as
* `AchievementsPayload`. Consumers narrow at the call site.
@@ -77,9 +89,10 @@ function fetchAchievements(): Promise<AchievementsPayload> {
export function useAchievementsQuery(
options: UseAchievementsQueryOptions = {},
): UseQueryResult<AchievementsPayload, Error> {
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
const enabled =
options.enabled !== undefined ? options.enabled : isAuthenticated;
(options.enabled !== undefined ? options.enabled : isAuthenticated) &&
canFetchAchievements(user?.accountAccess?.active);
const refetchInterval = options.refetchInterval ?? false;
return useQuery({
+27 -7
View File
@@ -40,16 +40,19 @@ import {
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { getRecordScope } from "@/lib/query-keys/record-scope";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
async function fetchDashboardSnapshot(
signal?: AbortSignal,
): Promise<DashboardSnapshot> {
// Routed through the typed wrapper so a failure carries its HTTP
// status (`ApiError`) for the transient-retry predicate, and the
// default 15 s timeout keeps a stalled response from pinning the
// whole dashboard on its skeleton.
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot");
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot", { signal });
}
/** Handoff freshness window — mirrors the hook's `staleTime`. */
@@ -68,12 +71,18 @@ const PRELOAD_MAX_AGE_MS = 60_000;
let preloadedSnapshot: {
promise: Promise<DashboardSnapshot>;
startedAt: number;
recordScope: string | null;
} | null = null;
export function _resetDashboardSnapshotPreloadForTests(): void {
preloadedSnapshot = null;
}
/** Drop a handoff when a tab begins or receives a record-session switch. */
export function discardDashboardSnapshotPreload(): void {
preloadedSnapshot = null;
}
/**
* Kick the snapshot fetch off ahead of the dashboard page chunk.
*
@@ -88,7 +97,10 @@ export function _resetDashboardSnapshotPreloadForTests(): void {
* `queryFn` consumes it; preload errors are swallowed by design — the
* mounted cell owns error surfacing with its own fresh attempt.
*/
export function prefetchDashboardSnapshot(queryClient: QueryClient) {
export function prefetchDashboardSnapshot(
queryClient: QueryClient,
signal?: AbortSignal,
) {
// Expired handoff from a previous visit — drop it so a long-idle slot
// can never serve stale data.
if (
@@ -128,22 +140,30 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) {
if (freshest > 0) {
return;
}
const promise = fetchDashboardSnapshot();
const recordScope = getRecordScope();
const promise = fetchDashboardSnapshot(signal);
promise.catch(() => {
// Swallow + clear so an unconsumed failed preload (racing 401 on
// login, transient network) never becomes an unhandled rejection and
// never gets handed to a later mount.
if (preloadedSnapshot?.promise === promise) preloadedSnapshot = null;
});
preloadedSnapshot = { promise, startedAt: Date.now() };
preloadedSnapshot = { promise, startedAt: Date.now(), recordScope };
}
/** Single-use consume of the preload handoff; `null` when absent/expired. */
function takePreloadedSnapshot(): Promise<DashboardSnapshot> | null {
if (!preloadedSnapshot) return null;
const { promise, startedAt } = preloadedSnapshot;
const { promise, startedAt, recordScope } = preloadedSnapshot;
preloadedSnapshot = null;
return Date.now() - startedAt < PRELOAD_MAX_AGE_MS ? promise : null;
// A handoff is not query-cached, so it needs the same record boundary as a
// query cache entry. A scope move while it is in flight must make the next
// mounted dashboard fetch afresh instead of adopting a response from the
// record that just ended.
return Date.now() - startedAt < PRELOAD_MAX_AGE_MS &&
recordScope === getRecordScope()
? promise
: null;
}
function makeSnapshotQueryFn(queryClient: QueryClient) {
@@ -21,6 +21,7 @@ import {
getRecordScope,
isReadingSharedRecord,
recordScopedQueryKeyHashFn,
setRefusedRecordScope,
setRecordScope,
} from "@/lib/query-keys/record-scope";
@@ -101,6 +102,24 @@ describe("the cache cannot serve one record's data while reading another", () =>
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
});
it("never puts data from a refused record into the caller's own cache slot", () => {
const client = appClient();
client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 });
setRefusedRecordScope();
expect(isReadingSharedRecord()).toBe(true);
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
});
it("keeps the auth identity reachable when a refused record changes scope", () => {
const ownAuthHash = recordScopedQueryKeyHashFn(queryKeys.authMe());
setRefusedRecordScope();
expect(recordScopedQueryKeyHashFn(queryKeys.authMe())).toBe(ownAuthHash);
});
it("keeps two records' entries apart at the same time", () => {
const client = appClient();
setRecordScope("acct-a");
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
__resetRecordSessionTransitionForTests,
beginRecordSessionTransition,
commitRecordSessionTransition,
getRecordSessionTransition,
resolveUnknownRecordSessionTransition,
settleRefusedRecordSessionTransition,
settleRecordSessionTransition,
} from "@/lib/query-keys/record-session-transition";
import {
__resetRecordScopeForTests,
getRecordScope,
setRecordScope,
} from "@/lib/query-keys/record-scope";
beforeEach(() => {
__resetRecordScopeForTests();
__resetRecordSessionTransitionForTests();
});
describe("record session transition", () => {
it("blocks every tab before the server switch starts", () => {
setRecordScope(null);
const transitionId = beginRecordSessionTransition("shared-record");
expect(getRecordSessionTransition()).toMatchObject({
id: transitionId,
phase: "blocking",
expectedScope: "shared-record",
});
expect(getRecordScope()).toBeNull();
});
it("keeps the hold through the committed scope until /me confirms it", () => {
const transitionId = beginRecordSessionTransition("shared-record");
commitRecordSessionTransition(transitionId, "shared-record");
expect(getRecordScope()).toBe("shared-record");
expect(getRecordSessionTransition()).toMatchObject({
id: transitionId,
phase: "resolving",
expectedScope: "shared-record",
});
settleRecordSessionTransition("shared-record");
expect(getRecordSessionTransition().phase).toBe("ready");
});
it("keeps a failed switch held until /me reconciles the prior scope", () => {
setRecordScope(null);
const transitionId = beginRecordSessionTransition("shared-record");
resolveUnknownRecordSessionTransition(transitionId);
expect(getRecordSessionTransition()).toMatchObject({
id: transitionId,
phase: "resolving",
expectedScope: undefined,
});
expect(getRecordScope()).toBeNull();
settleRecordSessionTransition(null);
expect(getRecordSessionTransition().phase).toBe("ready");
});
it("does not release a committed transition for a divergent /me response", () => {
const transitionId = beginRecordSessionTransition("shared-record");
commitRecordSessionTransition(transitionId, "shared-record");
settleRecordSessionTransition("different-record");
expect(getRecordSessionTransition()).toMatchObject({
phase: "resolving",
expectedScope: "shared-record",
});
});
it("releases an explicitly refused /me response without trusting a scope", () => {
const transitionId = beginRecordSessionTransition("shared-record");
commitRecordSessionTransition(transitionId, "shared-record");
settleRefusedRecordSessionTransition();
expect(getRecordSessionTransition()).toEqual({
id: transitionId,
phase: "ready",
expectedScope: undefined,
});
});
});
+2
View File
@@ -36,6 +36,7 @@ import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
import { nutrientKeys } from "./nutrients";
import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { sharingKeys } from "./sharing";
import { workoutKeys } from "./workouts";
@@ -65,6 +66,7 @@ export const queryKeys = {
...customMetrics,
...nutrientKeys,
...recordSettingsKeys,
...profileKeys,
...sharingKeys,
};
+4
View File
@@ -0,0 +1,4 @@
/** Query keys for the bounded, read-only profile summary surface. */
export const profileKeys = {
profileSummary: () => ["profile", "summary"] as const,
};
+54 -4
View File
@@ -51,7 +51,8 @@
*/
import type { QueryKey } from "@tanstack/react-query";
const STORAGE_KEY = "healthlog-record-scope";
export const RECORD_SCOPE_STORAGE_KEY = "healthlog-record-scope";
const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
/**
* The in-process copy. Seeded from storage on first read so the hash function
@@ -59,10 +60,15 @@ const STORAGE_KEY = "healthlog-record-scope";
*/
let scope: string | null = null;
let seeded = false;
const scopeListeners = new Set<() => void>();
function notifyScopeListeners(): void {
for (const listener of scopeListeners) listener();
}
function readMirror(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(RECORD_SCOPE_STORAGE_KEY);
return raw === null || raw.length === 0 ? null : raw;
} catch {
// Private mode, disabled storage, or SSR. "Own record" is the safe
@@ -104,13 +110,53 @@ export function setRecordScope(accountId: string | null): void {
if (typeof window === "undefined") return;
try {
if (accountId === null) {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(RECORD_SCOPE_STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, accountId);
localStorage.setItem(RECORD_SCOPE_STORAGE_KEY, accountId);
}
} catch {
/* storage unavailable — the in-process copy still partitions this tab */
}
notifyScopeListeners();
}
/**
* React bridge for consumers that must stop work as soon as another tab moves
* the session to a different record. `storage` never fires in the tab that
* wrote the mirror, so local writes notify directly above; other tabs adopt
* the new value before notifying their subscribers.
*/
export function subscribeToRecordScope(listener: () => void): () => void {
scopeListeners.add(listener);
if (typeof window === "undefined") {
return () => scopeListeners.delete(listener);
}
const onStorage = (event: StorageEvent) => {
if (event.key !== RECORD_SCOPE_STORAGE_KEY) return;
scope =
event.newValue === null || event.newValue.length === 0
? null
: event.newValue;
seeded = true;
notifyScopeListeners();
};
window.addEventListener("storage", onStorage);
return () => {
scopeListeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}
/**
* Keep a malformed active-record response out of the caller's own cache slot.
*
* No record id from an invalid payload is trusted, but `null` would make
* record-scoped reads share the caller's own cache identity. This sentinel is
* a cache partition only; the shell refuses the presentation until the caller
* leaves the server-side switch.
*/
export function setRefusedRecordScope(): void {
setRecordScope(REFUSED_RECORD_SCOPE);
}
/** True while this browser is reading somebody else's record. */
@@ -138,6 +184,10 @@ export function recordScopedQueryKeyHashFn(queryKey: QueryKey): string {
}, {})
: val,
);
// `/api/auth/me` identifies the caller, never the record they are reading.
// Its query begins before `fetchMe` can resolve or refuse the record scope,
// so it must remain reachable across that transition.
if (queryKey[0] === "auth") return key;
const record = getRecordScope();
return record === null ? key : `record:${record}|${key}`;
}
@@ -0,0 +1,282 @@
"use client";
import {
RECORD_SCOPE_STORAGE_KEY,
setRecordScope,
} from "@/lib/query-keys/record-scope";
/**
* A browser-wide hold around a server-side record-session switch.
*
* The session stamp is shared by every tab, but React Query and `/api/auth/me`
* are tab-local. A peer with a five-minute fresh `/me` cache can otherwise
* keep owner chrome mounted after another tab changes the server session. The
* switcher therefore publishes `blocking` before its POST. Recipients clear
* their local reads and render only the hydration gate. A committed scope then
* moves every tab to `resolving`; only a fresh `/me` whose active record
* exactly matches the committed scope releases the hold.
*
* This is a browser-side coordination point, not a distributed-security
* fence: BroadcastChannel and storage delivery cannot pre-empt a request that
* a suspended peer already sent. The server remains authoritative for every
* such request. After a tab processes the hold, this protocol removes local
* results and prevents its protected shell from issuing new reads until `/me`
* resolves the scope.
*/
export type RecordSessionPhase = "ready" | "blocking" | "resolving";
export interface RecordSessionTransition {
id: string | null;
phase: RecordSessionPhase;
/** Undefined only for a recovery reload whose server outcome is unknown. */
expectedScope: string | null | undefined;
}
interface WireTransition {
id: string;
phase: Exclude<RecordSessionPhase, "ready"> | "ready";
expectedScope?: string | null;
at: number;
}
const STORAGE_KEY = "healthlog-record-session-transition";
const CHANNEL_NAME = "healthlog-record-session-transition";
const TRANSITION_MAX_AGE_MS = 30_000;
const READY: RecordSessionTransition = {
id: null,
phase: "ready",
expectedScope: undefined,
};
let transition = READY;
let seeded = false;
const listeners = new Set<() => void>();
let channel: BroadcastChannel | null = null;
let storageListenerAttached = false;
function notify(): void {
for (const listener of listeners) listener();
}
function makeId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function parseWire(value: string | null): WireTransition | null {
if (value === null) return null;
try {
const parsed = JSON.parse(value) as Partial<WireTransition>;
if (
typeof parsed.id !== "string" ||
(parsed.phase !== "blocking" &&
parsed.phase !== "resolving" &&
parsed.phase !== "ready") ||
typeof parsed.at !== "number" ||
Date.now() - parsed.at > TRANSITION_MAX_AGE_MS
) {
return null;
}
if (
parsed.expectedScope !== undefined &&
parsed.expectedScope !== null &&
typeof parsed.expectedScope !== "string"
) {
return null;
}
return parsed as WireTransition;
} catch {
return null;
}
}
function toSnapshot(next: WireTransition): RecordSessionTransition {
return {
id: next.id,
// A new tab cannot know whether an inherited blocking POST is still in
// flight. It resolves `/me` rather than leaving the browser wedged if the
// initiating tab crashed between its POST and terminal message.
phase: next.phase === "blocking" && !seeded ? "resolving" : next.phase,
expectedScope: next.expectedScope,
};
}
function applyWire(next: WireTransition): void {
if (
transition.id === next.id &&
transition.phase === next.phase &&
transition.expectedScope === next.expectedScope
) {
return;
}
transition = toSnapshot(next);
seeded = true;
notify();
}
function writeWire(next: WireTransition): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* storage unavailable — BroadcastChannel can still notify peers */
}
}
function postWire(next: WireTransition): void {
try {
channel?.postMessage(next);
} catch {
/* a closed channel is only an optimisation loss; storage remains */
}
}
function publish(next: WireTransition): void {
applyWire(next);
writeWire(next);
postWire(next);
}
function ensureTransport(): void {
if (typeof window === "undefined") return;
if (!storageListenerAttached) {
window.addEventListener("storage", (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
const next = parseWire(event.newValue);
if (next) applyWire(next);
return;
}
// A scope write without a transition marker is an external/legacy
// switch. Fail closed and force `/me` to reconcile it before any stale
// shell can issue a new record read.
if (event.key === RECORD_SCOPE_STORAGE_KEY) {
const expectedScope =
event.newValue === null || event.newValue.length === 0
? null
: event.newValue;
// A current protocol transition already names this scope. The scope
// mirror is its second storage write, not a separate legacy switch.
if (
transition.phase !== "ready" &&
transition.expectedScope === expectedScope
) {
return;
}
publish({
id: makeId(),
phase: "resolving",
expectedScope,
at: Date.now(),
});
}
});
storageListenerAttached = true;
}
if (channel === null && typeof BroadcastChannel !== "undefined") {
channel = new BroadcastChannel(CHANNEL_NAME);
channel.onmessage = (event: MessageEvent<unknown>) => {
const value = event.data;
if (value === null || typeof value !== "object") return;
const next = parseWire(JSON.stringify(value));
if (next) applyWire(next);
};
}
}
function seed(): void {
if (seeded) return;
if (typeof window === "undefined") {
seeded = true;
return;
}
const next = parseWire(localStorage.getItem(STORAGE_KEY));
if (next) {
transition = toSnapshot(next);
}
seeded = true;
}
export function getRecordSessionTransition(): RecordSessionTransition {
seed();
return transition;
}
export function subscribeToRecordSessionTransition(
listener: () => void,
): () => void {
seed();
ensureTransport();
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Publish the safe hold before issuing `POST /api/account/switch`. */
export function beginRecordSessionTransition(
expectedScope: string | null,
): string {
seed();
ensureTransport();
const id = makeId();
publish({ id, phase: "blocking", expectedScope, at: Date.now() });
return id;
}
/** Record the server-confirmed scope, while every tab still waits for `/me`. */
export function commitRecordSessionTransition(
id: string,
expectedScope: string | null,
): void {
if (transition.id !== id) return;
publish({ id, phase: "resolving", expectedScope, at: Date.now() });
// The terminal transition marker is deliberately published before the
// scope mirror. A peer that receives the two storage events separately is
// already held when it learns the new record hash.
setRecordScope(expectedScope);
}
/**
* A switch response was lost after the request was made. Keep all tabs held
* and let `/me` decide which record the server actually kept.
*/
export function resolveUnknownRecordSessionTransition(id: string): void {
if (transition.id !== id) return;
publish({ id, phase: "resolving", at: Date.now() });
}
/** Release only the `/me` response that confirms the committed record. */
export function settleRecordSessionTransition(
resolvedScope: string | null,
): void {
if (transition.phase !== "resolving") return;
if (
transition.expectedScope !== undefined &&
transition.expectedScope !== resolvedScope
) {
return;
}
const id = transition.id ?? makeId();
publish({ id, phase: "ready", at: Date.now() });
}
/**
* Release the browser hold after a server response has explicitly refused its
* access block. The caller has already installed the refused scope sentinel,
* so returning to `ready` can only render the access-refusal door; it never
* restores a prior record scope.
*/
export function settleRefusedRecordSessionTransition(): void {
if (transition.phase !== "resolving") return;
const id = transition.id ?? makeId();
publish({ id, phase: "ready", at: Date.now() });
}
/** @internal — deterministic transition state for unit tests. */
export function __resetRecordSessionTransitionForTests(): void {
transition = READY;
seeded = false;
listeners.clear();
channel?.close();
channel = null;
}
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/db";
import { readHealthProfileFacts } from "@/lib/profile/health-facts";
export interface ProfileSummaryPayload {
allergies: Array<{ substance: string; category: string }>;
familyHistory: Array<{ condition: string; relationship: string }>;
facts: Array<{ label: string; value: string }>;
}
/**
* The shared profile surface deliberately carries only the bounded, structured
* facts a record viewer needs. It does not load settings, AI configuration, or
* any encrypted free-text field.
*/
export async function readProfileSummary(
userId: string,
): Promise<ProfileSummaryPayload> {
const [allergies, familyHistory, healthFacts] = await Promise.all([
prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
take: 25,
select: { substance: true, category: true },
}),
prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
take: 25,
select: { condition: true, relationship: true },
}),
readHealthProfileFacts(userId),
]);
return {
allergies,
familyHistory,
facts: Object.values(healthFacts.current).flatMap((fact) =>
fact?.value && !fact.unreadable
? [{ label: fact.kind, value: fact.value }]
: [],
),
};
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { parseAccountAccess } from "../account-access-schema";
const sharedEntry = {
accountId: "record-a",
username: "record-a",
displayName: "Record A",
access: "read",
level: "read",
recordKind: "shared",
sections: ["labs"],
canWrite: false,
};
function accessBlock(overrides: Record<string, unknown> = {}) {
return {
accounts: [sharedEntry],
active: sharedEntry,
recordKind: "shared",
canSwitch: true,
...overrides,
};
}
describe("parseAccountAccess", () => {
it("preserves canonical shared payloads, including an empty scope", () => {
const scoped = parseAccountAccess(accessBlock());
const empty = parseAccountAccess(
accessBlock({
accounts: [{ ...sharedEntry, sections: [] }],
active: { ...sharedEntry, sections: [] },
}),
);
expect(scoped?.active?.sections).toEqual(["labs"]);
expect(empty?.active?.sections).toEqual([]);
});
it("preserves the canonical manage compatibility fields", () => {
const manager = {
...sharedEntry,
access: "write",
level: "manage",
sections: null,
canWrite: true,
};
const parsed = parseAccountAccess(
accessBlock({ accounts: [manager], active: manager }),
);
expect(parsed?.active).toMatchObject({
level: "manage",
access: "write",
sections: null,
canWrite: true,
});
});
it.each([
[
"legacy access and resolved level",
{ access: "write", level: "write", canWrite: true },
{},
],
["record kind", { recordKind: "managed" }, { recordKind: "managed" }],
["scope", { sections: ["measurements"] }, {}],
[
"resolved write capability",
{ access: "write", level: "write", canWrite: true },
{},
],
["username", { username: "different-record" }, {}],
["display name", { displayName: "Different record" }, {}],
])(
"fails closed when active disagrees with its canonical entry on %s",
(_field, activeOverrides, blockOverrides) => {
const active = { ...sharedEntry, ...activeOverrides };
const block = accessBlock({ active, ...blockOverrides });
expect(parseAccountAccess(block)).toBeNull();
},
);
it.each([
["unknown level", { level: "owner" }],
["unknown record kind", { recordKind: "other" }],
["unknown section", { sections: ["other"] }],
["duplicated section", { sections: ["labs", "labs"] }],
["non-array section", { sections: "labs" }],
["read marked writable", { canWrite: true }],
["write marked read-only", { access: "write", level: "write" }],
[
"scoped manage",
{ access: "write", level: "manage", sections: ["labs"], canWrite: true },
],
[
"manage with read legacy field",
{ access: "read", level: "manage", sections: null, canWrite: true },
],
])("fails closed for %s entries", (_name, entryOverrides) => {
const entry = { ...sharedEntry, ...entryOverrides };
expect(
parseAccountAccess(accessBlock({ accounts: [entry], active: entry })),
).toBeNull();
});
it("fails closed for a stale active entry or a contradictory switch block", () => {
expect(
parseAccountAccess(
accessBlock({ active: { ...sharedEntry, accountId: "stale-record" } }),
),
).toBeNull();
expect(parseAccountAccess(accessBlock({ canSwitch: false }))).toBeNull();
expect(
parseAccountAccess({
accounts: [],
active: null,
recordKind: "self",
canSwitch: true,
}),
).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { z } from "zod/v4";
import type { AccountAccess } from "./account-access-view";
import { SHARE_DOMAINS } from "./scope";
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
const accountRecordKindSchema = z.enum(["shared", "managed"]);
const shareSectionsSchema = z.array(z.enum(SHARE_DOMAINS)).nullable();
/** The browser's resolved account-access payload, shared with OpenAPI. */
export const accountAccessEntrySchema = z
.object({
accountId: z.string().min(1),
username: z.string().min(1),
displayName: z.string().nullable(),
access: z.enum(["read", "write"]),
level: accountAccessLevelSchema,
recordKind: accountRecordKindSchema,
sections: shareSectionsSchema,
canWrite: z.boolean(),
})
.superRefine((entry, ctx) => {
const uniqueSections = new Set(entry.sections ?? []);
if (uniqueSections.size !== (entry.sections?.length ?? 0)) {
ctx.addIssue({
code: "custom",
message: "Account access sections must not repeat a domain",
path: ["sections"],
});
}
const isManage = entry.level === "manage";
if (isManage && (entry.access !== "write" || entry.sections !== null)) {
ctx.addIssue({
code: "custom",
message: "Manage access must use legacy write access and whole scope",
});
}
if (entry.canWrite !== (entry.level !== "read")) {
ctx.addIssue({
code: "custom",
message: "Account access write flag disagrees with its level",
path: ["canWrite"],
});
}
if (!isManage && entry.access !== entry.level) {
ctx.addIssue({
code: "custom",
message: "Legacy access must agree with the resolved level",
path: ["access"],
});
}
});
export const accountAccessBlockSchema = z.object({
accounts: z.array(accountAccessEntrySchema),
active: accountAccessEntrySchema.nullable(),
recordKind: z.enum(["self", "shared", "managed"]),
canSwitch: z.boolean(),
});
/** Both published views of the active record must name the same resolved grant. */
function matchesCanonicalAccountAccessEntry(
active: z.infer<typeof accountAccessEntrySchema>,
canonical: z.infer<typeof accountAccessEntrySchema>,
): boolean {
const activeSections = active.sections;
const canonicalSections = canonical.sections;
const sameSections =
activeSections === null || canonicalSections === null
? activeSections === canonicalSections
: activeSections.length === canonicalSections.length &&
activeSections.every(
(section, index) => section === canonicalSections[index],
);
return (
active.accountId === canonical.accountId &&
active.username === canonical.username &&
active.displayName === canonical.displayName &&
active.access === canonical.access &&
active.level === canonical.level &&
active.recordKind === canonical.recordKind &&
active.canWrite === canonical.canWrite &&
sameSections
);
}
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const parsed = accountAccessBlockSchema.safeParse(value);
if (!parsed.success) return null;
const { accounts, active, recordKind, canSwitch } = parsed.data;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (
!canonicalActive ||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
) {
return null;
}
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
+2 -1
View File
@@ -28,6 +28,7 @@ export type LegacyAccountAccessLevel = "read" | "write";
/** The server-resolved kind of record a presentation is naming. */
export type AccountRecordKind = "self" | "shared" | "managed";
export type SharedAccountRecordKind = Exclude<AccountRecordKind, "self">;
/**
* Read the portion of an account-access entry that an older whole-record
@@ -68,7 +69,7 @@ export interface AccountAccessEntry {
* What kind of record this is. This is presentation metadata only; it is
* resolved by the server and never accepted as an authorization input.
*/
recordKind: AccountRecordKind;
recordKind: SharedAccountRecordKind;
/**
* v1.37.0 — which sections of the record this grant opens, or `null` for
* the entire record.
-42
View File
@@ -1,42 +0,0 @@
export const RELEASE_JOURNEYS = [
{
name: "adult-full-read-scoped-read-write-and-manage",
contract: "inventory-only",
},
{
name: "manage-mutation-activity-and-fenced-settings",
contract: "inventory-only",
},
{
name: "managed-profile-two-guardian-lifecycle",
contract: "inventory-only",
},
{
name: "two-guardian-two-record-notification-delivery",
contract: "inventory-only",
},
{
name: "revoked-manage-idempotency-replay",
contract: "inventory-only",
},
{
name: "delegate-provider-no-egress-owner-positive",
contract: "inventory-only",
},
{
name: "durable-import-actor-attribution",
contract: "inventory-only",
},
{
name: "legacy-client-compatibility",
contract: "inventory-only",
},
] as const;
export const ACCESSIBILITY_STATES = [
{ name: "success", contract: "inventory-only" },
{ name: "empty", contract: "inventory-only" },
{ name: "loading", contract: "inventory-only" },
{ name: "error", contract: "inventory-only" },
{ name: "refusal", contract: "inventory-only" },
] as const;