diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0b021d082..7cc1841f9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29943,19 +29943,19 @@ components: carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, - auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, - auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle, - coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, - coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, - coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — - coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — - cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; - documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, + auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required, + auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing; + coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, + documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, @@ -44485,6 +44485,33 @@ components: - type: "null" description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends. + serverProviderHealth: + type: string + enum: + - healthy + - unhealthy + - unknown + description: "Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across + all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the + last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when the last outcome somebody + observed was a failure, `unknown` when nothing has been tried lately on this instance (including a brand-new + instance whose ledger is still empty) or the read failed. Deliberately carries no count, no timestamp and no + account identity — 'the provider of this instance last worked' is a statement about the instance, not about + other people. Fails closed for the offer: neither `unhealthy` nor `unknown` may be offered. They are not + interchangeable for copy, though — `unhealthy` is a verdict a client may state, `unknown` is the absence of + one and must not be reported as an outage. Reading this never probes a provider." + serverProviderOffer: + type: boolean + description: "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the + operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is + `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials + (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo + mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false." + serverProviderConsent: + type: boolean + description: Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST + /api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for + that reason rather than for a bad one. required: - provider - model @@ -44500,6 +44527,9 @@ components: - compatModel - hasCompatKey - responseTimeoutSeconds + - serverProviderHealth + - serverProviderOffer + - serverProviderConsent additionalProperties: false description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key. @@ -64323,9 +64353,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string @@ -65318,9 +65351,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string @@ -65724,9 +65760,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string diff --git a/e2e/invite-registration.spec.ts b/e2e/invite-registration.spec.ts new file mode 100644 index 000000000..aefaeb362 --- /dev/null +++ b/e2e/invite-registration.spec.ts @@ -0,0 +1,118 @@ +/** + * The journey that would have caught the invite defect. + * + * Every layer of the invite chain had unit coverage and the chain was broken + * anyway: the proxy test proved the edge admits `/invite/`, the landing + * page test proved it redirects onto `/auth/register?invite=…`, and neither + * could see that in the real runtime the redirect arrives in an RSC flight + * payload AFTER the shell has flushed — so the shell mounted on `/invite/…`, + * read it as a protected route and raced its own `router.replace( + * "/auth/login")` against it. The operator scanned his own QR in a private + * window and landed on the sign-in page holding a one-time invitation the + * sign-in page cannot use. + * + * So this spec is deliberately end to end and deliberately about the SEAM: + * mint a real invitation in the admin UI, open the real URL in a genuinely + * cookie-less context, and assert both where the visitor ends up and where + * they never went. + * + * The second case is the other half of the same defect: the same link opened + * in the admin's own window used to render the bare form, and submitting it + * replaced his session cookie with the new account's — signing him out of his + * admin account and into a stranger's onboarding. + * + * Neither case completes a signup: that would add a user to the shared e2e + * database and spend the 5-per-15-minutes registration bucket every other + * spec shares. What the signup does with the token is pinned in + * `src/app/api/auth/register/__tests__/route.test.ts`. + */ +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** Mint an invitation in the admin UI and return its `/invite/` path. */ +async function mintInvite(page: import("@playwright/test").Page) { + await page.goto("/admin/invites"); + await page.getByTestId("admin-invites-open-create").click(); + await page.getByTestId("admin-invites-submit-create").click(); + const minted = page.getByTestId("admin-invites-minted"); + await expect(minted).toBeVisible(); + const url = await page.getByTestId("admin-invites-minted-url").textContent(); + expect(url).toMatch(/\/invite\/hlv_[0-9a-f]{64}$/); + // The minted URL carries whatever origin the instance is configured with + // (`APP_URL` / `NEXT_PUBLIC_APP_URL`), which is not necessarily the one + // Playwright is driving. The path is the part under test. + return new URL(url!.trim()).pathname; +} + +test.describe("invitation links", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("opens registration in a cookie-less window and never passes login", async ({ + page, + browser, + }) => { + const invitePath = await mintInvite(page); + + // The operator's private window: a context with no cookies at all, not a + // page that merely has not logged in. + const fresh = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const guest = await fresh.newPage(); + const visited: string[] = []; + guest.on("framenavigated", (frame) => { + if (frame === guest.mainFrame()) visited.push(frame.url()); + }); + + try { + await guest.goto(invitePath); + await expect(guest).toHaveURL( + /\/auth\/register\?invite=hlv_[0-9a-f]{64}/, + ); + await expect(guest.getByTestId("register-invite-banner")).toBeVisible(); + // The form is reachable, not just the URL. + await expect(guest.locator("#username")).toBeVisible(); + // The defect, named: the visitor must never have been bounced through + // the sign-in page on the way. + expect(visited.filter((u) => u.includes("/auth/login"))).toEqual([]); + } finally { + // Contexts a spec creates itself are invisible to the route-cleanup + // fixture — see the header of `e2e/setup/test.ts`. + await fresh.close(); + } + }); + + test("shows the signed-in panel in the admin's own window and keeps the session", async ({ + page, + }) => { + const invitePath = await mintInvite(page); + + await page.goto(invitePath); + await expect(page).toHaveURL(/\/auth\/register\?invite=hlv_[0-9a-f]{64}/); + await expect(page.getByTestId("register-already-signed-in")).toBeVisible(); + // No form to submit, so no way to register over the live session. + await expect(page.locator("#username")).toHaveCount(0); + await expect(page.getByTestId("register-sign-out")).toBeVisible(); + + // The admin is still the admin. + const me = await page.request.get("/api/auth/me"); + expect(me.status()).toBe(200); + }); + + test("refuses a registration POST carried on a live session", async ({ + page, + }) => { + // The server half of the same rule, asserted through the wire rather than + // through the UI: the panel above is an explanation, not the enforcement. + const res = await page.request.post("/api/auth/register", { + data: { + email: "invite-over-session@healthlog.test", + username: "invite-over-session", + password: "ZJ4hN8x!Pq3vMr2C", + }, + }); + expect(res.status()).toBe(409); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("auth.already_authenticated"); + }); +}); diff --git a/e2e/onboarding-ai-offer.spec.ts b/e2e/onboarding-ai-offer.spec.ts new file mode 100644 index 000000000..7f09c78e8 --- /dev/null +++ b/e2e/onboarding-ai-offer.spec.ts @@ -0,0 +1,220 @@ +/** + * v1.38.19 — the AI offer at the end of the setup flow. + * + * The screen used to promise that insights "work for you right now" the + * moment the operator had configured a key. It never knew whether the key + * answered, and it never mentioned the consent receipt the egress actually + * needs — so the person who believed it met a `consent.ai.required` refusal + * on their first briefing. On 2026-09-11 the operator's own instance was + * answering HTTP 500 from its OAuth proxy while the screen kept promising. + * + * Three runs, one account, and the only difference between them is a row in + * `provider_health`: + * + * a success inside the freshness window → the offer, and one tap grants + * a dead credential inside its cooldown → "not answering", and no button + * no row at all → neither: the panel says nothing + * + * What is deliberately absent: any call to a provider. The flow's rule holds + * — nothing in it talks to one — and a probe here would bill the operator for + * every registration on the instance. + * + * Serial, and it restores the instance state it seeded: the tri-state folds + * across accounts by design, so its inputs are instance-wide. + */ +import { + E2E_SETUP_AI_OFFER, + SETUP_AI_OFFER_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { + clearAiConsent, + clearOperatorProvider, + clearSharedProviderResult, + seedOperatorProvider, + seedSharedProviderResult, +} from "./setup/ai-offer-fixture"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + answer, + answerEverything, + complete, + expectScreen, +} from "./setup-flow-helpers"; + +const OFFER = '[data-slot="onboarding-ai-offer"]'; +const GRANT = '[data-slot="onboarding-ai-offer-grant"]'; +const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]'; +const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]'; +/** The panel once it knows which variant it is; absent while the status is in + * flight, so an assertion of absence cannot pass on an unanswered question. */ +const SETTLED = '[data-slot="onboarding-ai-panel"][data-ai-state]'; + +/** Walk the account to the done screen through the API, then open it. */ +async function openDoneScreen(page: import("@playwright/test").Page) { + await page.goto("/onboarding"); + await answerEverything(page); + // Q6 applies because the answers name glucose and weight; the flow does not + // complete while a screen it owes is unanswered. + await answer(page, { + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }); + await complete(page); + await answer(page, { step: "first-result", status: "skipped" }); + await page.goto("/onboarding/done"); + await expectScreen(page, "done"); + // The panel itself is unconditional; the variant inside it is not. + await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible(); +} + +// One account, one instance-wide ledger. Two workers driving this at once +// would each read the other's seed. +test.describe.configure({ mode: "serial" }); + +test.describe("the setup flow's AI offer follows the shared provider's health", () => { + test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_AI_OFFER.username); + await clearAiConsent(E2E_SETUP_AI_OFFER.username); + await seedOperatorProvider(); + }); + + test.afterAll(async () => { + await clearOperatorProvider(); + await clearAiConsent(E2E_SETUP_AI_OFFER.username); + }); + + test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { + result: "ok", + okMinutesAgo: 30, + }); + + // No receipt before the tap — otherwise the assertion after it proves + // nothing about the button. + const before = await page.request.get( + "/api/consent/ai/latest?kind=ai_full", + ); + expect(before.status()).toBe(200); + expect( + ((await before.json()) as { data: { receipt: unknown } }).data.receipt, + ).toBeNull(); + + await openDoneScreen(page); + + await expect(page.locator(SETTLED)).toHaveAttribute( + "data-ai-state", + "offer", + { timeout: 15_000 }, + ); + await expect(page.locator(OFFER)).toBeVisible(); + await expect(page.locator(UNAVAILABLE)).toHaveCount(0); + + // No provider was called to decide this. The flow reads a projection. + const probes: string[] = []; + page.on("request", (req) => { + const path = new URL(req.url()).pathname; + if (path === "/api/ai/test" || path.startsWith("/api/insights/")) + probes.push(path); + }); + + await page.locator(GRANT).click(); + + // The line the person reads swaps to the granted one. + await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(GRANT)).toHaveCount(0); + expect(probes, "the offer must not trigger a provider call").toEqual([]); + + const after = await page.request.get("/api/consent/ai/latest?kind=ai_full"); + const receipt = ( + (await after.json()) as { + data: { receipt: { kind: string; revokedAt: string | null } | null }; + } + ).data.receipt; + expect(receipt).not.toBeNull(); + expect(receipt!.kind).toBe("ai_full"); + expect(receipt!.revokedAt).toBeNull(); + }); + + test("says the provider is not answering while its credential is benched, and offers no button", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { + result: "auth_failed", + }); + + await openDoneScreen(page); + + await expect(page.locator(SETTLED)).toHaveAttribute( + "data-ai-state", + "unavailable", + { timeout: 15_000 }, + ); + await expect(page.locator(UNAVAILABLE)).toBeVisible(); + await expect(page.locator(GRANT)).toHaveCount(0); + await expect(page.locator(OFFER)).toHaveCount(0); + // The unproven promise is not shown to somebody who has not consented. + await expect(page.locator(SHARED_KEY)).toHaveCount(0); + + // The rest of the panel is untouched: the flow stays complete without it. + await expect( + page.locator('[data-slot="onboarding-ai-keyless"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="onboarding-open-dashboard"]'), + ).toBeVisible(); + + // The grant route itself stays open — it is the user's own consent act + // and is reachable from Settings at any time. What this case pins is the + // decision the screen was handed, so read it back from the source. + const status = await page.request.get("/api/user/ai-provider"); + const data = ( + (await status.json()) as { + data: { serverProviderHealth: string; serverProviderOffer: boolean }; + } + ).data; + expect(data.serverProviderHealth).toBe("unhealthy"); + expect(data.serverProviderOffer).toBe(false); + }); + + test("claims nothing on an instance whose provider nobody has used yet", async ({ + page, + }) => { + test.setTimeout(90_000); + // The operator has configured a key and nothing has been asked of it — + // the state of every deployment until its first AI call, and so the most + // common way through this screen. The panel may not offer (nothing is + // known to work) and may not report an outage (nothing failed). + await clearSharedProviderResult(); + + await openDoneScreen(page); + + await expect(page.locator(SETTLED)).toHaveAttribute( + "data-ai-state", + "neutral", + { timeout: 15_000 }, + ); + await expect( + page.locator('[data-slot="onboarding-ai-keyless"]'), + ).toBeVisible(); + await expect(page.locator(UNAVAILABLE)).toHaveCount(0); + await expect(page.locator(OFFER)).toHaveCount(0); + await expect(page.locator(GRANT)).toHaveCount(0); + await expect(page.locator(SHARED_KEY)).toHaveCount(0); + + const status = await page.request.get("/api/user/ai-provider"); + const data = ( + (await status.json()) as { + data: { serverProviderHealth: string; serverProviderOffer: boolean }; + } + ).data; + expect(data.serverProviderHealth).toBe("unknown"); + expect(data.serverProviderOffer).toBe(false); + }); +}); diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts index 3891eb7c4..d034d1b6b 100644 --- a/e2e/setup-flow-a11y.spec.ts +++ b/e2e/setup-flow-a11y.spec.ts @@ -118,7 +118,14 @@ const SCREENS: ScreenCase[] = [ { screen: "first-result", path: "/onboarding/first-result", - painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'), + // v1.38.19 — wait for the tile the screen SETTLED on, not for + // the connect card. This sweep used to name the connect card, which is + // painted for the few hundred milliseconds before the status envelope + // resolves whatever the account's real state is — so it scanned a frame + // the person may never see, and passed straight through the defect that + // frame was hiding. `data-connect-slot` appears only once the screen has + // decided, and it is on every one of the four tiles. + painted: (page) => page.locator("[data-connect-slot]"), prepare: (page) => complete(page), }, { diff --git a/e2e/setup-flow-connect.spec.ts b/e2e/setup-flow-connect.spec.ts new file mode 100644 index 000000000..4a7aff3d2 --- /dev/null +++ b/e2e/setup-flow-connect.spec.ts @@ -0,0 +1,212 @@ +/** + * v1.38.19 — the setup flow, path 5: a wearable on Q4. + * + * The connect arm is the only place in the flow that renders a claim about + * real account state, and until this file no journey reached it: bp answers + * `manual`, visit answers `file`, and the child path ends at confirm. The + * a11y sweep does open the screen, but asserted the connect card was visible + * — which it was, for the few hundred milliseconds before the status query + * resolved. So the sweep passed THROUGH the defect it should have caught. + * + * Both cases here assert AFTER `GET /api/integrations/status` has settled, + * which is the only moment at which the screen's claim means anything. + * + * Runs in one project (see `playwright.config.ts`): it resets and mutates its + * own account. + */ +import pg from "pg"; + +import { + E2E_SETUP_CONNECT, + SETUP_CONNECT_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import type { Page, Response } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { answer, expectScreen, readMe } from "./setup-flow-helpers"; + +function pool(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[setup-flow-connect] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +/** + * Put the account's Oura back to "never connected". The journey's second case + * writes a token column and a ledger row, neither of which `resetSetupFlow` + * knows about — it resets the FLOW, and a leftover connection would make the + * first case assert against the second case's state. + */ +async function clearOura(): Promise { + const db = pool(); + try { + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_SETUP_CONNECT.username], + ); + const id = rows[0]?.id; + if (!id) throw new Error("[setup-flow-connect] no account"); + await db.query( + `UPDATE users SET oura_access_token_encrypted = NULL WHERE id = $1`, + [id], + ); + await db.query( + `DELETE FROM integration_statuses WHERE user_id = $1 AND integration = 'oura'`, + [id], + ); + } finally { + await db.end(); + } +} + +/** + * A connection that has been delivering for an hour: a stored token (the + * envelope reads `connected` off the column's presence alone) and a ledger + * row whose last success is recent enough for the `fresh` verdict. + */ +async function seedDeliveringOura(): Promise { + const db = pool(); + try { + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_SETUP_CONNECT.username], + ); + const id = rows[0]?.id; + if (!id) throw new Error("[setup-flow-connect] no account"); + await db.query( + `UPDATE users SET oura_access_token_encrypted = 'e2e-placeholder' WHERE id = $1`, + [id], + ); + await db.query( + `INSERT INTO integration_statuses + (id, user_id, integration, state, last_success_at, last_attempt_at, created_at, updated_at) + VALUES ($1, $2, 'oura', 'connected', NOW(), NOW(), NOW(), NOW()) + ON CONFLICT (user_id, integration) DO UPDATE + SET state = 'connected', + last_success_at = NOW(), + last_attempt_at = NOW(), + updated_at = NOW()`, + [`e2e-connect-oura-${Date.now()}`, id], + ); + } finally { + await db.end(); + } +} + +/** + * Walk the questions through the API, then open the screen under test. + * + * `areas` decides whether the priority has anywhere to fall to when the Q4 + * source turns out to be connected already: a Q2 area is a `log-reading` task, + * an empty Q2 is nothing. + */ +async function reachFirstResult( + page: Page, + areas: readonly string[], +): Promise { + await answer(page, { step: "who", recordTarget: "me" }); + await answer(page, { + step: "areas", + ...(areas.length > 0 ? { areas } : { status: "skipped" }), + }); + await answer(page, { step: "medication", medication: "no" }); + await answer(page, { step: "sources", sources: ["oura"] }); + await answer(page, { step: "visit", visit: "no" }); + const settled = page.waitForResponse( + (res: Response) => + res.url().includes("/api/integrations/status") && res.status() === 200, + ); + await page.goto("/onboarding/confirm"); + await expectScreen(page, "confirm"); + await page.locator('[data-slot="onboarding-next"]').click(); + await expectScreen(page, "first-result"); + // Every assertion below is about what the screen says once it KNOWS. + await settled; +} + +// The three cases drive the one account, each setting up a different +// connection state. Run in parallel they would each walk the others' state. +test.describe.configure({ mode: "serial" }); + +test.describe("setup flow — a wearable on Q4", () => { + test.use({ storageState: SETUP_CONNECT_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_CONNECT.username); + await clearOura(); + }); + + test.afterAll(async () => { + await clearOura(); + }); + + test("never claims a connection the account does not have", async ({ + page, + }) => { + test.setTimeout(90_000); + await page.goto("/onboarding"); + await reachFirstResult(page, ["sleep"]); + + // The tile the screen settled on: either the connect card, or — on an + // instance with no Oura app configured — the credentials note. Never a + // result: nothing was connected. + const tile = page.locator("[data-connect-slot]"); + await expect(tile).toBeVisible({ timeout: 15_000 }); + const slot = await tile.getAttribute("data-connect-slot"); + expect(["connect", "credentials"]).toContain(slot); + await expect(tile).toHaveAttribute("data-connect-state", "disconnected"); + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toHaveCount(0); + + // And the ledger was not stamped on the person's behalf. + const me = await readMe(page); + expect(me.onboarding.firstResult?.completedAt ?? null).toBeNull(); + }); + + test("offers the next task instead of a connection that already works", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedDeliveringOura(); + await page.goto("/onboarding"); + await reachFirstResult(page, ["sleep"]); + + // The answers name one area as well, so the priority has somewhere to + // fall to and the flow ends on something the person can actually do. + await expect(page.locator("section[data-task]")).toHaveAttribute( + "data-task", + "log-reading", + ); + await expect( + page.locator('[data-slot="onboarding-task-connect"]'), + ).toHaveCount(0); + }); + + test("acknowledges a connection that is already delivering", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedDeliveringOura(); + await page.goto("/onboarding"); + // Q2 passed over, so the connection is the only task the answers name. + await reachFirstResult(page, []); + + const tile = page.locator('[data-connect-slot="result"]'); + await expect(tile).toBeVisible({ timeout: 15_000 }); + await expect(tile).toHaveAttribute("data-connect-state", "fresh"); + + // The acknowledgment replaces the offer: the connect CTA is gone. + await expect( + page.locator('[data-slot="onboarding-task-connect"]'), + ).toHaveCount(0); + + // A working connection is a result the flow may record. + await expect + .poll(async () => (await readMe(page)).onboarding.firstResult?.task, { + timeout: 15_000, + }) + .toBe("connect-source"); + }); +}); diff --git a/e2e/setup/ai-offer-fixture.ts b/e2e/setup/ai-offer-fixture.ts new file mode 100644 index 000000000..5f427fa94 --- /dev/null +++ b/e2e/setup/ai-offer-fixture.ts @@ -0,0 +1,149 @@ +/** + * v1.38.19 — the instance state the shared-provider offer reads. + * + * Two rows decide what the setup flow's last screen may say, and neither has + * a product endpoint that writes them the way this journey needs: + * + * `app_settings.admin_ai_key_encrypted` — presence alone makes the operator + * the origin that would serve a user with no provider of their own + * (`managedBy: "server"`). Nothing decrypts it on this path, so the value + * below is a placeholder ciphertext and not a credential of any kind. + * + * `provider_health` — the ledger the tri-state folds. It is written only as + * a side effect of a real completion, which this suite must never make, so + * the journey states the outcome it wants to reason about instead. + * + * Both are INSTANCE-wide by nature: the projection deliberately folds across + * accounts, because a fresh account's own ledger is empty by construction. + * That is why the spec runs serially and puts both back afterwards. + * + * Seeded through `pg`, the same transport `global-setup.ts` and the other + * fixtures use. + */ +import pg from "pg"; + +/** Not a key. Presence is the only thing read on this path. */ +const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[ai-offer-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +async function userId(pool: pg.Pool, username: string): Promise { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [username], + ); + const id = rows[0]?.id; + if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`); + return id; +} + +/** The operator has configured a shared provider on this instance. */ +export async function seedOperatorProvider(): Promise { + const pool = connect(); + try { + await pool.query( + `INSERT INTO app_settings (id, admin_ai_key_encrypted) + VALUES ('singleton', $1) + ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`, + [PLACEHOLDER_ADMIN_KEY], + ); + } finally { + await pool.end(); + } +} + +/** Put the instance back: no operator key, no ledger rows for either tag. */ +export async function clearOperatorProvider(): Promise { + const pool = connect(); + try { + await pool.query( + `UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`, + ); + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + } finally { + await pool.end(); + } +} + +/** + * State one outcome for the operator's OpenAI provider. + * + * `ok` — it served somebody `okMinutesAgo` minutes ago. + * `auth_failed` — its credential is dead and benched for another hour, + * which is the shape the operator's own instance was in on + * 2026-09-11 (a 500 from the OAuth proxy, then a 401). + */ +export async function seedSharedProviderResult( + username: string, + outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" }, +): Promise { + const pool = connect(); + try { + const id = await userId(pool, username); + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + if (outcome.result === "ok") { + await pool.query( + `INSERT INTO provider_health + (id, user_id, provider_type, last_result, consecutive_failures, + last_ok_at, updated_at) + VALUES ($1, $2, 'admin-openai', 'ok', 0, + NOW() - ($3 || ' minutes')::interval, NOW())`, + [`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)], + ); + } else { + await pool.query( + `INSERT INTO provider_health + (id, user_id, provider_type, last_result, last_status, + consecutive_failures, last_failure_at, next_retry_at, updated_at) + VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1, + NOW(), NOW() + interval '1 hour', NOW())`, + [`e2e-ph-${id}`, id], + ); + } + } finally { + await pool.end(); + } +} + +/** + * The ledger holds nothing for either shared tag, with the operator's key + * still in place: a brand-new instance before its first AI call, which is + * what every deployment looks like until somebody makes one. + */ +export async function clearSharedProviderResult(): Promise { + const pool = connect(); + try { + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + } finally { + await pool.end(); + } +} + +/** No receipt on file — the state a fresh account reaches the screen in. */ +export async function clearAiConsent(username: string): Promise { + const pool = connect(); + try { + const id = await userId(pool, username); + await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [ + id, + ]); + } finally { + await pool.end(); + } +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 13bfccc33..63dad8a79 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -480,6 +480,21 @@ export const E2E_SETUP_A11Y = { role: "USER", } as const; +/** + * v1.38.19 — the connect-source journey's account. + * + * The one arm of the flow that renders a claim about real account state had + * no journey that reached it: bp answers `manual`, visit answers `file`, and + * the child path ends at confirm. This account answers Q4 with a wearable, so + * `/onboarding/first-result` really does render the connect step. + */ +export const E2E_SETUP_CONNECT = { + email: "e2e-setup-connect@healthlog.test", + username: "e2e-setup-connect", + password: "Cn6!Zx4tB9wQm3Jf", + role: "USER", +} as const; + export const E2E_SETUP_LOCALE = { email: "e2e-setup-locale@healthlog.test", username: "e2e-setup-locale", @@ -487,6 +502,21 @@ export const E2E_SETUP_LOCALE = { role: "USER", } as const; +/** + * v1.38.19 — the shared-provider offer's own account. + * + * Its journey seeds INSTANCE-wide state (the operator key's presence and the + * provider-health ledger the tri-state folds) and mints a consent receipt on + * the account, so it cannot share one with a journey that asserts a module + * map or a tile order. + */ +export const E2E_SETUP_AI_OFFER = { + email: "e2e-setup-ai-offer@healthlog.test", + username: "e2e-setup-ai-offer", + password: "Ao6!Zd3fB9qMv2Rk", + role: "USER", +} as const; + export const SETUP_BP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupBp.json", @@ -507,10 +537,18 @@ export const SETUP_A11Y_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupA11y.json", ); +export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupConnect.json", +); export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", ); +export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupAiOffer.json", +); const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_BP, @@ -519,6 +557,8 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_AI_OFFER, + E2E_SETUP_CONNECT, ] as const; async function hashPassword(password: string): Promise { @@ -1261,6 +1301,8 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); + await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); + await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/messages/de.json b/messages/de.json index bc23d2f08..0248279e8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -413,6 +413,11 @@ "loginFailed": "Anmeldung fehlgeschlagen", "registerFailed": "Registrierung fehlgeschlagen", "invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", + "alreadySignedIn": { + "title": "Du bist bereits angemeldet", + "body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.", + "signOut": "Abmelden und Einladung annehmen" + }, "createAccount": "Konto erstellen", "newAccount": "Neues Konto erstellen", "backToPasskey": "Zurück zu Passkey", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.", "setupCta": "Einrichten (etwa eine Minute)", - "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig." + "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig.", + "offer": { + "title": "Die Zusammenfassung dieser Instanz ist bereit.", + "body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.", + "cta": "Klartext einschalten", + "granted": "Eingeschaltet. Die erste Zusammenfassung erscheint auf dem Dashboard.", + "error": "Das Einschalten hat nicht geklappt. Versuch es gleich noch einmal." + }, + "unavailable": { + "title": "Der Anbieter dieser Instanz antwortet gerade nicht.", + "body": "HealthLog funktioniert ohne Klartext vollständig. Du kannst später in den Einstellungen einen eigenen Anbieter hinterlegen." + } }, "steps": { "who": "Für wen diese Akte ist", @@ -4661,6 +4677,7 @@ "alwaysOn": "Immer an: {modules} und die Kernwerte (Gewicht, Blutdruck, Puls).", "everythingElse": "Alles andere bleibt einen Klick entfernt unter", "modulesLink": "Einstellungen, Module", + "switchesOff": "Nicht mehr in der Navigation: {modules}. Jederzeit unter Einstellungen, Module wieder einschaltbar.", "glucoseUnit": "Blutzucker", "unitPreference": "Einheiten", "language": "Sprache", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "{target} verbinden", "body": "Eine Verbindung, aus deinen Antworten gewählt. Danach kommen die Werte von selbst.", - "howTo": "Die Verbindung wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.", + "howTo": "Die Verbindung zu {target} wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.", "cta": "{target} verbinden", "checking": "Verbindung wird geprüft.", "waiting": "Noch nicht verbunden.", - "done": "{target} ist verbunden.", - "syncing": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen." + "openSettings": "Einstellungen → Integrationen öffnen", + "noDate": "unbekannt", + "needsCredentials": { + "title": "Diese Instanz hat noch keine {target}-App hinterlegt.", + "detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten." + }, + "state": { + "pending_first_sync": { + "title": "{target} ist verbunden.", + "detail": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen." + }, + "fresh": { + "title": "{target} ist schon verbunden.", + "detail": "Letzter Wert {when}. Hier gibt es nichts mehr zu tun." + }, + "stale": { + "title": "{target} ist verbunden, aber es kommt gerade nichts an.", + "detail": "Letzter Wert {when}. Die Verbindung selbst ist in Ordnung." + }, + "stalled": { + "title": "{target} ist verbunden, aber es wird nicht mehr synchronisiert.", + "detail": "Letzter Versuch {when}. Prüfe die Verbindung unter Einstellungen → Integrationen." + }, + "failing": { + "title": "{target} ist verbunden, aber die Synchronisierung schlägt fehl.", + "detail": "Ohne Erfolg seit {when}. Prüfe die Verbindung unter Einstellungen → Integrationen." + }, + "reauth_required": { + "title": "Die Verbindung zu {target} muss erneuert werden.", + "detail": "Einmal neu anmelden, dann laufen die Werte weiter." + }, + "parked": { + "title": "Die Verbindung zu {target} ist pausiert.", + "detail": "Prüfe die Verbindung unter Einstellungen → Integrationen." + } + } }, "add-medication": { "title": "Dein erstes Medikament hinzufügen", @@ -10322,6 +10373,7 @@ "invite": { "title": "Jemanden einladen", "description": "Vor der Zustimmung wird nichts geteilt.", + "needsAccount": "Die Person braucht ein eigenes HealthLog-Konto auf dieser Instanz und findet die Einladung dort unter Einstellungen → Geteilter Zugriff.", "identifierLabel": "Benutzername oder E-Mail", "identifierPlaceholder": "Der Name, mit dem sie sich anmeldet", "identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..c2914500e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -413,6 +413,11 @@ "loginFailed": "Login failed", "registerFailed": "Registration failed", "invitedBanner": "You have been invited — create your account even while registration is otherwise closed.", + "alreadySignedIn": { + "title": "You are already signed in", + "body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.", + "signOut": "Sign out and accept the invitation" + }, "createAccount": "Create account", "newAccount": "Create new account", "backToPasskey": "Back to Passkey", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "Connect a ChatGPT account — no per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.", "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.", "setupCta": "Set it up (about a minute)", - "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed." + "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed.", + "offer": { + "title": "This instance can write your summary in plain language.", + "body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.", + "cta": "Turn on plain language", + "granted": "Turned on. The first summary appears on your dashboard.", + "error": "Turning it on did not work. Give it another try in a moment." + }, + "unavailable": { + "title": "This instance’s provider is not answering right now.", + "body": "HealthLog is fully useful without the plain-language read. You can add a provider of your own later under Settings." + } }, "steps": { "who": "Who this record is for", @@ -4661,6 +4677,7 @@ "alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).", "everythingElse": "Everything else stays one click away under", "modulesLink": "Settings, Modules", + "switchesOff": "Out of the navigation: {modules}. You can switch any of them back on under Settings, Modules.", "glucoseUnit": "Blood glucose", "unitPreference": "Units", "language": "Language", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "Connect {target}", "body": "One connection, chosen from your answers. Readings then arrive on their own.", - "howTo": "The connection is made on the integrations page. Come back here afterwards; this screen notices when it is done.", + "howTo": "The connection to {target} is made on the integrations page. Come back here afterwards; this screen notices when it is done.", "cta": "Connect {target}", "checking": "Checking the connection.", "waiting": "Not connected yet.", - "done": "{target} is connected.", - "syncing": "The first sync is in progress. Readings appear on the dashboard as they arrive." + "openSettings": "Open Settings → Integrations", + "noDate": "unknown", + "needsCredentials": { + "title": "This instance has no {target} app on file yet.", + "detail": "Enter your own credentials under Settings → Integrations, then the connection can start." + }, + "state": { + "pending_first_sync": { + "title": "{target} is connected.", + "detail": "The first sync is in progress. Readings appear on the dashboard as they arrive." + }, + "fresh": { + "title": "{target} is already connected.", + "detail": "Last reading {when}. Nothing left to do here." + }, + "stale": { + "title": "{target} is connected, but nothing is arriving right now.", + "detail": "Last reading {when}. The connection itself is fine." + }, + "stalled": { + "title": "{target} is connected, but it has stopped syncing.", + "detail": "Last attempt {when}. Check the connection under Settings → Integrations." + }, + "failing": { + "title": "{target} is connected, but the sync keeps failing.", + "detail": "Nothing has come through since {when}. Check the connection under Settings → Integrations." + }, + "reauth_required": { + "title": "The connection to {target} needs renewing.", + "detail": "Sign in once more and the readings carry on." + }, + "parked": { + "title": "The connection to {target} is paused.", + "detail": "Check the connection under Settings → Integrations." + } + } }, "add-medication": { "title": "Add your first medication", @@ -10322,6 +10373,7 @@ "invite": { "title": "Invite someone", "description": "Nothing is shared until they accept.", + "needsAccount": "The person needs their own HealthLog account on this instance and will find the invitation under Settings → Shared access.", "identifierLabel": "Username or e-mail", "identifierPlaceholder": "The name they sign in with", "identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..6333bee7e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -413,6 +413,11 @@ "loginFailed": "No se ha podido iniciar sesión", "registerFailed": "No se ha podido completar el registro", "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", + "alreadySignedIn": { + "title": "Ya has iniciado sesión", + "body": "Has iniciado sesión como {username}. Una invitación crea una cuenta nueva. Cierra la sesión para aceptarla.", + "signOut": "Cerrar sesión y aceptar la invitación" + }, "createAccount": "Crear cuenta", "newAccount": "Crear cuenta nueva", "backToPasskey": "Volver a passkey", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "Conecta una cuenta de ChatGPT — sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.", "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan — la IA solo añade la lectura en lenguaje claro por encima.", "setupCta": "Configúralo (un minuto aprox.)", - "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración." + "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración.", + "offer": { + "title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.", + "body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.", + "cta": "Activar el lenguaje claro", + "granted": "Activado. El primer resumen aparecerá en tu panel.", + "error": "No se pudo activar. Vuelve a intentarlo en un momento." + }, + "unavailable": { + "title": "El proveedor de esta instancia no responde en este momento.", + "body": "HealthLog es plenamente útil sin el lenguaje claro. Más adelante puedes añadir tu propio proveedor en los ajustes." + } }, "steps": { "who": "Para quién es este registro", @@ -4661,6 +4677,7 @@ "alwaysOn": "Siempre activos: {modules} y las mediciones básicas (peso, presión arterial, pulso).", "everythingElse": "Todo lo demás queda a un clic en", "modulesLink": "Ajustes, Módulos", + "switchesOff": "Fuera de la navegación: {modules}. Puedes volver a activarlos cuando quieras en Ajustes, Módulos.", "glucoseUnit": "Glucosa en sangre", "unitPreference": "Unidades", "language": "Idioma", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "Conectar {target}", "body": "Una conexión, elegida según tus respuestas. Después las mediciones llegan solas.", - "howTo": "La conexión se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.", + "howTo": "La conexión con {target} se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.", "cta": "Conectar {target}", "checking": "Comprobando la conexión.", "waiting": "Aún no conectado.", - "done": "{target} está conectado.", - "syncing": "La primera sincronización está en marcha. Las mediciones aparecen en el panel a medida que llegan." + "openSettings": "Abrir Ajustes → Integraciones", + "noDate": "desconocido", + "needsCredentials": { + "title": "Esta instancia aún no tiene una aplicación de {target} registrada.", + "detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar." + }, + "state": { + "pending_first_sync": { + "title": "{target} está conectado.", + "detail": "La primera sincronización está en marcha. Los valores aparecerán en el panel en cuanto lleguen." + }, + "fresh": { + "title": "{target} ya está conectado.", + "detail": "Último valor {when}. Aquí no queda nada por hacer." + }, + "stale": { + "title": "{target} está conectado, pero ahora mismo no llega nada.", + "detail": "Último valor {when}. La conexión en sí está bien." + }, + "stalled": { + "title": "{target} está conectado, pero ha dejado de sincronizar.", + "detail": "Último intento {when}. Revisa la conexión en Ajustes → Integraciones." + }, + "failing": { + "title": "{target} está conectado, pero la sincronización falla.", + "detail": "No ha llegado nada desde {when}. Revisa la conexión en Ajustes → Integraciones." + }, + "reauth_required": { + "title": "Hay que renovar la conexión con {target}.", + "detail": "Inicia sesión una vez más y los valores seguirán llegando." + }, + "parked": { + "title": "La conexión con {target} está en pausa.", + "detail": "Revisa la conexión en Ajustes → Integraciones." + } + } }, "add-medication": { "title": "Añade tu primer medicamento", @@ -10322,6 +10373,7 @@ "invite": { "title": "Invitar a alguien", "description": "No se comparte nada hasta que acepte.", + "needsAccount": "La persona necesita su propia cuenta de HealthLog en esta instancia y encontrará la invitación en Ajustes → Acceso compartido.", "identifierLabel": "Usuario o correo electrónico", "identifierPlaceholder": "El nombre con el que inicia sesión", "identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..f1ac4a8f8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -413,6 +413,11 @@ "loginFailed": "Échec de la connexion", "registerFailed": "Échec de l’inscription", "invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.", + "alreadySignedIn": { + "title": "Tu es déjà connecté", + "body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.", + "signOut": "Se déconnecter et accepter l'invitation" + }, "createAccount": "Créer un compte", "newAccount": "Créer un nouveau compte", "backToPasskey": "Revenir à la passkey", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "Reliez un compte ChatGPT — sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.", "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous — l'IA n'ajoute que la lecture en langage clair par-dessus.", "setupCta": "Configurez-le (environ une minute)", - "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire." + "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire.", + "offer": { + "title": "Cette instance peut rédiger votre résumé en langage clair.", + "body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.", + "cta": "Activer le langage clair", + "granted": "Activé. Le premier résumé apparaîtra sur votre tableau de bord.", + "error": "L’activation n’a pas abouti. Réessayez dans un instant." + }, + "unavailable": { + "title": "Le fournisseur de cette instance ne répond pas pour le moment.", + "body": "HealthLog reste pleinement utile sans le langage clair. Vous pourrez ajouter votre propre fournisseur plus tard dans les paramètres." + } }, "steps": { "who": "À qui appartient ce dossier", @@ -4661,6 +4677,7 @@ "alwaysOn": "Toujours actifs : {modules}, et les mesures de base (poids, tension, pouls).", "everythingElse": "Tout le reste reste à un clic dans", "modulesLink": "Réglages, Modules", + "switchesOff": "Hors de la navigation : {modules}. Tu peux les réactiver à tout moment dans Réglages, Modules.", "glucoseUnit": "Glycémie", "unitPreference": "Unités", "language": "Langue", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "Connecter {target}", "body": "Une seule connexion, choisie d'après vos réponses. Les mesures arrivent ensuite d'elles-mêmes.", - "howTo": "La connexion se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.", + "howTo": "La connexion à {target} se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.", "cta": "Connecter {target}", "checking": "Vérification de la connexion.", "waiting": "Pas encore connecté.", - "done": "{target} est connecté.", - "syncing": "La première synchronisation est en cours. Les mesures apparaissent sur le tableau de bord au fur et à mesure." + "openSettings": "Ouvrir Paramètres → Intégrations", + "noDate": "inconnue", + "needsCredentials": { + "title": "Cette instance n’a pas encore d’application {target} enregistrée.", + "detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer." + }, + "state": { + "pending_first_sync": { + "title": "{target} est connecté.", + "detail": "La première synchronisation est en cours. Les valeurs apparaîtront sur le tableau de bord dès leur arrivée." + }, + "fresh": { + "title": "{target} est déjà connecté.", + "detail": "Dernière valeur {when}. Il n’y a plus rien à faire ici." + }, + "stale": { + "title": "{target} est connecté, mais rien n’arrive en ce moment.", + "detail": "Dernière valeur {when}. La connexion elle-même est en ordre." + }, + "stalled": { + "title": "{target} est connecté, mais la synchronisation ne tourne plus.", + "detail": "Dernière tentative {when}. Vérifiez la connexion dans Paramètres → Intégrations." + }, + "failing": { + "title": "{target} est connecté, mais la synchronisation échoue.", + "detail": "Plus rien n’est arrivé depuis {when}. Vérifiez la connexion dans Paramètres → Intégrations." + }, + "reauth_required": { + "title": "La connexion à {target} doit être renouvelée.", + "detail": "Reconnectez-vous une fois et les valeurs reprendront." + }, + "parked": { + "title": "La connexion à {target} est en pause.", + "detail": "Vérifiez la connexion dans Paramètres → Intégrations." + } + } }, "add-medication": { "title": "Ajoutez votre premier médicament", @@ -10322,6 +10373,7 @@ "invite": { "title": "Inviter quelqu'un", "description": "Rien n'est partagé avant son acceptation.", + "needsAccount": "La personne a besoin de son propre compte HealthLog sur cette instance et trouvera l'invitation dans Paramètres → Accès partagé.", "identifierLabel": "Nom d'utilisateur ou e-mail", "identifierPlaceholder": "Le nom avec lequel elle se connecte", "identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.", diff --git a/messages/it.json b/messages/it.json index d965b3827..39910e728 100644 --- a/messages/it.json +++ b/messages/it.json @@ -413,6 +413,11 @@ "loginFailed": "Accesso non riuscito", "registerFailed": "Registrazione non riuscita", "invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.", + "alreadySignedIn": { + "title": "Hai già effettuato l'accesso", + "body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.", + "signOut": "Esci e accetta l'invito" + }, "createAccount": "Crea account", "newAccount": "Crea un nuovo account", "backToPasskey": "Torna a passkey", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "Collega un account ChatGPT — nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.", "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti — l'IA aggiunge solo la lettura in linguaggio chiaro.", "setupCta": "Configuralo (circa un minuto)", - "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria." + "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria.", + "offer": { + "title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.", + "body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.", + "cta": "Attiva le parole semplici", + "granted": "Attivato. Il primo riepilogo comparirà sulla tua dashboard.", + "error": "L’attivazione non è riuscita. Riprova tra un momento." + }, + "unavailable": { + "title": "Il fornitore di questa istanza al momento non risponde.", + "body": "HealthLog resta pienamente utile senza le parole semplici. Potrai aggiungere un fornitore tuo più avanti nelle impostazioni." + } }, "steps": { "who": "A chi appartiene questa cartella", @@ -4661,6 +4677,7 @@ "alwaysOn": "Sempre attivi: {modules} e le misurazioni di base (peso, pressione, polso).", "everythingElse": "Tutto il resto resta a un clic in", "modulesLink": "Impostazioni, Moduli", + "switchesOff": "Fuori dalla navigazione: {modules}. Puoi riattivarli quando vuoi in Impostazioni, Moduli.", "glucoseUnit": "Glicemia", "unitPreference": "Unità", "language": "Lingua", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "Collega {target}", "body": "Una sola connessione, scelta in base alle tue risposte. Poi le misurazioni arrivano da sole.", - "howTo": "La connessione si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.", + "howTo": "La connessione a {target} si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.", "cta": "Collega {target}", "checking": "Verifica della connessione in corso.", "waiting": "Non ancora collegato.", - "done": "{target} è collegato.", - "syncing": "La prima sincronizzazione è in corso. Le misurazioni compaiono nella dashboard man mano che arrivano." + "openSettings": "Apri Impostazioni → Integrazioni", + "noDate": "sconosciuto", + "needsCredentials": { + "title": "Questa istanza non ha ancora un’app {target} registrata.", + "detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire." + }, + "state": { + "pending_first_sync": { + "title": "{target} è collegato.", + "detail": "La prima sincronizzazione è in corso. I valori compaiono sulla dashboard appena arrivano." + }, + "fresh": { + "title": "{target} è già collegato.", + "detail": "Ultimo valore {when}. Qui non resta nulla da fare." + }, + "stale": { + "title": "{target} è collegato, ma al momento non arriva nulla.", + "detail": "Ultimo valore {when}. La connessione in sé è a posto." + }, + "stalled": { + "title": "{target} è collegato, ma non sincronizza più.", + "detail": "Ultimo tentativo {when}. Controlla la connessione in Impostazioni → Integrazioni." + }, + "failing": { + "title": "{target} è collegato, ma la sincronizzazione non riesce.", + "detail": "Non arriva nulla da {when}. Controlla la connessione in Impostazioni → Integrazioni." + }, + "reauth_required": { + "title": "La connessione a {target} va rinnovata.", + "detail": "Accedi ancora una volta e i valori riprendono ad arrivare." + }, + "parked": { + "title": "La connessione a {target} è in pausa.", + "detail": "Controlla la connessione in Impostazioni → Integrazioni." + } + } }, "add-medication": { "title": "Aggiungi il tuo primo farmaco", @@ -10322,6 +10373,7 @@ "invite": { "title": "Invita qualcuno", "description": "Nulla viene condiviso finché non accetta.", + "needsAccount": "La persona ha bisogno di un proprio account HealthLog su questa istanza e troverà l'invito in Impostazioni → Accesso condiviso.", "identifierLabel": "Nome utente o e-mail", "identifierPlaceholder": "Il nome con cui accede", "identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..a6a81dc1c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -413,6 +413,11 @@ "loginFailed": "로그인 실패", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", + "alreadySignedIn": { + "title": "이미 로그인되어 있습니다", + "body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.", + "signOut": "로그아웃하고 초대 수락하기" + }, "createAccount": "계정 만들기", "newAccount": "새 계정 만들기", "backToPasskey": "패스키로 돌아가기", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.", "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고 — AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", "setupCta": "설정하기 (1분 정도)", - "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요." + "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요.", + "offer": { + "title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.", + "body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.", + "cta": "쉬운 말 요약 켜기", + "granted": "켜졌습니다. 첫 요약은 대시보드에 표시됩니다.", + "error": "켜지지 않았습니다. 잠시 후에 다시 시도해 주세요." + }, + "unavailable": { + "title": "이 인스턴스의 공급자가 지금은 응답하지 않습니다.", + "body": "쉬운 말 요약 없이도 HealthLog는 완전히 쓸모가 있습니다. 나중에 설정에서 직접 공급자를 등록할 수 있습니다." + } }, "steps": { "who": "이 기록의 대상", @@ -4661,6 +4677,7 @@ "alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).", "everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:", "modulesLink": "설정, 모듈", + "switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.", "glucoseUnit": "혈당", "unitPreference": "단위", "language": "언어", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "{target} 연결", "body": "답변을 바탕으로 고른 연결 하나입니다. 이후 측정값은 저절로 들어옵니다.", - "howTo": "연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.", + "howTo": "{target} 연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.", "cta": "{target} 연결", "checking": "연결을 확인하는 중입니다.", "waiting": "아직 연결되지 않았습니다.", - "done": "{target}이(가) 연결되었습니다.", - "syncing": "첫 동기화가 진행 중입니다. 측정값이 도착하는 대로 대시보드에 나타납니다." + "openSettings": "설정 → 연동 열기", + "noDate": "알 수 없음", + "needsCredentials": { + "title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.", + "detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다." + }, + "state": { + "pending_first_sync": { + "title": "{target}이(가) 연결되었습니다.", + "detail": "첫 동기화가 진행 중입니다. 값이 도착하는 대로 대시보드에 표시됩니다." + }, + "fresh": { + "title": "{target}은(는) 이미 연결되어 있습니다.", + "detail": "마지막 값 {when}. 여기서 더 할 일은 없습니다." + }, + "stale": { + "title": "{target}은(는) 연결되어 있지만 지금은 값이 들어오지 않습니다.", + "detail": "마지막 값 {when}. 연결 자체에는 문제가 없습니다." + }, + "stalled": { + "title": "{target}은(는) 연결되어 있지만 동기화가 멈췄습니다.", + "detail": "마지막 시도 {when}. 설정 → 연동에서 연결을 확인하세요." + }, + "failing": { + "title": "{target}은(는) 연결되어 있지만 동기화가 계속 실패합니다.", + "detail": "{when} 이후로 아무것도 들어오지 않았습니다. 설정 → 연동에서 연결을 확인하세요." + }, + "reauth_required": { + "title": "{target} 연결을 갱신해야 합니다.", + "detail": "한 번 다시 로그인하면 값이 계속 들어옵니다." + }, + "parked": { + "title": "{target} 연결이 일시 중지되었습니다.", + "detail": "설정 → 연동에서 연결을 확인하세요." + } + } }, "add-medication": { "title": "첫 약 추가하기", @@ -10322,6 +10373,7 @@ "invite": { "title": "다른 사람 초대", "description": "상대가 수락하기 전에는 아무것도 공유되지 않아요.", + "needsAccount": "상대방에게는 이 인스턴스의 HealthLog 계정이 필요하며, 초대는 설정 → 공유 접근에서 확인할 수 있습니다.", "identifierLabel": "사용자 이름 또는 이메일", "identifierPlaceholder": "로그인에 쓰는 이름", "identifierHint": "이 인스턴스에 계정이 있어야 해요. 가입하지 않은 이메일 주소로는 초대를 보낼 수 없어요.", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..405ec5665 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -413,6 +413,11 @@ "loginFailed": "Logowanie nie powiodło się", "registerFailed": "Rejestracja nie powiodła się", "invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.", + "alreadySignedIn": { + "title": "Masz już zalogowaną sesję", + "body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.", + "signOut": "Wyloguj się i przyjmij zaproszenie" + }, "createAccount": "Utwórz konto", "newAccount": "Utwórz nowe konto", "backToPasskey": "Wróć do passkey", @@ -4553,7 +4558,18 @@ "ladderOauthBody": "Podłącz konto ChatGPT — bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.", "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają — AI dokłada tylko odczyt prostym językiem.", "setupCta": "Skonfiguruj (około minuty)", - "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji." + "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji.", + "offer": { + "title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.", + "body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.", + "cta": "Włącz zwykły język", + "granted": "Włączone. Pierwsze podsumowanie pojawi się na pulpicie.", + "error": "Włączenie się nie powiodło. Spróbuj ponownie za chwilę." + }, + "unavailable": { + "title": "Dostawca tej instancji w tej chwili nie odpowiada.", + "body": "HealthLog jest w pełni użyteczny bez zwykłego języka. Później możesz dodać w ustawieniach własnego dostawcę." + } }, "steps": { "who": "Dla kogo jest ta kartoteka", @@ -4661,6 +4677,7 @@ "alwaysOn": "Zawsze włączone: {modules} oraz podstawowe pomiary (waga, ciśnienie, tętno).", "everythingElse": "Cała reszta jest o jedno kliknięcie w", "modulesLink": "Ustawienia, Moduły", + "switchesOff": "Znika z nawigacji: {modules}. W każdej chwili możesz je włączyć w Ustawienia, Moduły.", "glucoseUnit": "Glukoza we krwi", "unitPreference": "Jednostki", "language": "Język", @@ -4677,12 +4694,46 @@ "connect-source": { "title": "Połącz {target}", "body": "Jedno połączenie, wybrane na podstawie Twoich odpowiedzi. Potem pomiary przychodzą same.", - "howTo": "Połączenie nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.", + "howTo": "Połączenie z {target} nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.", "cta": "Połącz {target}", "checking": "Sprawdzanie połączenia.", "waiting": "Jeszcze nie połączono.", - "done": "{target} jest połączone.", - "syncing": "Trwa pierwsza synchronizacja. Pomiary pojawią się na panelu, gdy tylko nadejdą." + "openSettings": "Otwórz Ustawienia → Integracje", + "noDate": "nieznany", + "needsCredentials": { + "title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.", + "detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć." + }, + "state": { + "pending_first_sync": { + "title": "{target} jest połączony.", + "detail": "Trwa pierwsza synchronizacja. Wartości pojawią się na pulpicie, gdy tylko dotrą." + }, + "fresh": { + "title": "{target} jest już połączony.", + "detail": "Ostatnia wartość {when}. Tutaj nie ma już nic do zrobienia." + }, + "stale": { + "title": "{target} jest połączony, ale w tej chwili nic nie przychodzi.", + "detail": "Ostatnia wartość {when}. Samo połączenie jest w porządku." + }, + "stalled": { + "title": "{target} jest połączony, ale synchronizacja się zatrzymała.", + "detail": "Ostatnia próba {when}. Sprawdź połączenie w Ustawieniach → Integracje." + }, + "failing": { + "title": "{target} jest połączony, ale synchronizacja się nie udaje.", + "detail": "Od {when} nic nie dotarło. Sprawdź połączenie w Ustawieniach → Integracje." + }, + "reauth_required": { + "title": "Połączenie z {target} wymaga odnowienia.", + "detail": "Zaloguj się jeszcze raz, a wartości popłyną dalej." + }, + "parked": { + "title": "Połączenie z {target} jest wstrzymane.", + "detail": "Sprawdź połączenie w Ustawieniach → Integracje." + } + } }, "add-medication": { "title": "Dodaj swój pierwszy lek", @@ -10322,6 +10373,7 @@ "invite": { "title": "Zaproś kogoś", "description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje.", + "needsAccount": "Ta osoba potrzebuje własnego konta HealthLog na tej instancji i znajdzie zaproszenie w Ustawienia → Udostępniony dostęp.", "identifierLabel": "Nazwa użytkownika lub e-mail", "identifierPlaceholder": "Nazwa, którą się loguje", "identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.", diff --git a/playwright.config.ts b/playwright.config.ts index a6231feed..a09ee667f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -232,7 +232,20 @@ export default defineConfig({ "setup-flow-visit.spec.ts", "setup-flow-child.spec.ts", "setup-flow-a11y.spec.ts", + "setup-flow-connect.spec.ts", "setup-flow-locale-overflow.spec.ts", + // The shared-provider offer journey states INSTANCE-wide facts to + // reason about: it writes the operator key into `app_settings` and + // replaces every `provider_health` row for both shared tags, then + // puts both back. Serial mode only orders tests inside one file, so + // a second project would hold and clear that state underneath the + // first — and underneath any other spec that reads AI availability. + "onboarding-ai-offer.spec.ts", + // The invitation journey mints its token and registers from a + // cookie-less window. Two projects registering from one address + // share the five-per-fifteen-minutes budget on `auth:register`, so + // the second one meets a rate limit rather than the form. + "invite-registration.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/__tests__/profile-rejection-surface-guard.test.ts b/src/__tests__/profile-rejection-surface-guard.test.ts index 7bed465fd..e0b12d002 100644 --- a/src/__tests__/profile-rejection-surface-guard.test.ts +++ b/src/__tests__/profile-rejection-surface-guard.test.ts @@ -31,12 +31,19 @@ const SRC = join(process.cwd(), "src"); const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"]; function sourceFiles(): string[] { - return walkSourceFiles(SRC, { floor: 3000 }) - .filter((p) => !p.startsWith("generated/")) - .filter((p) => !p.startsWith("app/api/")) - .filter((p) => !p.includes("__tests__")) - .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) - .sort(); + return ( + walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.startsWith("app/api/")) + // v1.39 — the edge proxy names routes and methods as DATA + // (the demo-mode mutation allowlist is a list of path/method pairs), and + // a pair reads to the matcher below exactly like a request expression. It + // submits nothing and has no answer to read. + .filter((p) => p !== "proxy.ts") + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort() + ); } function read(rel: string): string { diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index d51c61082..7853d5751 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -5,11 +5,16 @@ import { join, sep } from "node:path"; /** * Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy - * 403s every non-GET `/api/*` request except a narrow allowlist. Login - * is the historical baseline; the two dashboard display-pref writes - * (chart-overlay toggles + comparison-baseline selector) were added so - * the above-chart toggles work in the demo. They touch nothing but the - * caller's own `User.dashboardWidgetsJson` blob. + * 403s every non-GET `/api/*` request except a narrow allowlist. Login is + * the historical baseline; the rest are the writes the demo has to make to + * walk its own flows — the two dashboard display-pref blobs, the setup + * flow's closed-enum answers and checkpoints, and the baseline profile + * fields. None carries health data or free text. + * + * The demo is one published account every visitor signs into, so what a + * visitor writes is what the next visitor finds. The refusals below are as + * much of the contract as the admissions: free text that reaches the Coach + * prompt, and anything that creates a second record, stay out. * * This guard locks the allowlist shape in place so a refactor can't * silently widen it (admitting a data-bearing mutation) or narrow it @@ -106,6 +111,49 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(missing, "onboarding writes the demo cannot make").toEqual([]); }); + it("permits the profile write the baseline step makes", () => { + // v1.39 — a demo visitor who typed a height got a 403 and a generic + // toast, because the confirm screen's own write was not on the list. + // Date of birth, height and sex are closed, validated fields on the + // account's own record and no health data, so the step can complete. + expect( + proxy(makeRequest("/api/auth/profile", "PUT")).status, + "PUT /api/auth/profile is blocked in the demo", + ).not.toBe(403); + }); + + it("refuses the free-text self-context write, which the demo shares", () => { + // The demo is ONE published account every visitor signs into. Free text + // that persists for the next visitor is not a demo affordance, and this + // particular text is fed into the Coach prompt — an anonymous write into + // an LLM prompt on a public instance, held until the next reseed. The + // anamnesis card renders read-only in the demo instead, so nothing here + // reaches a visitor as a failed save. + expect(proxy(makeRequest("/api/coach/about-me", "PUT")).status).toBe(403); + }); + + it("refuses the managed-profile create, which needs a factor the demo has not got", () => { + // `POST /api/managed-profiles` opens with `requireFreshMfa`. The demo + // account has no second factor, so admitting it at the edge buys the + // guardian arm nothing: the call is refused a layer later either way, + // and the confirm screen already says the profile could not be created + // without one. + expect(proxy(makeRequest("/api/managed-profiles", "POST")).status).toBe( + 403, + ); + }); + + it("does not drag in a sibling verb on the baseline paths", () => { + expect(proxy(makeRequest("/api/auth/profile", "DELETE")).status).toBe(403); + expect(proxy(makeRequest("/api/managed-profiles", "DELETE")).status).toBe( + 403, + ); + // The per-profile routes are a different path and stay closed. + expect( + proxy(makeRequest("/api/managed-profiles/abc", "DELETE")).status, + ).toBe(403); + }); + it("still blocks a health-data mutation (POST /api/measurements)", () => { const res = proxy(makeRequest("/api/measurements", "POST")); expect(res.status).toBe(403); diff --git a/src/__tests__/proxy-invite-public.test.ts b/src/__tests__/proxy-invite-public.test.ts index feb7aee54..d7c2df685 100644 --- a/src/__tests__/proxy-invite-public.test.ts +++ b/src/__tests__/proxy-invite-public.test.ts @@ -8,6 +8,16 @@ import { NextRequest } from "next/server"; * visitor (scanning the admin QR) must reach it without the auth-gate * bounce to `/auth/login`. This guard locks the allowlist entry in place * so a future refactor of `src/proxy.ts` cannot silently re-gate it. + * + * v1.38.19 — the redirect moved here, to the edge. It used to be emitted by + * `src/app/invite/[token]/page.tsx`, and Next does not answer that with a + * server 307: it streams the root layout first and puts the redirect in the + * flight payload, so `` mounted on `/invite/…`, read the pathname + * as a protected route and raced its own `router.replace("/auth/login")` + * against the redirect. Answering at the edge means there is no HTML, no + * hydration and no race — the behaviour the page's doc comment already + * claimed. The page stays as the fallback for any path that bypasses the + * proxy matcher. */ vi.mock("@/lib/process-type", () => ({ @@ -33,19 +43,18 @@ function makeRequest( describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { it("does not 307 an unauthenticated /invite/ to /auth/login", () => { const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); - expect(res.status).toBe(200); - expect(res.headers.get("location")).toBeNull(); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); }); - it("admits a pending-onboarding session onto the invite landing", () => { + it("does not bounce a pending-onboarding session off the invite landing", () => { const res = proxy( makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1", hl_onboarding: "pending", }), ); - expect(res.status).toBe(200); - expect(res.headers.get("location")).toBeNull(); + expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); }); it("hardens the token-in-path surface against cache / index / referrer leaks", () => { @@ -67,3 +76,64 @@ describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { expect(res.headers.get("location")).toMatch(/\/auth\/login/); }); }); + +describe("proxy.ts /invite edge redirect (v1.38.19)", () => { + it("307s a shape-valid token straight onto the registration form", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); + + it("307s a malformed segment onto a bare registration form", () => { + // Never echo an attacker-controlled segment into the target — a + // malformed token behaves exactly like a visitor with no invite. + const res = proxy(makeRequest("/invite/not-a-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe("http://localhost/auth/register"); + }); + + it("ignores anything after the token segment", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}/extra`)); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); + + it.each([ + ["an uppercase hex body", `hlv_${"A".repeat(64)}`], + ["a short body", `hlv_${"a".repeat(63)}`], + ["a long body", `hlv_${"a".repeat(65)}`], + ["the wrong prefix", `hlk_${"a".repeat(64)}`], + ["an empty segment", ""], + ])("drops %s rather than echoing it into the target", (_label, segment) => { + const res = proxy(makeRequest(`/invite/${segment}`)); + expect(res.headers.get("location")).toBe("http://localhost/auth/register"); + }); + + it("keeps the token hardening on the malformed redirect too", () => { + const res = proxy(makeRequest("/invite/not-a-token")); + expect(res.headers.get("Cache-Control")).toBe( + "no-store, no-cache, must-revalidate", + ); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow"); + expect(res.headers.get("Referrer-Policy")).toBe("no-referrer"); + }); + + it("carries the baseline transport headers on the redirect hop", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("X-Frame-Options")).toBe("DENY"); + }); + + it("redirects a signed-in caller the same way, with no session read", () => { + const res = proxy( + makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1" }), + ); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); +}); diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts index d1af128c7..43f80ea02 100644 --- a/src/__tests__/record-settings-contract.test.ts +++ b/src/__tests__/record-settings-contract.test.ts @@ -171,6 +171,33 @@ describe("record settings contract", () => { ).toBe("error_reauth"); }); + it("reports a real credential with no ledger row as connected", () => { + // The connection map is the credential: this branch is only reached once + // it says a pipe exists. `unknown` on top of it means the sync ledger + // holds no row yet — an hourly provider connected minutes ago — and this + // projection's `state` is documented as "a pipe exists", not as liveness. + // Calling it "Not connected" invites a guardian to revoke the grant they + // just made. + const connected = { + withings: true, + whoop: false, + fitbit: false, + nightscout: false, + polar: false, + oura: false, + "google-health": false, + strava: false, + } as const; + + expect( + resolveManagedIntegrationState("unknown", connected, "withings"), + ).toBe("connected"); + // No credential still outranks it. + expect(resolveManagedIntegrationState("unknown", connected, "oura")).toBe( + "disconnected", + ); + }); + it("rejects a late response for another record in a second tab", () => { expect(() => assertRecordSettingsResponseForRecord( diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 855b7c650..324af37db 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -79,6 +79,12 @@ describe("S1 — the direct session-resolution set is frozen", () => { // the freshness comparison, which `requireAuth` does not project. "app/api/auth/native/complete/route.ts": "reads createdAt for the handoff freshness check", + // Anonymous self-registration. Probes for an already-valid session so an + // invitation opened from inside one is refused instead of minting a second + // account over the caller's own cookie. `requireAuth` is the wrong gate + // here twice over: it would throw 401 at the anonymous caller the route + // exists for, and this route wants the ABSENCE of a session. + "app/api/auth/register/route.ts": "refuses to register over a live session", // Anonymous IdP callback. Probes for an already-valid session so a // replayed callback redirects instead of minting a second identity. "app/api/auth/oidc/callback/route.ts": diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 870a5e1b2..d25cca103 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -31,8 +31,19 @@ vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn().mockResolvedValue(undefined), })); +// The invite ledger. A refused registration must never reach it — the +// assertion below is that `uses` is left alone, and the only way this route +// can touch it is through `consumeInviteToken`. +vi.mock("@/lib/auth/invite-token", () => ({ + consumeInviteToken: vi + .fn() + .mockResolvedValue({ ok: true, inviteId: "inv-1" }), + recordInviteConsumer: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/auth/password", () => ({ @@ -78,7 +89,8 @@ import { POST } from "../route"; import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; -import { createSession } from "@/lib/auth/session"; +import { createSession, getSession } from "@/lib/auth/session"; +import { consumeInviteToken } from "@/lib/auth/invite-token"; import { Prisma } from "@/generated/prisma/client"; function postReq(body: unknown): NextRequest { @@ -96,6 +108,7 @@ beforeEach(() => { allowed: true, ip: "1.2.3.4", } as never); + vi.mocked(getSession).mockResolvedValue(null as never); }); describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => { @@ -267,3 +280,97 @@ describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => expect(res.status).toBe(422); }); }); + +/** + * v1.38.19 — an invitation creates a NEW account, so it cannot be accepted + * from inside somebody's live session. + * + * Until this gate the route read no session at all: a request carrying a valid + * `healthlog_session` was treated exactly like an anonymous one, and the + * unconditional `createSession` at the end overwrote the caller's cookie with + * the fresh account's. The admin who clicked his own invite link to check it + * was signed out of his admin account and into a stranger's onboarding, with + * his previous session row left valid server-side and unreachable. + */ +describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => { + const invitedBody = { + email: "new@example.com", + username: "newuser", + password: "a-very-strong-password-123", + inviteToken: `hlv_${"a".repeat(64)}`, + }; + + // Everything the happy path needs is stubbed, so the ONLY reason this + // request can fail is the gate under test. Without it the same request + // answers 201 and mints a session over the caller's cookie. + beforeEach(() => { + vi.mocked(consumeInviteToken).mockResolvedValue({ + ok: true, + inviteId: "inv-1", + } as never); + vi.mocked(prisma.user.count).mockResolvedValue(1 as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + vi.mocked(hashPassword).mockResolvedValue("hashed" as never); + vi.mocked(prisma.$transaction).mockImplementation((( + fn: (tx: typeof prisma) => unknown, + ) => fn(prisma)) as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never); + vi.mocked(prisma.user.create).mockResolvedValue({ + id: "u1", + username: "newuser", + email: "new@example.com", + role: "USER", + } as never); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user: { id: "u-existing", username: "admin", email: "a@example.com" }, + } as never); + }); + + it("answers 409 already_authenticated", async () => { + const res = await postPathThrough(invitedBody); + expect(res.status).toBe(409); + const body = (await res.json()) as { + data: null; + error: string; + meta?: { errorCode?: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta?.errorCode).toBe("auth.already_authenticated"); + expect(body.error).toMatch(/sign/i); + }); + + it("creates no account", async () => { + await postPathThrough(invitedBody); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("does not burn a use of the invitation", async () => { + await postPathThrough(invitedBody); + expect(consumeInviteToken).not.toHaveBeenCalled(); + }); + + it("does not touch the caller's own session", async () => { + await postPathThrough(invitedBody); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("refuses before the rate-limit bucket is spent", async () => { + // The gate is a session read, not an attempt: a signed-in visitor + // clicking the link three times must not lock the door for the person + // the invitation is actually for, who shares the IP on a home network. + await postPathThrough(invitedBody); + expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled(); + }); + + it("still registers an anonymous caller", async () => { + // The control. Same request, no session — the invitation works. + vi.mocked(getSession).mockResolvedValue(null as never); + const res = await postPathThrough(invitedBody); + expect(res.status).toBe(201); + }); +}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 8189b0789..d3221afbc 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,7 +6,7 @@ import { } from "@/lib/auth/invite-token"; import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; -import { createSession } from "@/lib/auth/session"; +import { createSession, getSession } from "@/lib/auth/session"; import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { @@ -39,6 +39,31 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // v1.38.19 — an invitation creates a NEW account, so it can never be + // accepted from inside a live session. + // + // The route used to read no session at all: a request carrying a valid + // `healthlog_session` was handled exactly like an anonymous one, and the + // unconditional `createSession` at the end overwrote the caller's cookie + // with the fresh account's. An admin opening his own invite link to check it + // was signed out of his admin account and into a stranger's onboarding, his + // previous session row still valid server-side and no longer reachable. + // + // Refused BEFORE the rate limit on purpose: this is a state, not an attempt, + // and spending the 5-per-15-minutes bucket on it would lock the door for the + // person the invitation is actually for, who may well share the IP. + // `getSession` is the optional resolver — it answers `null` for an anonymous + // caller instead of throwing, which `requireAuth` would. + const existingSession = await getSession(); + if (existingSession) { + annotate({ action: { name: "auth.register.refused_authenticated" } }); + return apiError( + "Already signed in — sign out first to use an invitation", + 409, + { errorCode: "auth.already_authenticated" }, + ); + } + // v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain // is misconfigured; otherwise byte-equivalent per-IP semantics. const rl = await checkAuthSurfaceRateLimit( diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 31f546594..75f54a607 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -60,6 +60,7 @@ const whoopFind = prisma.whoopConnection.findUnique as ReturnType; type Entry = { integration: string; + state?: string; connected?: boolean; configured?: boolean; available?: boolean; @@ -344,3 +345,41 @@ describe("/api/integrations/status — the freshness read failed", () => { } }); }); + +/** + * v1.38.19 — the never-connected provider on the wire. + * + * The ledger's synthetic "no row" snapshot reads `unknown`, and the envelope + * publishes it verbatim. A client that reads `state` must see a value that + * cannot be mistaken for a live connection, while `syncHealth.verdict` keeps + * saying `disconnected` for the same entry. + */ +describe("/api/integrations/status — a provider with no ledger row", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(false); + }); + + it("publishes state unknown beside connected false and a disconnected verdict", async () => { + perIntegrationLedger.whoop = { + state: "unknown", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + failingSince: null, + }; + userFind.mockResolvedValue({}); + whoopFind.mockResolvedValue(null); + + const whoop = (await fetchEntries()).find( + (e) => e.integration === "whoop", + )!; + expect(whoop.state).toBe("unknown"); + expect(whoop.connected).toBe(false); + expect(whoop.syncHealth?.verdict).toBe("disconnected"); + }); +}); diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index a1421a944..40861013e 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -43,11 +43,33 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +// v1.38.19 — the four preconditions the honest offer rides on. +vi.mock("@/lib/ai/server-provider-health", () => ({ + readServerProviderHealth: vi.fn(), +})); + +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: vi.fn(), +})); + +vi.mock("@/lib/sharing/provider-work-authority", () => ({ + providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })), + providerCredentialPolicy: vi.fn(() => "personal"), +})); + +vi.mock("@/lib/ai/consent-guard", () => ({ + hasActiveConsentForSurface: vi.fn(), +})); + import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { resolveProviderAvailability } from "@/lib/ai/provider"; import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; +import { readServerProviderHealth } from "@/lib/ai/server-provider-health"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority"; +import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard"; function patchRequest(body: unknown): Request { return new Request("http://localhost/api/user/ai-provider", { @@ -70,6 +92,18 @@ interface AiProviderResponse { beforeEach(() => { vi.resetAllMocks(); + // The shape every GET needs before a case narrows it: an owning caller on + // an instance whose assistant surfaces are on, who has not consented yet. + vi.mocked(readServerProviderHealth).mockResolvedValue("unknown"); + vi.mocked(getAssistantFlags).mockResolvedValue({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + }); + vi.mocked(providerCredentialPolicy).mockReturnValue("personal"); + vi.mocked(hasActiveConsentForSurface).mockResolvedValue(false); }); describe("GET /api/user/ai-provider availability", () => { @@ -405,3 +439,150 @@ describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => { expect(JSON.stringify(body)).not.toContain("enc-gateway"); }); }); + +/** + * v1.38.19 — the honest shared-provider offer. + * + * The setup flow's last screen used to promise that insights "work for you + * right away" whenever `managedBy` said `server`. That is a presence read: + * it knows a key is configured, never that it works, and it says nothing + * about the consent receipt the egress actually needs. These three fields + * are what makes the promise checkable — and every one of the five + * preconditions below can only ever take the offer AWAY. + */ +describe("GET /api/user/ai-provider — the shared-provider offer", () => { + function serverManaged(): void { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + managedProfileAt: null, + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "server", + }); + } + + async function read(): Promise<{ + serverProviderHealth: string; + serverProviderOffer: boolean; + serverProviderConsent: boolean; + }> { + const res = await (GET as () => Promise)(); + const body = (await res.json()) as { + data: { + serverProviderHealth: string; + serverProviderOffer: boolean; + serverProviderConsent: boolean; + }; + }; + return body.data; + } + + it("offers the shared provider when it is demonstrably healthy and nothing forbids it", async () => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + await expect(read()).resolves.toMatchObject({ + serverProviderHealth: "healthy", + serverProviderOffer: true, + serverProviderConsent: false, + }); + }); + + it.each(["unhealthy", "unknown"] as const)( + "never offers on %s health — unknown is not a maybe", + async (health) => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue(health); + await expect(read()).resolves.toMatchObject({ + serverProviderHealth: health, + serverProviderOffer: false, + }); + }, + ); + + it("never offers when the operator switched the assistant surfaces off", async () => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + vi.mocked(getAssistantFlags).mockResolvedValue({ + enabled: true, + coach: false, + briefing: true, + insightStatus: true, + correlations: true, + }); + await expect(read()).resolves.toMatchObject({ + serverProviderHealth: "healthy", + serverProviderOffer: false, + }); + }); + + it("never offers to a managed profile — a child's record does not consent for itself", async () => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + vi.mocked(providerCredentialPolicy).mockReturnValue("operator-default"); + await expect(read()).resolves.toMatchObject({ serverProviderOffer: false }); + }); + + it("stops offering once the user holds a receipt, and says the consent is on file", async () => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + vi.mocked(hasActiveConsentForSurface).mockResolvedValue(true); + await expect(read()).resolves.toMatchObject({ + serverProviderOffer: false, + serverProviderConsent: true, + }); + }); + + it("never offers a provider the operator does not manage", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: "OPENAI", + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: "enc-openai", + managedProfileAt: null, + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "user", + }); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + await expect(read()).resolves.toMatchObject({ serverProviderOffer: false }); + }); + + it("reads the health without probing anything", async () => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + await read(); + expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledTimes(1); + expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledWith(); + }); + + it("never offers on a demo instance, where the grant cannot be made", async () => { + // The one tap posts `POST /api/consent/ai/web`, which the demo's edge + // allowlist refuses — and should: the demo is one shared account, so a + // receipt minted by one visitor would turn the operator's provider on + // for every later one. Offering a button that can only 403 into a + // generic toast is the misleading failure this screen exists to remove, + // so the offer is withheld and the screen falls to its neutral panel. + const previous = process.env.DEMO_MODE; + process.env.DEMO_MODE = "true"; + try { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + await expect(read()).resolves.toMatchObject({ + serverProviderHealth: "healthy", + serverProviderOffer: false, + }); + } finally { + if (previous === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = previous; + } + }); +}); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 524a32d01..21ef5c88e 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -11,6 +11,13 @@ import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; import { encrypt, decrypt } from "@/lib/crypto"; import { resolveProviderAvailability } from "@/lib/ai/provider"; +import { readServerProviderHealth } from "@/lib/ai/server-provider-health"; +import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { + providerCredentialPolicy, + providerWorkAuthorityForRecord, +} from "@/lib/sharing/provider-work-authority"; import { annotate } from "@/lib/logging/context"; import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; @@ -35,6 +42,9 @@ export const GET = apiHandler(async () => { aiCompatModel: true, // v1.22 (#89) aiResponseTimeoutSeconds: true, + // v1.38.19 — a managed profile is never the party that gives + // consent for itself; the offer below has to know. + managedProfileAt: true, }, }); @@ -45,6 +55,54 @@ export const GET = apiHandler(async () => { // `managedBy` reports the origin only; no admin keys/endpoints are leaked. const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id); + // ── v1.38.19 — the shared provider, honestly ──────────────── + // `managedBy: "server"` says the operator configured a key. It has never + // said the key WORKS, and the setup flow spent three releases promising + // that it did. It also says nothing about the consent receipt + // `consent-guard.ts` demands before any PHI reaches `admin-openai` / + // `admin-codex`, which is the decision a fresh account is actually + // missing — the provider was already chosen for them by the default chain. + // + // So three additive fields, and every one of them can only ever take the + // offer away: + // serverProviderHealth — the instance-wide tri-state, fail closed. + // serverProviderConsent — this user already holds a receipt. + // serverProviderOffer — all five preconditions hold at once. + // + // Nothing here probes a provider. The flow's rule ("nothing in the flow + // talks to an AI provider") holds: a probe would bill the OPERATOR for + // every registration on the instance. + const serverProviderHealth = await readServerProviderHealth(); + const serverProviderConsent = await hasActiveConsentForSurface( + user.id, + "coach", + ); + const assistantFlags = await getAssistantFlags(); + // `personal`, not merely "not deny": a guardian's own record resolves to + // `personal`, while a managed profile resolves to `operator-default` — + // its provider is the operator's by definition, and a child's record does + // not consent to its own PHI egress. Both must be excluded here, and only + // the positive form excludes both. + const credentialPolicy = providerCredentialPolicy( + providerWorkAuthorityForRecord(user.id), + u?.managedProfileAt ?? null, + ); + // A demo instance can show the offer but cannot honour it: the one tap + // posts `POST /api/consent/ai/web`, which the demo's edge allowlist + // refuses — and should, since the demo is one shared account and a receipt + // one visitor minted would turn the operator's provider on for every later + // one. A button that can only 403 into a generic toast is precisely the + // misleading failure this surface exists to remove, so there is no button. + const demoInstance = process.env.DEMO_MODE === "true"; + const serverProviderOffer = + !demoInstance && + managedBy === "server" && + serverProviderHealth === "healthy" && + assistantFlags.enabled && + assistantFlags.coach && + credentialPolicy === "personal" && + !serverProviderConsent; + return apiSuccess({ provider: u?.aiProvider ?? null, model: u?.aiModel ?? null, @@ -67,6 +125,11 @@ export const GET = apiHandler(async () => { hasCompatKey: Boolean(u?.aiCompatKeyEncrypted), // v1.22 (#89) — per-user response timeout, in seconds (null = default). responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null, + // v1.38.19 — see the block above. A tri-state and two booleans; + // no count, no timestamp and no other account is inferable from them. + serverProviderHealth, + serverProviderOffer, + serverProviderConsent, }); }); diff --git a/src/app/auth/register/__tests__/page.test.tsx b/src/app/auth/register/__tests__/page.test.tsx new file mode 100644 index 000000000..1ef5d0564 --- /dev/null +++ b/src/app/auth/register/__tests__/page.test.tsx @@ -0,0 +1,128 @@ +/** + * An invitation opened from inside a live session. + * + * `POST /api/auth/register` refuses that with 409 `already_authenticated` + * (see `src/app/api/auth/register/__tests__/route.test.ts`). The page must not + * make the visitor discover it by filling the form: a signed-in caller gets an + * explanation and a way out instead of three inputs that cannot succeed. + * + * SSR-only, per project convention (node environment, no DOM, no + * `@testing-library/react`), so what is asserted is the PAINT — which branch + * the page rendered. The sign-out click itself is driven in + * `e2e/invite-registration.spec.ts`. + * + * Mutation check, run: drop the signed-in branch → the two signed-in cases go + * red naming the form inputs they found, the anonymous case stays green. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const authRef = { + value: { + user: null as { username: string } | null, + isAuthenticated: false, + isLoading: false, + }, +}; +const inviteRef = { value: null as string | null }; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => ({ + get: (key: string) => (key === "invite" ? inviteRef.value : null), + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => authRef.value, + clearCachesForSessionEnd: vi.fn(), +})); + +import RegisterPage from "../page"; + +const TOKEN = `hlv_${"a".repeat(64)}`; + +function render(): string { + return renderToStaticMarkup( + + + + + , + ); +} + +function anonymous(invite: string | null): string { + authRef.value = { user: null, isAuthenticated: false, isLoading: false }; + inviteRef.value = invite; + return render(); +} + +function signedIn(invite: string | null, username = "marc"): string { + authRef.value = { + user: { username }, + isAuthenticated: true, + isLoading: false, + }; + inviteRef.value = invite; + return render(); +} + +describe("register page — an invitation cannot be accepted from a live session", () => { + it("renders the form for an anonymous visitor holding an invitation", () => { + const markup = anonymous(TOKEN); + expect(markup).toContain('data-testid="register-invite-banner"'); + expect(markup).toContain('id="username"'); + expect(markup).not.toContain('data-testid="register-already-signed-in"'); + }); + + it("replaces the form with the explaining panel for a signed-in visitor", () => { + const markup = signedIn(TOKEN); + expect(markup).toContain('data-testid="register-already-signed-in"'); + expect(markup).not.toContain('id="username"'); + expect(markup).not.toContain('id="password"'); + }); + + it("names the account the visitor is signed in as", () => { + const markup = signedIn(TOKEN, "grandma"); + expect(markup).toContain("grandma"); + }); + + it("offers the way out", () => { + const markup = signedIn(TOKEN); + expect(markup).toContain('data-testid="register-sign-out"'); + }); + + it("explains the same thing without an invitation in the URL", () => { + // Reaching /auth/register signed in is the same dead end with or without + // a token — the form below could only ever answer 409. + const markup = signedIn(null); + expect(markup).toContain('data-testid="register-already-signed-in"'); + }); + + it("holds the panel back while the session question is unanswered", () => { + // Fail closed the other way round: an unresolved `/api/auth/me` must not + // flash "you are already signed in" at somebody who is not. + authRef.value = { user: null, isAuthenticated: false, isLoading: true }; + inviteRef.value = TOKEN; + const markup = render(); + expect(markup).not.toContain('data-testid="register-already-signed-in"'); + }); + + it("renders the form when the auth probe failed and only a stale marker says otherwise", () => { + // `useAuth` deliberately holds the LAST-KNOWN state when `/api/auth/me` + // fails at the transport level or with a 5xx, so the shell does not sign + // people out on a blip. There is no account payload on that path, so the + // panel would name nobody ("You are signed in as .") and offer a sign-out + // that posts to the endpoint the same failure is affecting. An invited + // household member on a flaky connection could not register at all. + authRef.value = { user: null, isAuthenticated: true, isLoading: false }; + inviteRef.value = TOKEN; + const markup = render(); + expect(markup).not.toContain('data-testid="register-already-signed-in"'); + expect(markup).toContain('id="username"'); + }); +}); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 73c1f10a6..8b1bc9c34 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -3,7 +3,7 @@ import { useId, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, MailOpen, UserPlus } from "lucide-react"; +import { Loader2, LogOut, MailOpen, UserPlus } from "lucide-react"; import { Logo } from "@/components/ui/logo"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -13,7 +13,8 @@ import { PasswordStrength } from "@/components/ui/password-strength"; import { useTranslations } from "@/lib/i18n/context"; import { detectBrowserTimezone } from "@/lib/tz/format"; import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; export default function RegisterPage() { const router = useRouter(); @@ -23,6 +24,23 @@ export default function RegisterPage() { // admits the invited user; an open instance simply ignores it. const inviteToken = searchParams.get("invite"); const queryClient = useQueryClient(); + // v1.38.19 — an invitation creates a NEW account, so it cannot be accepted + // from inside a live session; `POST /api/auth/register` refuses one with + // 409 `already_authenticated`. Rendering the form anyway would let the + // visitor discover that by filling it in. + // + // The gate is the resolved account payload, not `isAuthenticated`. That + // flag deliberately holds the LAST-KNOWN state off a localStorage marker + // whenever `/api/auth/me` fails at the transport level or with a 5xx, so + // the shell does not sign people out on a blip — and on that path there is + // no `user`. Gating on it would show an invited household member behind a + // 502 a panel that names nobody and offers a sign-out posting to the very + // endpoint that is failing, with no way to register. A payload in hand is + // the only positive evidence of a session, and it is also absent while the + // probe is still in flight, so the panel cannot flash at somebody who is + // not signed in. + const { user } = useAuth(); + const [signingOut, setSigningOut] = useState(false); const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -69,6 +87,83 @@ export default function RegisterPage() { } } + async function handleSignOut() { + setError(null); + setSigningOut(true); + try { + await apiFetchRaw("/api/auth/logout", { method: "POST" }); + // Same session-end wipe the shell and the menu do: the QueryClient + // outlives the navigation on this SPA and the health-data families are + // not user-scoped, so nothing may survive into the next account. + clearCachesForSessionEnd(queryClient); + // Back to this page, invitation intact — NOT to the login screen the + // generic logout goes to. The point of the trip is to accept the + // invitation, and the token only rides the URL. + router.replace( + inviteToken + ? `/auth/register?invite=${encodeURIComponent(inviteToken)}` + : "/auth/register", + ); + router.refresh(); + } catch { + setError(t("common.networkError")); + } finally { + setSigningOut(false); + } + } + + if (user) { + return ( +
+
+
+
+ +
+

+ {t("auth.alreadySignedIn.title")} +

+

+ {t("auth.alreadySignedIn.body", { + username: user?.username ?? "", + })} +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); + } + return (
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index 73f4e8efe..9a2002b98 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -102,7 +102,16 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { return ; } if (screen === "confirm") { - return ; + // `DEMO_MODE` is a server-only env var; resolved here and threaded in, + // as the root layout does for the shell. The confirm screen's anamnesis + // card takes no input on a demo instance, whose account is shared and + // whose self-context write is off the edge allowlist. + return ( + + ); } if (screen === "first-result") { return ; diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 0e8a741ed..9047a519b 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -619,7 +619,10 @@ export function InviteTokensSection() { height={176} className="rounded-lg bg-white p-2" /> -

+

{minted.url}

@@ -176,12 +278,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { ) : null}

- - {t("onboarding.flow.confirm.changeInSettings")} - + {settingsReachable ? ( + + {t("onboarding.flow.confirm.changeInSettings")} + + ) : ( + + {t("onboarding.flow.confirm.changeInSettings")} + + )}

{forSomeoneElse ? ( @@ -196,7 +304,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { void finish(profile.id)} + onCreated={(profile) => finishReporting(profile.id)} />

{t("onboarding.flow.confirm.managedLater")}

@@ -212,7 +320,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { )} - {sharedKeyServes ? ( + {sharedKeyNote ? (

- {t("onboarding.ai.sharedKeyNote")} + {justGranted + ? t("onboarding.ai.offer.granted") + : t("onboarding.ai.sharedKeyNote")}

) : null} + {offerShared ? ( +
+

+ {t("onboarding.ai.offer.title")} +

+

+ {t("onboarding.ai.offer.body")} +

+ +
+ ) : null} + + {sharedUnavailable ? ( +
+

+ {t("onboarding.ai.unavailable.title")} +

+

+ {t("onboarding.ai.unavailable.body")} +

+
+ ) : null} +

{t("onboarding.ai.ladderTitle")} diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index d77894ab6..069d3d0d7 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; @@ -33,7 +33,10 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry"; import { AREA_PAGE_HREF, AREA_READING_TARGETS, + connectSourceView, + deliveringSources, SOURCE_INTEGRATION, + type ConnectSourceSlot, } from "@/lib/onboarding/first-result-config"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; @@ -68,7 +71,23 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { const { t } = useTranslations(); const router = useRouter(); const answer = useOnboardingAnswer(); - const offer = chooseFirstResultTask(state.needs); + // The server picked the order from the answers alone — it had no status in + // hand. Re-pick here once the envelope resolves: a source that is already + // delivering is not a task, and offering to connect it is the flow talking + // past an account it can see. The same query key the task body reads, so + // this costs no second request. + const statuses = useIntegrationStatuses(true); + const delivering = useMemo( + () => deliveringSources(statuses.data?.integrations), + [statuses.data], + ); + // …but only where there is somewhere to fall to. A person whose one answer + // is a source that is already delivering still gets an end to the flow: the + // screen acknowledges the connection it found rather than dropping the step + // and ending on nothing. + const offer = + chooseFirstResultTask(state.needs, delivering) ?? + chooseFirstResultTask(state.needs); const back = previousScreen(state, "first-result"); // Completed only when the LEDGER says so as well: a restart puts the step @@ -193,32 +212,92 @@ function ConnectSourceTask({ onComplete: (target: string | null) => Promise; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const { anchor, statusKey } = SOURCE_INTEGRATION[source]; const label = t(questionOptionLabelKey("sources", source)); // Refetches on window focus, so coming back from the integrations tab // after the handshake picks the fresh verdict up on its own. const statuses = useIntegrationStatuses(true); const status = pickStatus(statuses.data, statusKey as IntegrationKey); - const connected = status?.connected === true || status?.state === "connected"; + // The liveness truth is the server-resolved verdict and nothing else. The + // ledger's `state` is what the last ATTEMPT did, and its "no row" default + // read as `connected` until v1.38.19 — which is how a brand-new account was + // told its wearable was connected, and had this step stamped as achieved on + // its behalf. `null` means the envelope has not resolved: no claim, no tile. + const view = connectSourceView(source, status); + const settled = view?.settled ?? false; - // Record the result once the connection is there — once. `completed` flips - // only after the write resolves, and the status envelope can re-render this - // in between, so a ref latches the first call. + // Record the result once the connection is really there — once. `completed` + // flips only after the write resolves, and the status envelope can re-render + // this in between, so a ref latches the first call. const recorded = useRef(completed); useEffect(() => { - if (connected && !recorded.current) { + if (settled && !recorded.current) { recorded.current = true; void onComplete(source); } - }, [connected, onComplete, source]); + }, [settled, onComplete, source]); - if (completed || connected) { + const settingsHref = `/settings/integrations#${anchor}`; + + if (!view) { + // Nothing is known yet. The heading is the person's own answer, so it + // stays; the body says only that the check is running. + return ( +

+ {t("onboarding.flow.first-result.connect-source.checking")} +

+ ); + } + + const key = `onboarding.flow.first-result.connect-source.state.${view.verdict}`; + const when = view.when + ? fmt.dateTime(view.when) + : t("onboarding.flow.first-result.connect-source.noDate"); + + if (view.slot === "result") { return ( + ); + } + + if (view.slot === "attention") { + return ( + + ); + } + + if (view.slot === "credentials") { + return ( + ); } @@ -227,6 +306,8 @@ function ConnectSourceTask({

{t("onboarding.flow.first-result.connect-source.howTo", { @@ -234,21 +315,70 @@ function ConnectSourceTask({ })}

- {statuses.isLoading - ? t("onboarding.flow.first-result.connect-source.checking") - : t("onboarding.flow.first-result.connect-source.waiting")} + {t("onboarding.flow.first-result.connect-source.waiting")}

); } +/** + * A connection the flow cannot treat as its result: one that needs repairing + * (`attention`) or one the instance cannot start at all (`credentials`). Both + * say which state it is in and link at the one page that can change it — + * never a "Connect" button onto a card that has none. + */ +function ConnectNoticeTile({ + slot, + state, + testSlot, + title, + detail, + href, + cta, +}: { + slot: ConnectSourceSlot; + state: string; + testSlot: string; + title: string; + detail: string; + href: string; + cta: string; +}) { + return ( +
+

{title}

+

{detail}

+ {/* This CTA names a PATH — "Settings → Integrations" — which is the + longest label shape in the flow and the only button in the tree that + carries one. The button base is `whitespace-nowrap` with a fixed + height and `shrink-0`, so at 390 px a German or French rendering ran + 27 px past the viewport instead of shrinking or breaking. It is + bounded to the card (`w-full`), allowed to break between the words + of the path (`whitespace-normal`) and allowed to grow for the second + line (`h-auto`, with the 44 px tap target kept by `min-h-11`). */} + +
+ ); +} + /* ── add a medication ──────────────────────────────────────────────────── */ interface MedicationDetail { @@ -508,11 +638,22 @@ function LatestReadingTile({ * itself sits under it in content colour, because it is the reading, the * medication or the connection the person came for. */ -function ResultTile({ title, detail }: { title: string; detail?: string }) { +function ResultTile({ + title, + detail, + connect, +}: { + title: string; + detail?: string; + /** Set by the connect arm so a journey can read which verdict it painted. */ + connect?: { slot: ConnectSourceSlot; state: string }; +}) { return (
>({ queryKey: queryKeys.medications(), diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index b2fec48bd..e4be7672d 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -54,9 +54,15 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { const back = previousScreen(state, "units"); + const answerBody = unitsAnswerBody({ asked, glucoseUnit, unitPreference }); + async function persist( input: - ReturnType | { step: "units"; status: "skipped" }, + | NonNullable> + | { + step: "units"; + status: "skipped"; + }, ) { try { const written = await answer.mutateAsync(input); @@ -121,9 +127,10 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { void persist({ step: "units", status: "skipped" })} - onNext={() => - void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference })) - } + onNext={() => { + if (answerBody) void persist(answerBody); + }} + nextDisabled={answerBody === null} pending={answer.isPending} /> diff --git a/src/components/settings/access/__tests__/grant-invite-needs-account.test.tsx b/src/components/settings/access/__tests__/grant-invite-needs-account.test.tsx new file mode 100644 index 000000000..142bfe887 --- /dev/null +++ b/src/components/settings/access/__tests__/grant-invite-needs-account.test.tsx @@ -0,0 +1,51 @@ +/** + * The grant card says where the invitation will turn up. + * + * A sharing invitation carries no link and nothing is e-mailed: it is + * addressed to a username or e-mail of an account that already exists here, + * and the person accepts it from inside their own session (`POST + * /api/account/grants/[id]/accept`). The card said the first half — the input + * hint already names the account requirement — and left the second half + * unsaid, so an inviter who then goes looking for a link to send has nothing + * to find and no reason to stop looking. + * + * SSR-only, per project convention. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "owner", modules: {} }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { GrantInviteCard } from "../grant-invite-card"; +import en from "../../../../../messages/en.json"; + +function render(): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("grant invite card — the invitee needs an account here", () => { + it("says where the invited person will find the invitation", () => { + const markup = render(); + expect(markup).toContain(en.recordSharing.invite.needsAccount); + }); + + it("names Settings → Shared access rather than a link to send", () => { + expect(en.recordSharing.invite.needsAccount).toMatch(/Settings/); + expect(en.recordSharing.invite.needsAccount).not.toMatch(/https?:/); + }); +}); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index 24a336e8b..fbb0872f4 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -150,7 +150,14 @@ export function GrantInviteCard() {
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index 4b8d2e5b7..7435f0330 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -63,6 +63,7 @@ import { IntegrationErrorMessage, pillFailurePropsFor, pillStateForVerdict, + type IntegrationState, } from "./shared"; import { CallbackMismatchNotice, @@ -78,12 +79,12 @@ export interface OAuthProviderStatus { available: boolean; /** Whether the user has stored their own BYO client id/secret pair. */ hasOwnCredentials?: boolean; - state?: - | "connected" - | "error_transient" - | "error_reauth" - | "disconnected" - | "parked"; + /** + * The raw ledger state, carried for shape parity with the envelope. The + * card paints from `syncHealth.verdict`, never from this — which is why + * v1.38.19's `unknown` member needs no arm here. + */ + state?: IntegrationState; lastSuccessAt?: string | null; lastAttemptAt?: string | null; lastError?: string | null; diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index ffcbc70d9..61f759367 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -36,8 +36,16 @@ export type IntegrationKey = // Nightscout folds onto the envelope so the card stops fetching its own // status and inherits the shared verdict. | "nightscout"; +/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */ export type IntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + | "connected" + | "error_transient" + | "error_reauth" + | "disconnected" + | "parked" + // v1.38.19 — no ledger row: the ledger has no history for this provider. + // Read like `disconnected`; `syncHealth.verdict` owns liveness. + | "unknown"; export interface IntegrationStatusViewModel { integration: IntegrationKey; diff --git a/src/lib/ai/__tests__/server-provider-health.test.ts b/src/lib/ai/__tests__/server-provider-health.test.ts new file mode 100644 index 000000000..59e98925f --- /dev/null +++ b/src/lib/ai/__tests__/server-provider-health.test.ts @@ -0,0 +1,150 @@ +/** + * v1.38.19 — the instance-wide health of the operator's shared + * provider, as a tri-state a FRESH account is allowed to read. + * + * The ledger itself is per-user and fails OPEN (an optimisation, never a + * gate). This projection is the opposite on both counts: it folds rows + * across every account into one instance-level verdict, and it fails + * CLOSED — anything but a demonstrated recent success is not an offer. + * + * What it must never leak: how many accounts are tracked, when any of them + * last succeeded, or which account a row belongs to. The return type is the + * whole contract. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + providerHealth: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + SERVER_PROVIDER_FRESH_WINDOW_MS, + readServerProviderHealth, +} from "../server-provider-health"; + +const NOW = new Date("2026-09-11T12:00:00.000Z"); + +function ago(ms: number): Date { + return new Date(NOW.getTime() - ms); +} + +function ahead(ms: number): Date { + return new Date(NOW.getTime() + ms); +} + +function rows( + ...list: Array<{ + lastResult: string; + lastOkAt?: Date | null; + nextRetryAt?: Date | null; + }> +) { + vi.mocked(prisma.providerHealth.findMany).mockResolvedValue( + list.map((row) => ({ + lastResult: row.lastResult, + lastOkAt: row.lastOkAt ?? null, + nextRetryAt: row.nextRetryAt ?? null, + })) as never, + ); +} + +beforeEach(() => { + vi.mocked(prisma.providerHealth.findMany).mockReset(); + vi.mocked(annotate).mockReset(); +}); + +describe("readServerProviderHealth", () => { + it("is healthy when some account saw the shared provider work inside the window", async () => { + rows({ lastResult: "ok", lastOkAt: ago(60 * 60 * 1000) }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("healthy"); + }); + + it("is unknown when the last outcome was a success, just not a recent one", async () => { + // A quiet single-person instance nobody asked anything for two days has + // not observed a failure. "It is not working" would be a claim about + // something that was never measured; the offer is withheld either way. + rows({ + lastResult: "ok", + lastOkAt: ago(SERVER_PROVIDER_FRESH_WINDOW_MS + 60_000), + }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown"); + }); + + it("is unhealthy when rows exist but none ever succeeded", async () => { + rows({ lastResult: "hard_failed", lastOkAt: null }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + it("is unhealthy when a stale success is the only thing beside a failure", async () => { + // Somebody tried and it failed, and no success has contradicted that + // since. That verdict was earned and the card may state it. + rows( + { lastResult: "ok", lastOkAt: ago(SERVER_PROVIDER_FRESH_WINDOW_MS * 2) }, + { lastResult: "hard_failed", lastOkAt: null }, + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + it("lets a 401 in cooldown beat a fresh ok — a dead key means the same for everyone", async () => { + rows( + { lastResult: "ok", lastOkAt: ago(60 * 1000) }, + { + lastResult: "auth_failed", + lastOkAt: ago(3 * 60 * 60 * 1000), + nextRetryAt: ahead(60 * 60 * 1000), + }, + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + it("ignores an auth failure whose cooldown has run out", async () => { + rows( + { lastResult: "ok", lastOkAt: ago(60 * 1000) }, + { + lastResult: "auth_failed", + lastOkAt: null, + nextRetryAt: ago(60 * 1000), + }, + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("healthy"); + }); + + it("is unknown when the instance has no rows for either shared provider", async () => { + rows(); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown"); + }); + + it("is unknown when the read throws, and says so in the event", async () => { + vi.mocked(prisma.providerHealth.findMany).mockRejectedValue( + new Error("connection terminated"), + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown"); + expect(vi.mocked(annotate)).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "ai.server_health.read_failed" }, + }), + ); + }); + + it("reads only the operator-funded provider tags, and no per-account column", async () => { + rows({ lastResult: "ok", lastOkAt: ago(1000) }); + await readServerProviderHealth(NOW); + const args = vi.mocked(prisma.providerHealth.findMany).mock.calls[0]![0]!; + expect(args.where).toEqual({ + providerType: { in: ["admin-openai", "admin-codex"] }, + }); + expect(Object.keys(args.select ?? {}).sort()).toEqual([ + "lastOkAt", + "lastResult", + "nextRetryAt", + ]); + }); +}); diff --git a/src/lib/ai/server-provider-health.ts b/src/lib/ai/server-provider-health.ts new file mode 100644 index 000000000..b4ba030cb --- /dev/null +++ b/src/lib/ai/server-provider-health.ts @@ -0,0 +1,126 @@ +/** + * v1.38.19 — "does the shared provider of THIS instance work?", + * answered for a user who has never made a single AI call. + * + * The question comes from the end of the setup flow. The done screen used + * to claim "insights work for you right away" on the strength of + * `managedBy === "server"`, which is a PRESENCE read: it says the operator + * configured a key, never that the key works. On 2026-09-11 the operator's + * own instance answered HTTP 500 from its OAuth proxy while the screen kept + * making that promise. This module is the evidence the promise needs. + * + * Why it cannot come from the user's own ledger: `provider_health` is unique + * on `(user_id, provider_type)`, and an account walking the wizard has NO + * row — for any provider. Its own ledger can only answer "unknown", forever. + * So the projection folds rows ACROSS accounts. That is also the reason for + * the shape of the return value. + * + * What leaves this module is a tri-state and nothing else: no counts, no + * timestamps, no user ids, not even "how many accounts are tracked". "The + * provider of this instance last worked" is a statement about the instance; + * "seven accounts used it at 06:42Z" is a statement about other people. The + * admin fold (`GET /api/admin/provider-health`) keeps the second kind and + * stays behind `requireAdmin()`; this one is readable by any signed-in user + * precisely because it carries none of it. + * + * Fail CLOSED, deliberately against the grain of the ledger it reads. The + * ledger fails open (`provider-health-ledger.ts`) because there it protects + * a generation already under way — contributing nothing is the safe outcome. + * Here the output is a promise made to a person, so a read error, a timeout + * or an empty table is `unknown`, and `unknown` never buys an offer. It does + * NOT buy the opposite claim either: an empty table is the state of every + * instance until its first AI call, and telling that operator "the provider + * is not answering" is the same unmeasured claim the offer itself avoids. + * + * No probe. `POST /api/ai/test` exists and would be the direct answer, but it + * is a real completion billed to whoever the chain resolves to — usually the + * operator. A probe here would charge the operator for every registration, + * and a sign-up bot would scale that bill. The design spec's rule holds: + * nothing in the flow talks to a provider. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +/** + * The whole contract. Three words, no payload. + * + * healthy — the instance's shared provider demonstrably served somebody + * inside the freshness window, and no credential is benched. + * unhealthy — somebody tried it and the last thing that happened was a + * failure. A verdict a screen is allowed to state out loud. + * unknown — nobody has tried it lately on this instance, or the read + * failed. Never an offer either, but it is also not a verdict: + * a caller may withhold the offer and must not claim an outage. + */ +export type ServerProviderHealth = "healthy" | "unhealthy" | "unknown"; + +/** + * How recent a success has to be to still count. + * + * The ledger's own cooldowns bracket the choice: 6 h for an auth failure, + * 5 min for a hard one. A window under those would read `unhealthy` on a + * quiet single-person instance simply because nothing ran that hour; a much + * larger one would paint a key that died on Monday as green on Thursday. + * A day is long enough for an instance with one briefing per morning to hold + * the threshold, short enough that a dead key goes grey within a day. + */ +export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * The provider tags the operator pays for: the instance-wide OpenAI key and + * the shared central Codex account. These are exactly the two tags + * `consent-guard.ts` gates on, and that is not a coincidence — the offer + * this feeds is the consent for these two and for nothing else. + */ +const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const; + +export async function readServerProviderHealth( + now: Date = new Date(), +): Promise { + let rows: Array<{ + lastResult: string; + lastOkAt: Date | null; + nextRetryAt: Date | null; + }>; + try { + rows = await prisma.providerHealth.findMany({ + where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } }, + // Three columns, none of which identifies an account. The absence of + // `userId` here is load-bearing, not tidiness. + select: { lastResult: true, lastOkAt: true, nextRetryAt: true }, + }); + } catch { + annotate({ action: { name: "ai.server_health.read_failed" } }); + return "unknown"; + } + + if (rows.length === 0) return "unknown"; + + // A benched credential outranks any success. A 401/403 is not one + // account's bad luck — the operator's key is one object, and while it is + // in its cooldown it is dead for everybody, including whoever is reading + // this screen right now. + const credentialBenched = rows.some( + (row) => + row.lastResult === "auth_failed" && + row.nextRetryAt !== null && + row.nextRetryAt.getTime() > now.getTime(), + ); + if (credentialBenched) return "unhealthy"; + + const floor = now.getTime() - SERVER_PROVIDER_FRESH_WINDOW_MS; + const workedRecently = rows.some( + (row) => row.lastOkAt !== null && row.lastOkAt.getTime() >= floor, + ); + if (workedRecently) return "healthy"; + + // Nothing succeeded inside the window, and that alone is not a failure. + // `unhealthy` is a statement the screen puts to a person — "it is not + // answering" — so it has to be earned by an outcome somebody actually + // observed: a row whose LAST outcome was a failure, uncontradicted by a + // recent success. When every row's last outcome was a success and it is + // merely old, nobody has asked this provider for anything lately, and the + // honest answer is that we do not know. Either way there is no offer. + const lastOutcomeFailed = rows.some((row) => row.lastResult !== "ok"); + return lastOutcomeFailed ? "unhealthy" : "unknown"; +} diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index f7cfe1361..89c75b2cb 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { afterEach, describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { @@ -282,3 +282,84 @@ describe("applyProfileUpdate email conflict", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +/** + * v1.39 — what a demo instance may write through this helper. + * + * `PUT /api/auth/profile` is on the proxy's demo mutation allowlist so the + * setup flow's baseline step can complete. That allowlist admits a path and a + * method; it cannot see that the route behind the path also writes the + * account's email, display name, full name and insurer fields. The demo is + * one published account every visitor signs into, so each of those lands on + * the record the next visitor meets. + * + * The narrowing is therefore here, on the server, and not in the client that + * happens to send only three fields — the allowlist exists precisely because + * the caller is not trusted. `tests/integration/demo-mode-profile-write.test.ts` + * proves the same contract against real Postgres by reading the row back; this + * pins the written payload so the default suite catches a regression too. + */ +describe("applyProfileUpdate under DEMO_MODE", () => { + const ORIGINAL = process.env.DEMO_MODE; + + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = ORIGINAL; + }); + + const HOSTILE_AND_BASELINE = { + email: "attacker@example.test", + displayName: "Owned", + fullName: "Attacker", + insurerName: "Attacker Insurance", + timezone: "America/New_York", + heightCm: 181, + dateOfBirth: "1988-04-12", + gender: "FEMALE", + }; + + it("writes the three baseline fields and nothing else", async () => { + process.env.DEMO_MODE = "true"; + + const result = await applyProfileUpdate(USER_ID, HOSTILE_AND_BASELINE); + + expect(result.ok).toBe(true); + const written = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data; + expect(Object.keys(written as object).sort()).toEqual([ + "dateOfBirth", + "gender", + "heightCm", + ]); + }); + + it("never reaches the email conflict check, which is an existence oracle", async () => { + // The check 409s when another account holds the address. On a public + // demo that answers "does this person have an account here?" to anybody, + // so the narrowing has to happen ahead of it rather than after. + process.env.DEMO_MODE = "true"; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "someone-else", + } as never); + + const result = await applyProfileUpdate(USER_ID, { + email: "taken@example.test", + }); + + expect(result.ok).toBe(true); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("writes every field on an ordinary instance", async () => { + delete process.env.DEMO_MODE; + + await applyProfileUpdate(USER_ID, HOSTILE_AND_BASELINE); + + const written = vi.mocked(prisma.user.update).mock.calls[0]![0]! + .data as Record; + expect(written.email).toBe("attacker@example.test"); + expect(written.displayName).toBe("Owned"); + expect(written.fullName).toBe("Attacker"); + expect(written.insurerName).toBe("Attacker Insurance"); + expect(written.heightCm).toBe(181); + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 55a8d5828..58c4adbf3 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -48,6 +48,28 @@ const extendedProfileSchema = profileSchema.extend({ type ExtendedProfileInput = z.infer; +/** + * The only profile fields a demo instance may write: the three the setup + * flow's baseline step collects. + * + * `PUT /api/auth/profile` sits on the proxy's `DEMO_MUTATION_ALLOWLIST` so + * that step can complete. That list is an EDGE control — it admits a path and + * a method, and it cannot see that the handler behind the path also writes + * this account's contact email, its display name, its full name and its + * insurer fields. On an ordinary instance those are the caller's own record; + * on the demo, which is ONE published account every visitor signs into, each + * of them is the record the next visitor arrives at, until a reseed. + * + * So the narrowing is here and not in the client. The web form sends only + * these three plus a display name, but a form is not a control: the whole + * reason the allowlist exists is that the caller is not trusted. + */ +export const DEMO_WRITABLE_PROFILE_FIELDS = [ + "heightCm", + "dateOfBirth", + "gender", +] as const; + /** * The profile schema has no cross-field `.refine()` — every field is an * independent scalar column. That means a rejected `gender` has no @@ -202,6 +224,17 @@ export async function applyProfileUpdate( } } + // Ahead of the conflict check below, deliberately: that check answers 409 + // when another account holds the address, which on a public demo is an + // existence oracle over the instance's accounts. Dropping the field first + // closes the write and the question in one move. + if (process.env.DEMO_MODE === "true") { + const writable = DEMO_WRITABLE_PROFILE_FIELDS as readonly string[]; + data = Object.fromEntries( + Object.entries(data).filter(([key]) => writable.includes(key)), + ) as Partial; + } + const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; if (data.email !== undefined && normalizedEmail) { diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 6f689fba9..dc672b92f 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -48,6 +48,7 @@ vi.mock("@/lib/crypto", () => ({ })); import { + getIntegrationStatus, recordSyncFailure, recordSyncSuccess, markReauthRequired, @@ -1368,3 +1369,25 @@ describe("recordSyncFailure — leg set", () => { expect(writtenUpdate().failingLegs).toEqual(["sleep"]); }); }); + +/** + * v1.38.19 — the synthetic "no row" snapshot. + * + * `state: "connected"` used to be the value an account with no sync history + * read back, which made the ledger field's default mean the opposite of its + * name: a provider nobody had ever connected published "connected" on the + * envelope, and the setup flow's connect step believed it. The honest value + * for "there is no history here" is `unknown`; `syncHealth.verdict` stays the + * only liveness truth. + */ +describe("getIntegrationStatus with no ledger row", () => { + it("reads as unknown, not as connected", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce( + null as never, + ); + const snapshot = await getIntegrationStatus("u1", "whoop"); + expect(snapshot.state).toBe("unknown"); + expect(snapshot.lastSuccessAt).toBeNull(); + expect(snapshot.lastAttemptAt).toBeNull(); + }); +}); diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 2fa914b4f..96e730cb5 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -159,7 +159,22 @@ export function isSyncFailureRecorded(err: unknown): boolean { * trail. */ export type IntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + | "connected" + | "error_transient" + | "error_reauth" + | "disconnected" + | "parked" + /** + * v1.38.19 — no ledger row exists for this (user, provider) pair, so the + * ledger has nothing to say. It is NOT a claim about the connection either + * way: `connected` used to be the synthetic default here, which made the + * field's default read as the opposite of the truth for every account that + * had never connected anything — the setup flow's connect step believed it + * and told a brand-new account its wearable was connected. Consumers treat + * `unknown` the way they treat `disconnected`; `syncHealth.verdict` stays + * the only liveness truth. + */ + | "unknown"; /** * The ladder at which a streak of failures escalates from "user-visible @@ -323,9 +338,11 @@ function zeroBuckets(): ConsecutiveFailuresByKind { } /** - * Read the current snapshot. Returns a synthetic "connected, never - * attempted" record when no row exists yet — the UI treats this as - * "no sync history" without a special case. + * Read the current snapshot. Returns a synthetic "unknown, never attempted" + * record when no row exists yet — the ledger holds no history, and says so + * rather than guessing a state. `resolveSyncVerdict` reads the same absence + * off the null timestamps and answers `pending_first_sync` for a live + * connection, `disconnected` for one that is not there. */ export async function getIntegrationStatus( userId: string, @@ -337,7 +354,7 @@ export async function getIntegrationStatus( if (!row) { return { integration, - state: "connected", + state: "unknown", lastSuccessAt: null, lastAttemptAt: null, lastError: null, diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 6d19e8fa1..06d5cf2bc 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -9,6 +9,7 @@ import { CHECKLIST_BASE_ITEM_IDS, CHECKLIST_ITEM_IDS, isProfileComplete, + isStillInSetup, shouldShowChecklist, trendHintFor, upcomingVisitCountFrom, @@ -608,3 +609,75 @@ describe("shouldShowChecklist and the setup flow", () => { ).toBe(false); }); }); + +describe("isStillInSetup", () => { + // The one predicate both the visibility rule and the component's query gate + // read, so the card can never render with its supporting queries switched + // off. + it("is true for a record that ran the flow, whatever its reading count", () => { + expect( + isStillInSetup({ + onboarding: onboardingState(), + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 120, + }), + ).toBe(true); + }); + + it("is true for a restarted run nobody finished", () => { + const restarted = onboardingState({ completedAt: null }); + expect( + isStillInSetup({ + onboarding: restarted, + // `restart` deliberately leaves the account stamp alone. + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 120, + }), + ).toBe(true); + }); + + it("keeps the pre-flow rule for a record that never entered the flow", () => { + const untouched: OnboardingStateDto = { + steps: defaultOnboardingSteps(), + needs: emptyOnboardingNeeds(), + completedAt: null, + firstResult: null, + }; + expect( + isStillInSetup({ + onboarding: untouched, + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 10, + }), + ).toBe(false); + expect( + isStillInSetup({ + onboarding: untouched, + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 4, + }), + ).toBe(true); + expect( + isStillInSetup({ + onboarding: null, + onboardingCompletedAt: null, + measurementCount: 10, + }), + ).toBe(true); + }); + + it("is the rule `shouldShowChecklist` itself applies", () => { + const args = { + onboarding: onboardingState(), + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 120, + }; + expect( + shouldShowChecklist({ + ...args, + dismissedAll: false, + items: buildChecklist(inputs({ measurementCount: 120 })), + }), + ).toBe(isStillInSetup(args)); + }); +}); diff --git a/src/lib/onboarding/__tests__/confirm-summary.test.ts b/src/lib/onboarding/__tests__/confirm-summary.test.ts index 3ced0603a..d72bf9f2a 100644 --- a/src/lib/onboarding/__tests__/confirm-summary.test.ts +++ b/src/lib/onboarding/__tests__/confirm-summary.test.ts @@ -66,3 +66,62 @@ describe("confirmedModules", () => { ).toContain("vaccinations"); }); }); + +describe("confirmedModules — what leaves the navigation", () => { + const noMedication = { + ...emptyOnboardingNeeds(), + recordTarget: "me" as const, + areas: ["sleep" as const], + medication: "no" as const, + visit: "no" as const, + }; + + it("names a module the answers do not ask for and the record shows today", () => { + const { wouldSwitchOff } = confirmedModules(noMedication, { + medications: true, + doctorReport: true, + }); + expect(wouldSwitchOff).toContain("medications"); + expect(wouldSwitchOff).toContain("doctorReport"); + }); + + it("never names a module the answers switch on", () => { + const { chosen, wouldSwitchOff } = confirmedModules( + { ...noMedication, medication: "yes" }, + { medications: true }, + ); + expect(chosen).toContain("medications"); + expect(wouldSwitchOff).not.toContain("medications"); + }); + + it("never names a module the record does not show today", () => { + const { wouldSwitchOff } = confirmedModules(noMedication, { + medications: false, + }); + expect(wouldSwitchOff).not.toContain("medications"); + // Nothing is known about a module the payload does not carry, so the + // screen says nothing about it. + expect(confirmedModules(noMedication, {}).wouldSwitchOff).toEqual([]); + }); + + it("never names an always-on module, whatever the answers say", () => { + const current = Object.fromEntries( + ONBOARDING_ALWAYS_ON_MODULES.map((key) => [key, true]), + ); + const { wouldSwitchOff } = confirmedModules(noMedication, current); + for (const key of ONBOARDING_ALWAYS_ON_MODULES) { + expect(wouldSwitchOff).not.toContain(key); + } + }); + + it("is in registry order, like the list beside it", () => { + const current = Object.fromEntries( + OWNED_MODULE_KEYS.map((key) => [key, true]), + ); + const { wouldSwitchOff } = confirmedModules(noMedication, current); + expect(wouldSwitchOff).toEqual( + OWNED_MODULE_KEYS.filter((key) => wouldSwitchOff.includes(key)), + ); + expect(wouldSwitchOff.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts index 40e8aa56f..3a618f862 100644 --- a/src/lib/onboarding/__tests__/first-result-config.test.ts +++ b/src/lib/onboarding/__tests__/first-result-config.test.ts @@ -3,6 +3,9 @@ * on a surface that exists, so the first-result screen can never be handed * a task it cannot render. */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; @@ -11,6 +14,8 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; import { AREA_PAGE_HREF, AREA_READING_TARGETS, + connectSourceView, + deliveringSources, SOURCE_INTEGRATION, } from "../first-result-config"; import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps"; @@ -38,3 +43,216 @@ describe("first-result targets", () => { } }); }); + +/** + * v1.38.19 — what the connect step is allowed to say. + * + * The screen used to decide "connected" from the ledger's `state` field, whose + * default meant the opposite of its name, and had one piece of copy for every + * outcome. The decision is a pure function over the envelope entry now, so the + * eight verdicts can be enumerated here rather than inferred from markup. + */ +describe("connectSourceView", () => { + const cases = [ + ["disconnected", "connect", false], + ["pending_first_sync", "result", true], + ["fresh", "result", true], + ["stale", "result", false], + ["stalled", "attention", false], + ["failing", "attention", false], + ["reauth_required", "attention", false], + ["parked", "attention", false], + ] as const; + + it.each(cases)( + "verdict %s renders the %s tile and settles=%s", + (verdict, slot, settled) => { + const view = connectSourceView("whoop", { + connected: verdict !== "disconnected", + configured: true, + syncHealth: { verdict, since: "2026-09-01T00:00:00.000Z" }, + lastSuccessAt: "2026-09-01T00:00:00.000Z", + }); + expect(view?.slot).toBe(slot); + expect(view?.settled).toBe(settled); + expect(view?.verdict).toBe(verdict); + }, + ); + + it("says nothing at all until the envelope has resolved", () => { + expect(connectSourceView("whoop", undefined)).toBeNull(); + }); + + it("does not read a fresh account's empty ledger as a connection", () => { + // What a brand-new account publishes: no row anywhere, so `state` is the + // synthetic `unknown` and the verdict is `disconnected`. + const view = connectSourceView("whoop", { + connected: false, + configured: true, + state: "unknown", + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("connect"); + expect(view?.settled).toBe(false); + }); + + it("points a BYO provider with no credentials at the credentials", () => { + const view = connectSourceView("whoop", { + connected: false, + configured: false, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("credentials"); + }); + + it("points a shared-app provider with no usable app at the credentials", () => { + const view = connectSourceView("oura", { + connected: false, + configured: false, + available: false, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("credentials"); + }); + + it("offers the connect card when the instance's shared app resolves", () => { + const view = connectSourceView("oura", { + connected: false, + configured: false, + available: true, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("connect"); + }); + + it("never withholds the connect card from a self-hosted URL source", () => { + const view = connectSourceView("nightscout", { + connected: false, + configured: false, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("connect"); + }); + + it("dates a stale tile from the last value and a failing one from the streak", () => { + expect( + connectSourceView("whoop", { + connected: true, + configured: true, + syncHealth: { verdict: "stale", since: "2026-08-01T00:00:00.000Z" }, + lastSuccessAt: "2026-08-20T00:00:00.000Z", + })?.when, + ).toBe("2026-08-20T00:00:00.000Z"); + expect( + connectSourceView("whoop", { + connected: true, + configured: true, + syncHealth: { verdict: "failing", since: "2026-08-01T00:00:00.000Z" }, + lastSuccessAt: null, + })?.when, + ).toBe("2026-08-01T00:00:00.000Z"); + }); +}); + +/** + * The connect step's copy is chosen by a key built at runtime from the + * verdict, which the repo's three i18n guards cannot see into: a template + * `ns.${x}` counts as covering its whole subtree, so a MEMBER missing from + * every bundle is invisible to them. Enumerate the verdicts here instead — + * the eight the server can resolve, in all seven locales. + */ +describe("connect-source state copy", () => { + const VERDICTS = [ + "fresh", + "stale", + "stalled", + "failing", + "reauth_required", + "parked", + "pending_first_sync", + ] as const; + + const LOCALES = readdirSync(join(__dirname, "../../../../messages")) + .filter((file) => file.endsWith(".json")) + .map((file) => ({ + locale: file.replace(/\.json$/, ""), + copy: JSON.parse( + readFileSync(join(__dirname, "../../../../messages", file), "utf8"), + ) as Record, + })); + + it("covers all seven locales", () => { + expect(LOCALES.length).toBe(7); + }); + + it.each(LOCALES)( + "names every verdict in $locale, with no English fallback", + ({ locale, copy }) => { + const block = ( + copy as unknown as { + onboarding: { + flow: { + "first-result": { + "connect-source": Record>; + }; + }; + }; + } + ).onboarding.flow["first-result"]["connect-source"]; + for (const verdict of VERDICTS) { + const entry = block.state[verdict] as + { title?: string; detail?: string } | undefined; + expect(entry?.title, `${locale}/${verdict}.title`).toBeTruthy(); + expect(entry?.detail, `${locale}/${verdict}.detail`).toBeTruthy(); + } + // `disconnected` is the one verdict with no state copy: it renders the + // connect card or the credentials note, neither of which is a claim + // about a connection that exists. + expect(block.state.disconnected).toBeUndefined(); + expect(block.needsCredentials.title).toBeTruthy(); + expect(block.openSettings).toBeTruthy(); + expect(block.noDate).toBeTruthy(); + }, + ); +}); + +/** + * v1.38.19 — which sources the flow must stop offering. + * + * Only a connection that is actually delivering, or one whose data has merely + * gone quiet: both are real connections the person made, and offering to make + * them again is the wizard talking past the account it can see. Everything + * else — never connected, first sync running, needs repairing — stays in the + * priority, because there is still something to do or something to say. + */ +describe("deliveringSources", () => { + function entry(integration: string, verdict: string) { + return { + integration, + connected: verdict !== "disconnected", + configured: true, + syncHealth: { verdict, since: null }, + lastSuccessAt: null, + }; + } + + it("names the sources that are delivering or merely quiet", () => { + const set = deliveringSources([ + entry("whoop", "fresh"), + entry("oura", "stale"), + entry("polar", "pending_first_sync"), + entry("fitbit", "reauth_required"), + entry("withings", "disconnected"), + ] as never); + expect([...set].sort()).toEqual(["oura", "whoop"]); + }); + + it("names nothing while the envelope has not resolved", () => { + expect(deliveringSources(undefined).size).toBe(0); + }); +}); diff --git a/src/lib/onboarding/__tests__/question-config.test.ts b/src/lib/onboarding/__tests__/question-config.test.ts index 0151f4204..5c7684fe3 100644 --- a/src/lib/onboarding/__tests__/question-config.test.ts +++ b/src/lib/onboarding/__tests__/question-config.test.ts @@ -80,11 +80,15 @@ describe("questionAnswerBody", () => { expect(questionAnswerBody("visit", [])).toBeNull(); }); - it("sends an empty list for a many-answer question with nothing chosen", () => { - expect(questionAnswerBody("areas", [])).toEqual({ - step: "areas", - areas: [], - }); + it("is null for a many-answer question with nothing chosen", () => { + // An empty tick list is not an opinion. It used to be sent as an + // ANSWER, and an answered Q2 with no areas is what switched every + // optional module off for somebody who meant "no preference" — Skip is + // the way past, and Skip is the conservative derivation. + expect(questionAnswerBody("areas", [])).toBeNull(); + expect(questionAnswerBody("sources", [])).toBeNull(); + // A list of values none of which the step offers is just as empty. + expect(questionAnswerBody("areas", ["no-such-area"])).toBeNull(); }); it("drops a value the step's vocabulary does not contain", () => { @@ -125,13 +129,22 @@ describe("units", () => { expect(onboardingAnswerSchema.safeParse(body).success).toBe(true); }); - it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => { + it("is null when nothing was chosen, so Skip is the way past", () => { + // An empty units answer marked Q6 done without recording a preference. expect( unitsAnswerBody({ asked: { glucose: true, weight: true }, glucoseUnit: null, unitPreference: null, }), - ).toEqual({ step: "units", units: {} }); + ).toBeNull(); + // One of two asked questions answered is still an answer. + expect( + unitsAnswerBody({ + asked: { glucose: true, weight: true }, + glucoseUnit: "mg/dL", + unitPreference: null, + }), + ).toEqual({ step: "units", units: { glucoseUnit: "mg/dL" } }); }); }); diff --git a/src/lib/onboarding/__tests__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts index bbb82e687..ec2fc912c 100644 --- a/src/lib/onboarding/__tests__/wizard-steps.test.ts +++ b/src/lib/onboarding/__tests__/wizard-steps.test.ts @@ -167,6 +167,46 @@ describe("chooseFirstResultTask", () => { ).toEqual({ task: "log-reading", target: "weight-body" }); expect(chooseFirstResultTask(emptyOnboardingNeeds())).toBeNull(); }); + + /** + * v1.38.19 — the choice used to read the answers alone, so an + * account whose WHOOP had been delivering for months was told to go and + * connect WHOOP. A source that is already delivering is not a task; the + * priority falls through to the next one the person's answers name. + */ + it("passes over a source that is already delivering", () => { + const needs: OnboardingNeeds = { + ...emptyOnboardingNeeds(), + sources: ["whoop", "oura"], + medication: "yes", + areas: ["sleep"], + }; + expect(chooseFirstResultTask(needs, new Set(["whoop"]))).toEqual({ + task: "connect-source", + target: "oura", + }); + expect(chooseFirstResultTask(needs, new Set(["whoop", "oura"]))).toEqual({ + task: "add-medication", + target: null, + }); + expect( + chooseFirstResultTask( + { ...needs, medication: "no" }, + new Set(["whoop", "oura"]), + ), + ).toEqual({ task: "log-reading", target: "sleep" }); + }); + + it("is unchanged when nothing is known about the connections", () => { + const needs: OnboardingNeeds = { + ...emptyOnboardingNeeds(), + sources: ["whoop"], + medication: "yes", + }; + expect(chooseFirstResultTask(needs)).toEqual( + chooseFirstResultTask(needs, new Set()), + ); + }); }); describe("firstResultApplies", () => { diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index c0fda5294..20fd24d3c 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -282,11 +282,7 @@ export function checklistProgress(items: ChecklistItem[]): ChecklistProgress { * unfinished, because the one task the flow offered is not one of the six * rows: "every row done" and "the setup finished" are different statements. */ -export function shouldShowChecklist(args: { - onboardingCompletedAt: string | null; - measurementCount: number; - dismissedAll: boolean; - items: ChecklistItem[]; +export interface StillInSetupInputs { /** * v1.39 (C1) — the needs-based setup state for this record, or null before * the account payload resolves. A record that never entered the flow reads @@ -295,17 +291,45 @@ export function shouldShowChecklist(args: { * on the rule it already had. */ onboarding?: OnboardingStateDto | null; -}): boolean { + onboardingCompletedAt: string | null; + measurementCount: number; +} + +/** + * v1.39 — "is this record still being set up?", as ONE predicate. + * + * Two places decide this: whether the card renders at all, and whether the + * component fetches the data its rows are about. They were two restatements of + * the same rule and they drifted — the visibility rule gained + * `hasEnteredOnboardingFlow`, the query gate kept the older half, and a + * wizard-run account past five readings got a permanently visible card whose + * every network-backed row read "not done". A record whose + * restarted run was abandoned is in the same state: `restart` clears the + * record's own `completedAt` and deliberately not the account stamp, so + * `hasEnteredOnboardingFlow` is what keeps its rows live. + * + * Exported so the component calls it rather than restating it. + */ +export function isStillInSetup(args: StillInSetupInputs): boolean { + return ( + hasEnteredOnboardingFlow(args.onboarding ?? null) || + args.onboardingCompletedAt == null || + args.measurementCount < 5 + ); +} + +export function shouldShowChecklist( + args: StillInSetupInputs & { + dismissedAll: boolean; + items: ChecklistItem[]; + }, +): boolean { if (args.dismissedAll) return false; const onboarding = args.onboarding ?? null; const visible = visibleChecklist(args.items); if (visible.length === 0) return false; - const stillInSetup = - hasEnteredOnboardingFlow(onboarding) || - args.onboardingCompletedAt == null || - args.measurementCount < 5; - if (!stillInSetup) return false; + if (!isStillInSetup(args)) return false; if (!isOnboardingSettled(onboarding)) return true; return visible.some((item) => !item.done); diff --git a/src/lib/onboarding/confirm-summary.ts b/src/lib/onboarding/confirm-summary.ts index 1dfb28c9b..2c0bfec0a 100644 --- a/src/lib/onboarding/confirm-summary.ts +++ b/src/lib/onboarding/confirm-summary.ts @@ -15,6 +15,7 @@ */ import { deriveOnboardingModuleDefaults, + mergeDerivedModulePreferences, MODULE_KEYS, ONBOARDING_ALWAYS_ON_MODULES, type ModuleKey, @@ -27,9 +28,23 @@ export interface ConfirmedModules { chosen: ModuleKey[]; /** On whatever the answers say. */ alwaysOn: ModuleKey[]; + /** + * v1.39 — what the derivation takes OUT of the navigation: + * modules this record shows today that the answers do not ask for. Empty + * until the account payload has resolved, because a module the payload + * says nothing about is a module this screen knows nothing about. + */ + wouldSwitchOff: ModuleKey[]; } -export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules { +/** + * @param current the module map the record shows today (`user.modules`), so + * the screen names only modules that are actually in the navigation now. + */ +export function confirmedModules( + needs: OnboardingNeeds, + current: Partial> = {}, +): ConfirmedModules { const derived = deriveOnboardingModuleDefaults({ // Q1 is required and a confirm screen is never reached without it; the // fallback only keeps the type total. @@ -45,8 +60,27 @@ export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules { } if (derived.cycleTracking) on.add("cycle"); const alwaysOn = new Set(ONBOARDING_ALWAYS_ON_MODULES); + // The registry's own merge rule decides what a derivation puts at `false`, + // so this screen never restates it. It is asked with nothing to spare — + // no stored preference, no domain holding rows — because that is the + // record the confirm screen is looking at on a first run, and it is the + // maximum the completion can take away. What the merge would spare on a + // re-run (a module switched on by hand, one holding data) it spares + // server-side, so this list can only ever be longer than what happens, + // never shorter, and the sentence beside it says how to get one back. + const afterDerivation = mergeDerivedModulePreferences( + {}, + derived.preferences, + new Set(), + ); return { chosen: MODULE_KEYS.filter((key) => on.has(key) && !alwaysOn.has(key)), alwaysOn: MODULE_KEYS.filter((key) => alwaysOn.has(key)), + wouldSwitchOff: MODULE_KEYS.filter( + (key) => + !alwaysOn.has(key) && + current[key] === true && + afterDerivation[key] === false, + ), }; } diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts index 5b10a99bc..0f2eb588b 100644 --- a/src/lib/onboarding/first-result-config.ts +++ b/src/lib/onboarding/first-result-config.ts @@ -7,9 +7,13 @@ * which integration a Q4 source is, and which page carries an area the * measurement form cannot log. */ +import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; import type { OnboardingAreaKey } from "@/lib/modules/registry"; -import type { BrowserConnectableSource } from "./wizard-steps"; +import { + BROWSER_CONNECTABLE_SOURCES, + type BrowserConnectableSource, +} from "./wizard-steps"; export interface AreaReadingTarget { /** The `defaultType` the measurement form opens on. */ @@ -47,19 +51,158 @@ export const AREA_PAGE_HREF: Readonly< illness: "/illness", }); +/** + * How a source's connection is authorised on this instance, which decides + * what "cannot be connected yet" means for it: + * + * - `byo` — the OAuth app is per-user only (`configured` is the stored client + * id/secret pair). With none, the provider card renders a credentials note + * and no connect button at all, so offering "Connect" walks the person into + * a dead end. + * - `shared` — an operator env app is the fallback (`available` says whether + * any usable credentials resolve, own or shared). + * - `self-hosted` — a URL the person pastes; nothing can be missing up front. + */ +export type SourceCredentialModel = "byo" | "shared" | "self-hosted"; + /** * The connections panel's anchor for each connectable source — the `id` of * the provider card on Settings → Integrations, and the key its status is * published under on `/api/integrations/status`. */ export const SOURCE_INTEGRATION: Readonly< - Record + Record< + BrowserConnectableSource, + { anchor: string; statusKey: string; credentials: SourceCredentialModel } + > > = Object.freeze({ - withings: { anchor: "withings", statusKey: "withings" }, - oura: { anchor: "oura", statusKey: "oura" }, - whoop: { anchor: "whoop", statusKey: "whoop" }, - polar: { anchor: "polar", statusKey: "polar" }, - fitbit: { anchor: "fitbit", statusKey: "fitbit" }, - strava: { anchor: "strava", statusKey: "strava" }, - nightscout: { anchor: "nightscout", statusKey: "nightscout" }, + withings: { anchor: "withings", statusKey: "withings", credentials: "byo" }, + oura: { anchor: "oura", statusKey: "oura", credentials: "shared" }, + whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" }, + polar: { anchor: "polar", statusKey: "polar", credentials: "shared" }, + fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" }, + strava: { anchor: "strava", statusKey: "strava", credentials: "shared" }, + nightscout: { + anchor: "nightscout", + statusKey: "nightscout", + credentials: "self-hosted", + }, }); + +/** Which tile the connect step renders. */ +export type ConnectSourceSlot = + /** Nothing is connected and something can be: today's connect card. */ + | "connect" + /** Nothing is connected and nothing CAN be: point at the credentials. */ + | "credentials" + /** A working connection — the flow's result, whether or not it is new. */ + | "result" + /** A connection the person has to repair, named rather than painted green. */ + | "attention"; + +/** The fields of an `/api/integrations/status` entry this decision reads. */ +export interface ConnectSourceStatus { + connected?: boolean; + configured?: boolean; + available?: boolean; + state?: string; + syncHealth?: { verdict: SyncVerdict; since: string | null }; + lastSuccessAt?: string | null; +} + +export interface ConnectSourceView { + slot: ConnectSourceSlot; + verdict: SyncVerdict; + /** + * Whether the flow may record this as the completed first result. Only a + * connection that is delivering, or one whose first sync is under way — + * never a connection that needs repairing, and never the mere absence of a + * ledger row. + */ + settled: boolean; + /** The instant the copy names, or null when there is none to name. */ + when: string | null; +} + +/** + * What the connect step may honestly say about one source. + * + * The liveness truth is `syncHealth.verdict` and nothing else — the ledger's + * `state` is what the last ATTEMPT did, and its "no row" default used to read + * as `connected`, which is how a brand-new account was told its wearable was + * connected and had the flow's one result stamped as achieved on its behalf. + * + * Returns `null` while the envelope has not resolved: an unloaded status is + * not evidence of anything, and the screen says nothing rather than guessing. + */ +export function connectSourceView( + source: BrowserConnectableSource, + status: ConnectSourceStatus | undefined, +): ConnectSourceView | null { + if (!status) return null; + const verdict = status.syncHealth?.verdict ?? "disconnected"; + const settled = verdict === "pending_first_sync" || verdict === "fresh"; + const when = + verdict === "fresh" || verdict === "stale" + ? (status.lastSuccessAt ?? status.syncHealth?.since ?? null) + : (status.syncHealth?.since ?? status.lastSuccessAt ?? null); + + if (settled || verdict === "stale") { + return { slot: "result", verdict, settled, when }; + } + if (verdict !== "disconnected") { + return { slot: "attention", verdict, settled, when }; + } + return { + slot: canStartConnection(source, status) ? "connect" : "credentials", + verdict, + settled, + when, + }; +} + +/** + * Whether the connect CTA leads anywhere. A `byo` provider without its client + * pair and a `shared` provider without any resolvable app both land on a card + * that has no connect button — the dead end the flow used to walk into. + */ +function canStartConnection( + source: BrowserConnectableSource, + status: ConnectSourceStatus, +): boolean { + switch (SOURCE_INTEGRATION[source].credentials) { + case "byo": + return status.configured !== false; + case "shared": + return status.available !== false; + case "self-hosted": + return true; + } +} + +/** + * The sources the flow must stop offering to connect: the ones whose + * connection is already delivering (`fresh`), and the ones whose data has + * merely gone quiet (`stale`) — both are connections the person made, and + * both would be "connect it" a second time. + * + * `pending_first_sync` is deliberately NOT in the set: that is a connection + * made moments ago, and it is exactly the result this step exists to show. + * Neither is anything on the attention arm — there is still something to say. + */ +export function deliveringSources( + entries: + ReadonlyArray | undefined, +): ReadonlySet { + const delivering = new Set(); + if (!entries) return delivering; + for (const source of BROWSER_CONNECTABLE_SOURCES) { + const entry = entries.find( + (candidate) => + candidate.integration === SOURCE_INTEGRATION[source].statusKey, + ); + const verdict = connectSourceView(source, entry)?.verdict; + if (verdict === "fresh" || verdict === "stale") delivering.add(source); + } + return delivering; +} diff --git a/src/lib/onboarding/question-config.ts b/src/lib/onboarding/question-config.ts index 547b28cd0..99c858a21 100644 --- a/src/lib/onboarding/question-config.ts +++ b/src/lib/onboarding/question-config.ts @@ -110,9 +110,15 @@ function only( /** * The body for one answered step, or null when the selection cannot be an - * answer (Q1 with nothing chosen). Every arm names its field; a value that is - * not in the step's vocabulary is dropped rather than sent, so the strict - * server schema never sees a chip this file did not offer. + * answer. Every arm names its field; a value that is not in the step's + * vocabulary is dropped rather than sent, so the strict server schema never + * sees a chip this file did not offer. + * + * v1.39 — an empty tick list is not an answer either. The many-answer arms + * used to send `[]` and mark the step DONE, and an + * answered Q2 with no areas is what switched every optional module off for + * somebody whose click meant "no preference". Skip is the way past a question + * nobody wants to answer, and Skip is the conservative derivation. */ export function questionAnswerBody( step: QuestionStepId, @@ -123,14 +129,18 @@ export function questionAnswerBody( const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected); return recordTarget ? { step, recordTarget } : null; } - case "areas": - return { step, areas: only(ONBOARDING_AREA_KEYS, selected) }; + case "areas": { + const areas = only(ONBOARDING_AREA_KEYS, selected); + return areas.length > 0 ? { step, areas } : null; + } case "medication": { const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected); return medication ? { step, medication } : null; } - case "sources": - return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) }; + case "sources": { + const sources = only(ONBOARDING_SOURCE_KEYS, selected); + return sources.length > 0 ? { step, sources } : null; + } case "visit": { const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected); return visit ? { step, visit } : null; @@ -171,7 +181,7 @@ export function unitsAnswerBody(input: { asked: { glucose: boolean; weight: boolean }; glucoseUnit: "mg/dL" | "mmol/L" | null; unitPreference: "metric" | "imperial" | null; -}): OnboardingAnswerInput { +}): OnboardingAnswerInput | null { const units: { glucoseUnit?: "mg/dL" | "mmol/L"; unitPreference?: "metric" | "imperial"; @@ -182,5 +192,7 @@ export function unitsAnswerBody(input: { if (input.asked.weight && input.unitPreference) { units.unitPreference = input.unitPreference; } - return { step: "units", units }; + // v1.39 — nothing chosen is not an answer: an empty + // units body marked Q6 done while recording no preference at all. + return Object.keys(units).length > 0 ? { step: "units", units } : null; } diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts index 71c5912a7..b5055dccc 100644 --- a/src/lib/onboarding/wizard-steps.ts +++ b/src/lib/onboarding/wizard-steps.ts @@ -93,6 +93,8 @@ export function questionScreens(state: FlowState): OnboardingStepId[] { : [...FIXED_QUESTIONS]; } +const EMPTY_CONNECTED: ReadonlySet = new Set(); + export interface FirstResultOffer { task: OnboardingFirstResultTask; /** A source key for a connection, an area key for a reading, else null. */ @@ -104,11 +106,21 @@ export interface FirstResultOffer { * the browser can complete → the first medication with a reminder, if the * answer to Q3 was a daily schedule → one reading for the first Q2 area. * Everything the priority passes over goes to the checklist instead. + * + * `connected` names the sources whose connection is already delivering. It is + * optional because the server picks the order from the answers alone, before + * any status is in hand; the screen re-picks once the envelope resolves. A + * source in the set is not a task — an account whose WHOOP had been feeding + * the dashboard for months was told to go and connect WHOOP, which is the + * wizard talking past the account it can already see. */ export function chooseFirstResultTask( needs: OnboardingNeeds, + connected: ReadonlySet = EMPTY_CONNECTED, ): FirstResultOffer | null { - const connectable = needs.sources.find(isBrowserConnectableSource); + const connectable = needs.sources.find( + (source) => isBrowserConnectableSource(source) && !connected.has(source), + ); if (connectable) return { task: "connect-source", target: connectable }; if (needs.medication === "yes") return { task: "add-medication", target: null }; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 98fede930..ade5c0dec 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -46,6 +46,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], auth: [ "auth.admin.required", + "auth.already_authenticated", "auth.mfa.code_invalid", "auth.missing", "auth.refresh.invalid", diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 13fcf3981..020549058 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -326,9 +326,10 @@ const integrationLedgerState = z "error_reauth", "disconnected", "parked", + "unknown", ]) .describe( - "Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.", + "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never synced sees: the ledger row is created on the first attempt, and its absence is no claim about the connection in either direction — it used to read as `connected`, which is why a client must never treat this field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a ledger state is painted.", ); const nightscoutConnectRequest = nightscoutConnectSchema.meta({ diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 2ce21fc07..f8ee62338 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -559,6 +559,26 @@ const aiProviderResponse = z .describe( "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.", ), + // v1.38.19 — the setup flow's last screen used to promise + // that insights "work for you right away" as soon as `managedBy` read + // `server`. That is a presence read; it never knew whether the key + // worked, and it said nothing about the consent receipt the egress + // actually needs. These three fields make the promise checkable. + serverProviderHealth: z + .enum(["healthy", "unhealthy", "unknown"]) + .describe( + "Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when the last outcome somebody observed was a failure, `unknown` when nothing has been tried lately on this instance (including a brand-new instance whose ledger is still empty) or the read failed. Deliberately carries no count, no timestamp and no account identity — 'the provider of this instance last worked' is a statement about the instance, not about other people. Fails closed for the offer: neither `unhealthy` nor `unknown` may be offered. They are not interchangeable for copy, though — `unhealthy` is a verdict a client may state, `unknown` is the absence of one and must not be reported as an outage. Reading this never probes a provider.", + ), + serverProviderOffer: z + .boolean() + .describe( + "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false.", + ), + serverProviderConsent: z + .boolean() + .describe( + "Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST /api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for that reason rather than for a bad one.", + ), }) .meta({ id: "AiProviderResponse", diff --git a/src/lib/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts index 192dd7e7c..50331d854 100644 --- a/src/lib/record-settings/integrations.ts +++ b/src/lib/record-settings/integrations.ts @@ -5,14 +5,27 @@ import type { /** * A status-ledger row alone cannot prove a connection: integrations without a - * row synthesize a healthy ledger state for the owner Settings dashboard. The + * row synthesize a ledger state the owner Settings dashboard reads. The * managed projection must instead report those as disconnected until its * record carries a real credential or connection row. + * + * This projection's `state` says whether a pipe exists, never whether it is + * live — liveness is `syncHealth.verdict`, and the card beside this pill + * reads that. So the connection map decides first, and only then does the + * ledger state refine the answer. */ export function resolveManagedIntegrationState( state: IntegrationState, connected: Record, integration: IntegrationKey, ): IntegrationState { - return connected[integration] ? state : "disconnected"; + if (!connected[integration]) return "disconnected"; + // v1.38.19 — `unknown` is the ledger saying it holds no row, and past the + // gate above that means a real credential exists whose first sync has not + // landed yet. "A pipe exists" is exactly what this field reports, so it + // reads `connected`. Reporting "Not connected" over a credential the + // guardian created minutes ago invites them to revoke and redo the grant, + // and it would contradict the `connected: true` the same row carries. The + // closed vocabulary this projection publishes stays closed either way. + return state === "unknown" ? "connected" : state; } diff --git a/src/proxy.ts b/src/proxy.ts index 5184878e7..23c1e0cf7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -13,7 +13,7 @@ import { * compose healthcheck rely on it. `/api/health` stays public for the same * reason. */ -const PUBLIC_PATHS = [ +export const PUBLIC_PATHS = [ "/auth/", "/api/auth/login", "/api/auth/register", @@ -142,13 +142,21 @@ function isPublicPath(pathname: string): boolean { * PUT must not drag in the layout-reset DELETE on the same path). * * The login family is the historical baseline — a demo visitor still has - * to authenticate. The two dashboard entries are display-only preference - * writes: idempotent, user-scoped, Zod-validated, and touching nothing but - * the caller's own `User.dashboardWidgetsJson` blob (chart-overlay toggles - * + comparison-baseline selector). They carry no health data and are safe - * to exercise in the demo so the above-chart toggles work there. This whole - * block only runs under `DEMO_MODE=true`, so production (apps01) is - * unaffected by construction. + * to authenticate. The rest are idempotent, Zod-validated writes that carry + * no health data and no free text, and that land only on the acting record: + * the two dashboard display-preference blobs (chart-overlay toggles + + * comparison-baseline selector), the setup flow's closed-enum answers, its + * step ledger and disclaimer/tour checkpoints, and the baseline profile + * fields the confirm screen submits. + * + * The demo is ONE published account that every visitor signs into, so + * "user-scoped" is not privacy here — whatever one visitor writes is what + * the next one finds until the instance is reseeded. That is tolerable for + * a unit toggle and a date of birth, and it is why free text, anything that + * reaches an AI prompt, and anything creating a second record are refused. + * + * This whole block only runs under `DEMO_MODE=true`, so production (apps01) + * is unaffected by construction. */ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = [ @@ -174,6 +182,35 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = // Neither creates health data. { path: "/api/onboarding/disclaimer", method: "POST" }, { path: "/api/onboarding/tour", method: "POST" }, + // v1.39 — the baseline write the confirm screen makes. Without it a demo + // visitor who typed a height got a 403 and a generic toast, so only the + // empty-form/Skip path completed and the demo did not in fact walk the + // same flow (design spec §Principles 5). + // + // Read the HANDLER's field list, not the caller's. This route writes + // `email`, `displayName`, `fullName`, `insurerName`, `insurerIkNumber`, + // `insuranceNumber`, `locale`, `timezone`, `timeFormat`, `dateFormat` and + // `moodReminderEnabled` as well as the three the baseline step collects — + // admitting the path admits all of them, and on one shared published + // account each is the next visitor's starting state. So the write is + // narrowed on the SERVER: `applyProfileUpdate` drops everything outside + // `DEMO_WRITABLE_PROFILE_FIELDS` (height, date of birth, sex) while + // `DEMO_MODE` is set. What this entry admits is those three fields. + // + // Two neighbours of it are deliberately NOT here. The demo is one shared, + // published account, so "the caller's own" is every visitor at once: + // `PUT /api/coach/about-me` is free text that persists for the next + // visitor AND is fed into the Coach prompt — an anonymous prompt- + // injection surface on a public instance. The flow's anamnesis card + // renders read-only under `DEMO_MODE` instead, so no visitor meets it + // as a failed save. + // `POST /api/managed-profiles` opens with `requireFreshMfa`, and the + // demo account has no second factor, so admitting it here would buy + // the guardian arm nothing — the call is refused a layer later either + // way, and the confirm screen already says so. + // The per-profile routes (`/api/managed-profiles/{id}`, its guardians) + // are different paths and stay closed too. + { path: "/api/auth/profile", method: "PUT" }, ]; // Legacy route redirects (German → English) @@ -330,6 +367,45 @@ export function proxy(request: NextRequest) { } } + // v1.38.19 — the invite universal-link landing answers at the EDGE. + // + // `src/app/invite/[token]/page.tsx` has always redirected onto + // `/auth/register?invite=…`, but Next does not deliver that as a server 307: + // it streams the root layout first and puts the redirect in the RSC flight + // payload. `` therefore mounted on the pathname `/invite/…`, and + // before the entry added beside this one it read the route as protected, + // took a 401 from `/api/auth/me` and raced its own + // `router.replace("/auth/login")` against the redirect — logged out the + // login bounce won, and the visitor held a one-time invite on a page that + // cannot use it. Answering here means no HTML, no hydration, no race. + // + // The page stays as the fallback for any path that bypasses the matcher. + // The shape gate is the twin of `looksLikeInviteToken` in + // `src/lib/auth/invite-token.ts`, restated rather than imported: that module + // pulls the Prisma client, which has no business in the proxy bundle. A + // segment that fails it is dropped rather than reflected, so the target can + // never echo attacker-controlled text. + if (pathname.startsWith("/invite/")) { + const token = pathname.slice("/invite/".length).split("/")[0] ?? ""; + const target = new URL("/auth/register", request.url); + if (/^hlv_[0-9a-f]{64}$/.test(token)) { + target.searchParams.set("invite", token); + } + const redirected = applyBaselineSecurityHeaders( + NextResponse.redirect(target, 307), + ); + // The secret rides in the path of the request that produced this hop, so + // the hop itself carries the same defence the landing page earned in + // v1.17.0: uncacheable, unindexable, and never leaked as a `Referer`. + redirected.headers.set( + "Cache-Control", + "no-store, no-cache, must-revalidate", + ); + redirected.headers.set("X-Robots-Tag", "noindex, nofollow"); + redirected.headers.set("Referrer-Policy", "no-referrer"); + return redirected; + } + // Server-side route protection for pages (not API routes — those have their own getSession checks) const isApiRoute = pathname.startsWith("/api/"); const isStaticFile = /\.\w+$/.test(pathname); diff --git a/tests/integration/demo-mode-profile-write.test.ts b/tests/integration/demo-mode-profile-write.test.ts new file mode 100644 index 000000000..5da3aa440 --- /dev/null +++ b/tests/integration/demo-mode-profile-write.test.ts @@ -0,0 +1,179 @@ +/** + * v1.39 — what `PUT /api/auth/profile` may write on a demo instance. + * + * The route is on the proxy's `DEMO_MUTATION_ALLOWLIST` so the setup flow's + * baseline step can complete in the demo. That allowlist is an EDGE control: + * it decides which path and method get through, and once a path is through, + * every field that route writes is writable. This route writes the account's + * contact email, its display name, its full name and its insurer fields — and + * the demo is one published account that every visitor signs into, so any of + * them lands on the record the next visitor sees. + * + * The narrowing therefore cannot live in the client. It is a server-side + * field filter in `applyProfileUpdate`, and this file proves it against real + * Postgres rather than against a status code: the route answers 200 either + * way — it answered 200 while writing the email — so the verdict has to be + * read back off the row. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** What the shared demo account carries before any visitor touches it. */ +const SEEDED = { + username: "demo", + email: "demo@healthlog.test", + displayName: "HealthLog Demo", + fullName: "Demo Person", + insurerName: "Demo Krankenkasse", +}; + +/** What a visitor could put on the shared record through this one route. */ +const HOSTILE = { + email: "attacker@example.test", + displayName: "Owned", + fullName: "Attacker", + insurerName: "Attacker Insurance", +}; + +/** The three the baseline step exists to collect, and the only three + * the demo may write. */ +const BASELINE = { + heightCm: 181, + dateOfBirth: "1988-04-12", + gender: "FEMALE" as const, +}; + +const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; +}); + +async function seedSignedInAccount(): Promise { + const { hashPassword } = await import("@/lib/auth/password"); + const { createSession } = await import("@/lib/auth/session"); + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + ...SEEDED, + role: "USER", + passwordHash: await hashPassword("DemoStrongP@ssw0rd!123abc"), + }, + }); + await createSession(user.id, false); + return user.id; +} + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/profile", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function putProfile(body: unknown): Promise { + const { PUT } = await import("@/app/api/auth/profile/route"); + const res = await PUT(makeRequest(body)); + return res.status; +} + +describe("PUT /api/auth/profile under DEMO_MODE (real Postgres)", () => { + it("writes the baseline fields and drops everything else", async () => { + process.env.DEMO_MODE = "true"; + const userId = await seedSignedInAccount(); + + // Everything in one body, exactly as a visitor could send it. The client + // never offers these, which is precisely why the client cannot be the + // control: this request does not come from the client. + const status = await putProfile({ ...HOSTILE, ...BASELINE }); + expect(status).toBe(200); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + }); + + // The record the next visitor sees is the record the reseed left. + expect(row.email).toBe(SEEDED.email); + expect(row.displayName).toBe(SEEDED.displayName); + expect(row.fullName).toBe(SEEDED.fullName); + expect(row.insurerName).toBe(SEEDED.insurerName); + + // And the step the allowlist entry exists for still completes. + expect(row.heightCm).toBe(BASELINE.heightCm); + expect(row.gender).toBe(BASELINE.gender); + expect(row.dateOfBirth?.toISOString().slice(0, 10)).toBe( + BASELINE.dateOfBirth, + ); + }); + + it("does not answer the 'is this email taken?' question either", async () => { + // The conflict check runs before the write and 409s on a taken address. + // On a public demo that is an existence oracle over the instance's + // accounts, so the narrowing has to happen ahead of it, not after. + process.env.DEMO_MODE = "true"; + const prisma = getPrismaClient(); + await seedSignedInAccount(); + await prisma.user.create({ + data: { + username: "someone-else", + email: "taken@healthlog.test", + role: "USER", + passwordHash: "x", + }, + }); + + expect(await putProfile({ email: "taken@healthlog.test" })).toBe(200); + }); + + it("writes every field on an ordinary instance — the gate is demo mode, not the route", async () => { + delete process.env.DEMO_MODE; + const userId = await seedSignedInAccount(); + + expect(await putProfile({ ...HOSTILE, ...BASELINE })).toBe(200); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + }); + expect(row.email).toBe(HOSTILE.email); + expect(row.displayName).toBe(HOSTILE.displayName); + expect(row.fullName).toBe(HOSTILE.fullName); + expect(row.insurerName).toBe(HOSTILE.insurerName); + expect(row.heightCm).toBe(BASELINE.heightCm); + }); +});