From 82b7c11b0589eaee6af16d194fa98cbffe6ef71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:04:50 +0200 Subject: [PATCH 01/34] feat(ai): a tri-state health of the instance's shared provider, fail closed The done screen promised working insights on the strength of a presence read. A fresh account's own provider-health ledger is empty by construction (the table is unique on user + provider), so the evidence has to be an instance-wide fold. It folds across accounts and returns three words: no counts, no timestamps, no account can be inferred from it. Fails closed against the grain of the ledger it reads. There the fail-open protects a generation already running; here the output is a promise made to a person, so an empty table, a timeout or a read error is 'unknown', and 'unknown' buys nothing. --- .../__tests__/server-provider-health.test.ts | 137 ++++++++++++++++++ src/lib/ai/server-provider-health.ts | 112 ++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/lib/ai/__tests__/server-provider-health.test.ts create mode 100644 src/lib/ai/server-provider-health.ts 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..7e6c58909 --- /dev/null +++ b/src/lib/ai/__tests__/server-provider-health.test.ts @@ -0,0 +1,137 @@ +/** + * v1.38.19 (wave D) — 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 unhealthy when the last success is older than the window", async () => { + rows({ + lastResult: "ok", + lastOkAt: ago(SERVER_PROVIDER_FRESH_WINDOW_MS + 60_000), + }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + 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("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..aea613a71 --- /dev/null +++ b/src/lib/ai/server-provider-health.ts @@ -0,0 +1,112 @@ +/** + * v1.38.19 (wave D) — "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` buys nothing: the caller + * treats it exactly like `unhealthy`. + * + * 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 — it has been tried and it is not working. + * unknown — nobody has tried it yet on this instance, or the read + * failed. Never an offer; see the module note on failing closed. + */ +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, + ); + return workedRecently ? "healthy" : "unhealthy"; +} From dec57326871d5b74a9ddb3d2d5158008dd890aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:07:41 +0200 Subject: [PATCH 02/34] fix(onboarding): checklist queries follow its own setup rule The card's visibility rule gained a "ran the setup flow" disjunct; the component's query gate kept restating the older half of it. A wizard-run account past five readings therefore saw a permanent checklist whose medication, data-source, notification and insight rows were frozen at "not done", because every supporting query was disabled. Both places now read one exported predicate, isStillInSetup, which also keeps the rows live for a restarted run nobody finished. --- .../getting-started-checklist.test.tsx | 139 ++++++++++++++++++ .../onboarding/getting-started-checklist.tsx | 25 +++- .../onboarding/__tests__/checklist.test.ts | 73 +++++++++ src/lib/onboarding/checklist.ts | 46 ++++-- 4 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 src/components/onboarding/__tests__/getting-started-checklist.test.tsx diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx new file mode 100644 index 000000000..cfe0c20e9 --- /dev/null +++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx @@ -0,0 +1,139 @@ +/** + * v1.39 (Wave C, C1) — the checklist's data queries follow the same rule as + * its visibility. + * + * `shouldShowChecklist` gained `hasEnteredOnboardingFlow` as a third + * disjunct; `checklistRelevant` in the component kept restating the older + * half of the rule, so a wizard-run account past five readings still saw the + * card while every supporting query was `enabled: false` — the medication, + * data-source, notification and insights rows read "not done" for things the + * account had already done (research I9). + * + * Rendered SSR-only, per this repo's component-test convention. `useQuery` is + * mocked to honour `enabled` exactly the way tanstack does — a disabled query + * has no data — so what the rows say here is what the dashboard says. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +let currentUser: AuthUser | null = null; +/** Every `enabled` flag the component asked for, keyed by its query key. */ +const enabledByKey = new Map(); + +vi.mock("@/hooks/use-auth", () => ({ + useAccountOnceMounted: () => currentUser, +})); + +vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({ + useDashboardSnapshot: () => ({ + data: { tiles: { summaries: { weight: { count: 12 } } } }, + }), +})); + +const RESPONSES: Record = { + '["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }], + '["user","ai-provider"]': { aiAvailable: true }, + '["integrations","status"]': { + integrations: [{ integration: "whoop", connected: true }], + }, + // Deliberately NOT configured: one open row keeps the card on screen, so + // the rows this test is about are actually rendered. + '["notifications","preferences"]': { channels: [{ enabled: false }] }, +}; + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQuery: (options: { + queryKey: readonly unknown[]; + enabled?: boolean; + }) => { + const key = JSON.stringify(options.queryKey); + const enabled = options.enabled !== false; + enabledByKey.set(key, enabled); + return { data: enabled ? RESPONSES[key] : undefined }; + }, + }; +}); + +const { GettingStartedChecklist } = + await import("../getting-started-checklist"); + +/** A record the flow really finished: Q1 answered, confirmed, task settled. */ +function settledOnboarding(): OnboardingStateDto { + return { + steps: defaultOnboardingSteps().map((step) => ({ + ...step, + status: "done" as const, + })), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me" }, + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: { + task: "log-reading", + target: null, + completedAt: "2026-09-10T08:05:00.000Z", + }, + } as OnboardingStateDto; +} + +function user(overrides: Partial = {}): AuthUser { + return { + id: "u1", + email: "a@b.c", + username: "a", + heightCm: 175, + dateOfBirth: "1990-01-01T00:00:00.000Z", + gender: "MALE", + onboardingCompletedAt: "2026-09-10T08:00:00.000Z", + onboarding: settledOnboarding(), + ...overrides, + } as AuthUser; +} + +function render(): string { + enabledByKey.clear(); + return renderToStaticMarkup( + + + , + ); +} + +describe(" — rows for an established account", () => { + it("reads the real medication, data-source and insights state once the flow is behind the account", () => { + currentUser = user(); + const html = render(); + + // The card is on screen: the notifications row is genuinely open. The + // list itself is collapsed on a server paint, so the progress meter is + // what the rows add up to — profile, measurement, medication, data + // source and insights done, notifications not. + expect(html).toContain('data-testid="onboarding-card"'); + expect(html).toContain("5 of 6 done"); + }); + + it("keeps the supporting queries enabled while the card can render", () => { + currentUser = user(); + render(); + expect(enabledByKey.get('["medications"]')).toBe(true); + expect(enabledByKey.get('["integrations","status"]')).toBe(true); + expect(enabledByKey.get('["notifications","preferences"]')).toBe(true); + expect(enabledByKey.get('["user","ai-provider"]')).toBe(true); + }); + + it("still fetches nothing for a record that never entered the flow and is long past five readings", () => { + currentUser = user({ onboarding: null }); + render(); + expect(enabledByKey.get('["medications"]')).toBe(false); + expect(enabledByKey.get('["integrations","status"]')).toBe(false); + }); +}); diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 09d678d29..15343b037 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -26,6 +26,7 @@ import { queryKeys } from "@/lib/query-keys"; import { buildChecklist, checklistProgress, + isStillInSetup, shouldShowChecklist, upcomingVisitCountFrom, visibleChecklist, @@ -239,17 +240,27 @@ export function GettingStartedChecklist() { }, [snapshotData]); // v1.15.20 — only fetch the checklist's supporting data while the card - // can actually render: the user is still in setup (`shouldShowChecklist`'s - // `onboardingCompletedAt == null || measurementCount < 5` rule) and has - // not dismissed it. Established users used to pay this medications fetch - // (and, pre-v1.5, two more) on every dashboard load for a card that - // never mounts. Gated on the snapshot having resolved so the count is - // real, not the loading-default 0. + // can actually render: the user is still in setup and has not dismissed + // it. Established users used to pay this medications fetch (and, pre-v1.5, + // two more) on every dashboard load for a card that never mounts. Gated on + // the snapshot having resolved so the count is real, not the + // loading-default 0. + // + // v1.39 (Wave C, C1) — the rule is `isStillInSetup`, the same predicate + // `shouldShowChecklist` applies, rather than a restatement of half of it. + // The two drifted apart once already: the visibility rule learned about + // records that ran the setup flow and this gate did not, so the card + // rendered for a wizard-run account past five readings with every + // network-backed row frozen at "not done" (research I9, M-p). const checklistRelevant = !!user && !dismissedAll && snapshotData !== undefined && - (user.onboardingCompletedAt == null || measurementCount < 5); + isStillInSetup({ + onboarding: user.onboarding ?? null, + onboardingCompletedAt: user.onboardingCompletedAt ?? null, + measurementCount, + }); const { data: medsData } = useQuery>({ queryKey: queryKeys.medications(), diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 6d19e8fa1..693f336d3 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 (research I9). + 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 (M-p)", () => { + 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/checklist.ts b/src/lib/onboarding/checklist.ts index c0fda5294..fab022c00 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 (Wave C, C1) — "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" (research I9). A record whose + * restarted run was abandoned is in the same state (M-p): `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); From 0d2bad69e38307f1f73bdd24073fc54215b8c22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:07:45 +0200 Subject: [PATCH 03/34] feat(ai): the provider status says whether the shared provider fits GET /api/user/ai-provider gains three additive fields: the instance-wide health tri-state, whether this caller already holds a receipt, and the one boolean that answers 'may the shared provider be offered in one tap'. The offer is the conjunction of five preconditions and every one of them can only take it away: the operator's provider is the one that would serve the caller, the health is demonstrably healthy, the assistant master and coach flags are on, the acting record holds its own credentials, and no receipt is on file yet. A managed profile is excluded by the positive form of the policy test - 'not deny' would have let a child's record through. Nothing here probes a provider; a probe would bill the operator for every registration on the instance. docs/api/openapi.yaml is regenerated from the schema source. --- docs/api/openapi.yaml | 27 +++ .../user/ai-provider/__tests__/route.test.ts | 159 ++++++++++++++++++ src/app/api/user/ai-provider/route.ts | 55 ++++++ src/lib/openapi/routes/profile.ts | 20 +++ 4 files changed, 261 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ba15e063..37a26fc86 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -44485,6 +44485,30 @@ 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 they have been tried and are + not working, `unknown` when nothing has been tried yet on this instance 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: treat `unknown` exactly like + `unhealthy`. 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), and the caller holds no receipt yet. 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 +44524,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. 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..fbef38c9c 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 (wave D) — 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,128 @@ describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => { expect(JSON.stringify(body)).not.toContain("enc-gateway"); }); }); + +/** + * v1.38.19 (wave D) — 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(); + }); +}); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 524a32d01..9fbe13e47 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 (wave D) — a managed profile is never the party that gives + // consent for itself; the offer below has to know. + managedProfileAt: true, }, }); @@ -45,6 +55,46 @@ 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 (wave D) — 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, + ); + const serverProviderOffer = + managedBy === "server" && + serverProviderHealth === "healthy" && + assistantFlags.enabled && + assistantFlags.coach && + credentialPolicy === "personal" && + !serverProviderConsent; + return apiSuccess({ provider: u?.aiProvider ?? null, model: u?.aiModel ?? null, @@ -67,6 +117,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 (wave D) — 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/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 2ce21fc07..e8b805e81 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 (wave D) — 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 they have been tried and are not working, `unknown` when nothing has been tried yet on this instance 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: treat `unknown` exactly like `unhealthy`. 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), and the caller holds no receipt yet. 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", From 753438a329ca22cfe643053b5cf9354500c30024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:07:57 +0200 Subject: [PATCH 04/34] fix(auth): the invite landing is public in the client shell too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/invite/` has been public in `proxy.ts` since v1.17.0 and was never added to the client-side gate in `auth-shell.tsx`. Next streams the shell before the landing page's RSC redirect resolves, so the shell classified the route as protected, `/api/auth/me` answered 401, and `router.replace( "/auth/login")` won the race — the operator's invite link landed on the sign-in page it was meant to bypass. The shell now lists `/invite/`, and lists the `/auth/` family as the prefix the edge already admits rather than as two individual pages, so a future auth page cannot inherit the same defect. `PUBLIC_PATHS` is exported from `proxy.ts` so a new parameterised guard can render the shell at every page prefix the edge lets through and fail when the two lists drift apart again. --- .../auth-shell-public-paths.test.tsx | 160 ++++++++++++++++++ src/components/layout/auth-shell.tsx | 14 +- src/proxy.ts | 2 +- 3 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/components/layout/__tests__/auth-shell-public-paths.test.tsx diff --git a/src/components/layout/__tests__/auth-shell-public-paths.test.tsx b/src/components/layout/__tests__/auth-shell-public-paths.test.tsx new file mode 100644 index 000000000..98ff62794 --- /dev/null +++ b/src/components/layout/__tests__/auth-shell-public-paths.test.tsx @@ -0,0 +1,160 @@ +/** + * The edge gate and the client gate must agree about which PAGES are public. + * + * `src/proxy.ts` decides whether a request reaches the app at all; + * `auth-shell.tsx` decides whether the mounted shell treats the pathname as + * public or as a protected route that needs `/api/auth/me`. The two lists are + * maintained by hand, and when they drift the visitor pays: Next streams the + * shell before an RSC redirect resolves, so a route the edge admits but the + * shell classifies as protected renders the protected branch and fires + * `router.replace("/auth/login")` against the redirect. That is exactly what + * happened to `/invite/` — public in `proxy.ts` since v1.17.0, never + * added here, and the operator's invite link landed on the login page. + * + * So this file does not pin one route. It parameterises over every PAGE prefix + * the proxy admits and asserts the shell paints the page for each, which makes + * the drift itself the thing under test rather than the one instance of it. + * + * SSR-only, per project convention (node environment, no DOM, no + * `@testing-library/react`): effects never run here, so the assertion is the + * PAINT. On a public path the shell hands the child through; on a protected + * path with auth resolved as unauthenticated it paints the redirect spinner + * instead and the child never appears. The production evidence for the defect + * was the same signal read off a `curl` — `record-scope-hydration-gate` in the + * body of `GET /invite/`. + * + * Mutation check, run: remove `"/invite/"` from the shell's `PUBLIC_PATHS` → + * the `/invite/` case goes red with the child sentinel missing, every other + * 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 mockPathRef = { value: "/" }; + +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathRef.value, + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: false, + isAuthUnknown: false, + isLoading: false, + refetch: vi.fn(), + }), + clearCachesForSessionEnd: vi.fn(), +})); + +vi.mock("@/hooks/use-record-session-transition", () => ({ + useRecordSessionTransition: () => ({ + id: "none", + phase: "ready", + expectedScope: null, + }), +})); + +// Chrome that is not the subject — each pulls its own query cells and portals. +vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null })); +vi.mock("../bottom-nav", () => ({ BottomNav: () => null })); +vi.mock("../top-bar", () => ({ TopBar: () => null })); +vi.mock("../offline-banner", () => ({ OfflineBanner: () => null })); +vi.mock("../demo-banner", () => ({ DemoBanner: () => null })); +vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null })); +vi.mock("../shared-record-unavailable", () => ({ + SharedRecordUnavailable: () => null, +})); +vi.mock("@/components/i18n/maintainership-banner", () => ({ + MaintainershipBanner: () => null, +})); +vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({ + AchievementUnlockNotifier: () => null, +})); +vi.mock("@/components/insights/layout-coach-fab", () => ({ + LayoutCoachFab: () => null, +})); +vi.mock("@/components/insights/layout-coach-mount", () => ({ + LayoutCoachMount: () => null, +})); +vi.mock("@/components/onboarding/tour-launcher", () => ({ + TourLauncher: () => null, +})); + +import { AuthShell } from "../auth-shell"; +// The proxy's list is the source of truth for what the edge lets through. +import { PUBLIC_PATHS as PROXY_PUBLIC_PATHS } from "@/proxy"; + +/** + * Prefixes the proxy admits that are NOT pages — route handlers with no + * React tree, so `` never mounts on them and has nothing to say + * about them. Named explicitly rather than pattern-matched: a new non-page + * public prefix should turn this file red once, so its author classifies it + * here deliberately instead of a regex swallowing a real page. + */ +const NON_PAGE_PREFIXES = new Set([ + "/i18n/", // locale-catalog boot script + "/robots.txt", // static text route +]); + +const PAGE_PREFIXES = PROXY_PUBLIC_PATHS.filter( + (p) => + !p.startsWith("/api/") && + !p.startsWith("/.well-known") && + !NON_PAGE_PREFIXES.has(p), +); + +function render(pathname: string): string { + mockPathRef.value = pathname; + return renderToStaticMarkup( + + + +
+ + + , + ); +} + +/** + * A representative pathname under a prefix. A trailing-slash prefix admits + * EVERY path beneath it at the edge, so the sample is a segment that no + * narrower shell entry could be covering by accident — otherwise the guard + * would pass on the one page that happens to exist there today and miss the + * next one. + */ +function sampleFor(prefix: string): string { + return prefix.endsWith("/") ? `${prefix}probe` : prefix; +} + +describe("AuthShell public paths agree with proxy.ts", () => { + it("has page prefixes to check at all", () => { + expect(PAGE_PREFIXES.length).toBeGreaterThan(0); + expect(PAGE_PREFIXES).toContain("/invite/"); + }); + + it.each(PAGE_PREFIXES)( + "renders the page for %s instead of the sign-in gate", + (prefix) => { + const markup = render(sampleFor(prefix)); + expect(markup).toContain('data-slot="sentinel-page"'); + }, + ); + + it("renders the invite landing for a real token path", () => { + // The instance of the class: the operator's link, byte for byte. + const markup = render(`/invite/hlv_${"a".repeat(64)}`); + expect(markup).toContain('data-slot="sentinel-page"'); + }); + + it("still gates a protected route", () => { + // Negative check — confirms the fixture would catch a real regression. + const markup = render("/insights"); + expect(markup).not.toContain('data-slot="sentinel-page"'); + }); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index c49a352dc..c10364e59 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -40,8 +40,12 @@ import { TopBar } from "./top-bar"; // both lists must agree or the route round-trips through the // sign-in redirect. const PUBLIC_PATHS = [ - "/auth/login", - "/auth/register", + // The whole `/auth/` family, exactly as `proxy.ts` admits it. Listed as the + // prefix rather than as `/auth/login` + `/auth/register`: the edge already + // lets every path under it through without a session, so a future auth page + // that the shell did not know about would mount the protected branch and + // bounce its own visitor to the sign-in it is trying to replace. + "/auth/", "/privacy", "/about", // v1.11.0 — the public clinician view renders its own standalone chrome @@ -49,6 +53,12 @@ const PUBLIC_PATHS = [ // bare; the route is also in `proxy.ts` PUBLIC_PATHS so it never round-trips // the sign-in redirect. "/c/", + // v1.17.0 — the invite universal-link landing is a server redirect onto + // `/auth/register?invite=…`. It is public in `proxy.ts`, and it must be + // public here too: Next streams this shell before the redirect resolves, + // so without the entry the shell classifies the route as protected and + // `router.replace("/auth/login")` wins the race against the redirect. + "/invite/", ]; export function AuthShell({ diff --git a/src/proxy.ts b/src/proxy.ts index 5184878e7..b516e9c0f 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", From 0bdbf009f6a14d393768dd584036a2721caf59cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:08:38 +0200 Subject: [PATCH 05/34] fix(integrations): a provider with no history is "unknown" The ledger's synthetic "no row" snapshot returned `state: "connected"`, so the field's default meant the opposite of its name: every account that had never connected a provider published `connected` on the status envelope. Settings survived it by never reading `state`; the setup flow's connect step did read it, and told brand-new accounts their wearable was connected. The snapshot now reads `unknown`. `syncHealth.verdict` is unchanged and stays the only liveness truth; the managed-record projection maps `unknown` onto `disconnected` the way it already maps a missing connection. --- docs/api/openapi.yaml | 27 ++++++++----- .../status/__tests__/route.test.ts | 39 +++++++++++++++++++ .../integrations/oauth-provider-card.tsx | 13 ++++--- .../settings/integrations/shared.tsx | 10 ++++- src/lib/integrations/__tests__/status.test.ts | 23 +++++++++++ src/lib/integrations/status.ts | 27 ++++++++++--- src/lib/openapi/routes/integrations.ts | 3 +- src/lib/record-settings/integrations.ts | 7 +++- 8 files changed, 126 insertions(+), 23 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ba15e063..b674ba177 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -64323,9 +64323,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 +65321,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 +65730,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/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 31f546594..75c79e084 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 (wave B / I5) — 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/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/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 6f689fba9..99845eedd 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 (wave B / I5) — 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/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/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts index 192dd7e7c..cadea4616 100644 --- a/src/lib/record-settings/integrations.ts +++ b/src/lib/record-settings/integrations.ts @@ -14,5 +14,10 @@ export function resolveManagedIntegrationState( 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. The managed + // projection publishes a closed vocabulary its card paints from, and + // "no history" is not one of its states, so it reads as `disconnected` + // here exactly as it does everywhere else. + return state === "unknown" ? "disconnected" : state; } From cfca84a2944ebca2b04581a6e9aea88163487b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:09:46 +0200 Subject: [PATCH 06/34] fix(auth): the invite landing redirects at the edge, no shell, no race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy now answers `/invite/` with a real 307 onto `/auth/register?invite=…` (or a bare `/auth/register` when the segment fails the `hlv_` shape gate), carrying the baseline transport headers plus the no-store / noindex / no-referrer hardening the token-in-path surface has had since v1.17.0. Before this the redirect came from the landing page, which Next delivers in the RSC flight payload rather than as a server 307 — the shell mounted first and raced it. At the edge there is no HTML and no hydration, so there is nothing to race. The page stays in place as the fallback for any path that bypasses the proxy matcher. --- src/__tests__/proxy-invite-public.test.ts | 80 +++++++++++++++++++++-- src/proxy.ts | 39 +++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) 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/proxy.ts b/src/proxy.ts index b516e9c0f..4a582c538 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -330,6 +330,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); From 0e79b195b0b58dd80ebb292c47c062922094c523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:12:36 +0200 Subject: [PATCH 07/34] fix(onboarding): a failed completion leaves confirm usable The baseline form raised its pending flag and lowered it on only two of three exits; the success path went through the confirm screen's finish(), which caught its own error and returned normally. A failed completion therefore disabled both buttons until the page was reloaded. The reset now lives in a finally, finish() rethrows so the form sees the failure, and the two managed-arm buttons report it themselves. The step passes before the completion run off a ledger ref, so a retry after a failure midway no longer re-sends a step the ledger already carries. --- .../confirm-failure-recovery.test.ts | 155 ++++++++++++++++++ src/components/onboarding/baseline-form.tsx | 109 +++++++----- src/components/onboarding/confirm-screen.tsx | 103 +++++++++--- 3 files changed, 303 insertions(+), 64 deletions(-) create mode 100644 src/components/onboarding/__tests__/confirm-failure-recovery.test.ts diff --git a/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts new file mode 100644 index 000000000..0fae32763 --- /dev/null +++ b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts @@ -0,0 +1,155 @@ +/** + * v1.39 (Wave C, C2) — a failed completion leaves the confirm screen usable. + * + * Two defects, one symptom. `advance()` in the baseline form set its pending + * flag and never reset it on the success path, and `finish()` on the confirm + * screen caught its own error and returned normally — so a failed + * `POST /api/onboarding/complete` (or a 429 on the answer PATCHes before it) + * left "Überspringen" and "Bestätigen und weiter" disabled for the rest of the + * page's life and only a reload recovered (research I10). + * + * The second defect is the retry itself: `finish()` fires one PATCH per + * question the flow never showed, and a failure midway left a partly-passed + * ledger that the next attempt walked from the top again (M-j). + * + * Both are pinned at the seam the component drives, because this repo's + * component tests render server-side and cannot click. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +import { runBaselineAttempt } from "../baseline-form"; +import { runConfirmFinish } from "../confirm-screen"; + +type PassableStep = Parameters< + Parameters[0]["passStep"] +>[0]; + +/** + * A flow with no unit-bearing area: Q6 is the question the machine never + * showed, so the confirm screen has to pass it on the ledger before the + * completion. + */ +function state( + overrides: Partial = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps().map((step) => + step.id === "who" ? { ...step, status: "done" as const } : step, + ), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me" }, + completedAt: null, + firstResult: null, + ...overrides, + }; +} + +/** The ledger as the server would answer it after one step was passed. */ +function withPassed( + base: OnboardingStateDto, + ids: readonly string[], +): OnboardingStateDto { + return { + ...base, + steps: base.steps.map((step) => + ids.includes(step.id) ? { ...step, status: "skipped" as const } : step, + ), + }; +} + +describe("runBaselineAttempt", () => { + it("hands the buttons back when the completion rejects", async () => { + const pending: boolean[] = []; + const onError = vi.fn(); + await runBaselineAttempt( + (value) => pending.push(value), + async () => { + throw new Error("complete failed"); + }, + onError, + ); + expect(pending).toEqual([true, false]); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("hands the buttons back on the success path too", async () => { + const pending: boolean[] = []; + await runBaselineAttempt( + (value) => pending.push(value), + async () => {}, + vi.fn(), + ); + expect(pending).toEqual([true, false]); + }); + + it("is the form's only way to move the pending flag", () => { + // A second, inline `setSaving(false)` is how the flag stopped being + // reset on one of the three exits in the first place. + const source = readFileSync( + join(process.cwd(), "src/components/onboarding/baseline-form.tsx"), + "utf8", + ); + expect(source).not.toContain("setSaving(false)"); + expect(source).not.toContain("setSaving(true)"); + }); +}); + +describe("runConfirmFinish", () => { + it("rethrows a failed completion instead of returning normally", async () => { + const ledger = { current: state() }; + const passed: PassableStep[] = []; + const navigate = vi.fn(); + await expect( + runConfirmFinish({ + ledger, + // The server answers the written ledger; only the runner may put it + // back, which is the whole point of the ref. + passStep: async (step) => { + passed.push(step); + return withPassed(ledger.current, [step]); + }, + complete: async () => { + throw new Error("429"); + }, + navigate, + }), + ).rejects.toThrow("429"); + // Q6 was never shown for a flow with no unit-bearing area, so it is + // passed on the ledger before the completion — and that write survived. + expect(passed).toEqual(["units"]); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("does not pass a step the failed attempt already passed (M-j)", async () => { + const ledger = { current: state() }; + const passed: PassableStep[] = []; + const passStep = async (step: PassableStep) => { + passed.push(step); + return withPassed(ledger.current, [step]); + }; + let attempt = 0; + const complete = async () => { + attempt += 1; + if (attempt === 1) throw new Error("429"); + return { onboarding: ledger.current }; + }; + const navigate = vi.fn(); + + await expect( + runConfirmFinish({ ledger, passStep, complete, navigate }), + ).rejects.toThrow("429"); + await runConfirmFinish({ ledger, passStep, complete, navigate }); + + // "units" exactly once across both attempts; "first-result" is the + // second attempt's own pass, because these answers offer no task. + expect(passed).toEqual(["units", "first-result"]); + expect(navigate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 3f0cdc266..5ff30b80f 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -71,6 +71,37 @@ interface BaselineFormState { gender: string; } +/** + * v1.39 (Wave C, C2) — one attempt at confirming, with the buttons always + * handed back. + * + * `advance()` used to raise its own pending flag and lower it again on two of + * the three exits: the refused-field return and its catch. The third exit — + * the success path, `await onConfirmed()` — never lowered it, and + * `onConfirmed` is the confirm screen's completion, which caught its own + * error and returned normally. So a failed completion disabled "Skip" and + * "Confirm and continue" for the rest of the page's life and only a reload + * recovered (research I10). + * + * The reset lives in a `finally` here, which is the one place it cannot be + * forgotten on a new exit, and the component has no other way to move the + * flag. + */ +export async function runBaselineAttempt( + setPending: (pending: boolean) => void, + attempt: () => Promise, + onError: (err: unknown) => void, +): Promise { + setPending(true); + try { + await attempt(); + } catch (err) { + onError(err); + } finally { + setPending(false); + } +} + export function BaselineForm({ initial, backHref, @@ -152,50 +183,50 @@ export function BaselineForm({ async function advance(opts: { saveProfile: boolean }) { if (saving || confirming) return; - setSaving(true); setFieldErrors({}); - try { - if (opts.saveProfile) { - const profileBody = buildBaselineProfileBody( - form, - heightAdapter.toCanonicalCm(form.height), - ); - if (Object.keys(profileBody).length > 0) { - const outcome = describeBaselineSaveOutcome( - await putBaselineProfile(profileBody), - t, - baselineFieldLabelKeys(heightAdapter.usesFeetInches), + await runBaselineAttempt( + setSaving, + async () => { + if (opts.saveProfile) { + const profileBody = buildBaselineProfileBody( + form, + heightAdapter.toCanonicalCm(form.height), ); - if (outcome.notice) { - const show = - outcome.notice.tone === "warning" ? toast.warning : toast.error; - show(outcome.notice.message); + if (Object.keys(profileBody).length > 0) { + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(profileBody), + t, + baselineFieldLabelKeys(heightAdapter.usesFeetInches), + ); + if (outcome.notice) { + const show = + outcome.notice.tone === "warning" ? toast.warning : toast.error; + show(outcome.notice.message); + } + if (!outcome.advance) { + // A field was refused. Staying on the step is the point — + // each refused input now says why under itself, the values + // the person typed are still in front of them, and the + // account is not stamped as set up over a value that never + // landed. + setFieldErrors(outcome.fieldErrors); + return; + } } - if (!outcome.advance) { - // A field was refused. Staying on the step is the point — - // each refused input now says why under itself, the values - // the person typed are still in front of them, and the - // account is not stamped as set up over a value that never - // landed. - setFieldErrors(outcome.fieldErrors); - setSaving(false); - return; - } - } - // Anamnesis — only write when the user actually filled a field - // (the helper returns null for an untouched card, so a - // collapsed card never round-trips). - const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis); - if (aboutMeBody) { - await apiPut("/api/coach/about-me", aboutMeBody); + // Anamnesis — only write when the user actually filled a field + // (the helper returns null for an untouched card, so a + // collapsed card never round-trips). + const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis); + if (aboutMeBody) { + await apiPut("/api/coach/about-me", aboutMeBody); + } } - } - await onConfirmed(); - } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); - setSaving(false); - } + await onConfirmed(); + }, + (err) => + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")), + ); } return ( diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 9a0b053ca..8f55be3a5 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; @@ -20,7 +20,13 @@ import { localeLabels } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_REGISTRY } from "@/lib/modules/registry"; import { confirmedModules } from "@/lib/onboarding/confirm-summary"; -import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { + ONBOARDING_SKIPPABLE_STEP_IDS, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +/** A step the confirm screen may pass on the ledger without an answer. */ +type PassableStepId = (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number]; import { firstResultApplies, nextScreen, @@ -51,6 +57,40 @@ import { * offer, otherwise done. When there is no task, the first-result step is * passed explicitly so the ledger reads as settled rather than owed. */ +/** + * v1.39 (Wave C, C2) — the completion sequence, apart from the component so + * a failure partway through it can be pinned without a browser. + * + * Passes every question the flow never showed (the route derives only once + * EVERY question is answered or passed, and a step nobody was asked cannot be + * answered), completes, passes the first-result step when there is no task to + * offer, and navigates. It reports a failure by THROWING — the caller owns the + * buttons and the toast. + */ +export async function runConfirmFinish(args: { + /** The ledger as the last write left it; updated in place as steps pass. */ + ledger: { current: OnboardingStateDto }; + managedRecordId?: string; + passStep: (step: PassableStepId) => Promise; + complete: ( + managedRecordId?: string, + ) => Promise<{ onboarding?: OnboardingStateDto | null }>; + navigate: (href: string) => void; +}): Promise { + for (const id of questionsToPassBeforeConfirm(args.ledger.current)) { + args.ledger.current = await args.passStep(id); + } + const { onboarding } = await args.complete(args.managedRecordId); + let written = onboarding ?? args.ledger.current; + if (!firstResultApplies(written)) { + // Nothing to offer, so the step is passed rather than left owed: a + // pending step would keep the setup reading as unfinished forever. + written = await args.passStep("first-result"); + } + args.ledger.current = written; + args.navigate(screenHref(nextScreen(written, "confirm") ?? "done")); +} + export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const { t, locale } = useTranslations(); const router = useRouter(); @@ -58,6 +98,17 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const complete = useOnboardingComplete(); const answer = useOnboardingAnswer(); const [finishing, setFinishing] = useState(false); + /** + * v1.39 (Wave C, C2) — the ledger as the last write left it (M-j). + * `finish()` sends one PATCH per question the flow never showed; a failure + * midway used to leave a partly-passed ledger that the next attempt walked + * from the top again, because the `state` prop it read was the one this + * screen was rendered with. Every mutation answers the written state, so + * the ref carries the progress across attempts and + * `questionsToPassBeforeConfirm` — which lists pending steps only — then + * skips what the failed attempt already passed. + */ + const ledger = useRef(state); const needs = state.needs; const forSomeoneElse = needs.recordTarget === "someone-else"; @@ -73,37 +124,39 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { * after" just created: the route then applies the derivation to THAT * record and stamps this one complete without deriving, so the guardian's * own modules are never re-ordered around the child's answers. + * + * v1.39 (Wave C, C2) — this RETHROWS. `` owns the two + * buttons and releases them in its own `finally`, and it can only do that + * for a failure it is told about: the previous version caught, toasted and + * returned normally, which left both buttons disabled until a reload + * (research I10). The two managed-arm callers, which have no form around + * them, go through `finishReporting` below. */ async function finish(managedRecordId?: string) { if (finishing) return; setFinishing(true); try { - // A question this flow never showed — Q6 for somebody with no - // unit-bearing area — is passed on the ledger before the completion, - // because the route derives only once EVERY question is answered or - // passed, and a step nobody was asked cannot be answered. - for (const id of questionsToPassBeforeConfirm(state)) { - await answer.mutateAsync({ step: id, status: "skipped" }); - } - const { onboarding } = await complete.mutateAsync( - managedRecordId ? { managedRecordId } : undefined, - ); - let written = onboarding ?? state; - if (!firstResultApplies(written)) { - // Nothing to offer, so the step is passed rather than left owed: - // a pending step would keep the setup reading as unfinished forever. - written = await answer.mutateAsync({ - step: "first-result", - status: "skipped", - }); - } - router.push(screenHref(nextScreen(written, "confirm") ?? "done")); + await runConfirmFinish({ + ledger, + managedRecordId, + passStep: (step) => answer.mutateAsync({ step, status: "skipped" }), + complete: (id) => + complete.mutateAsync(id ? { managedRecordId: id } : undefined), + navigate: (href) => router.push(href), + }); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); setFinishing(false); + throw err; } } + /** The managed arm's two buttons: nothing above them reports a failure. */ + function finishReporting(managedRecordId?: string) { + void finish(managedRecordId).catch((err) => { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + }); + } + const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) => keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", "); @@ -196,7 +249,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
void finish(profile.id)} + onCreated={(profile) => finishReporting(profile.id)} />

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

@@ -212,7 +265,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { +
+ ) : null} + + {sharedUnavailable ? ( +
+

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

+

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

+
+ ) : null} +

{t("onboarding.ai.ladderTitle")} From 77a09a5080347754fb81f42f65ed41544e4ec869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:14:34 +0200 Subject: [PATCH 09/34] fix(onboarding): an empty multi-select is not an answer Q2, Q4 and Q6 let "Weiter" write an answer with nothing ticked. On Q2 that answered "no areas", which is what switched every optional module off for somebody whose click meant "no preference"; on Q6 it marked the step done while recording no unit at all. An empty selection now yields no body, so the primary button is dead until something is chosen. Skip stays the way past, and Skip is the conservative derivation the completion route already applies. --- .../__tests__/units-screen.test.tsx | 82 +++++++++++++++++++ src/components/onboarding/units-screen.tsx | 15 +++- .../__tests__/question-config.test.ts | 27 ++++-- src/lib/onboarding/question-config.ts | 30 +++++-- 4 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 src/components/onboarding/__tests__/units-screen.test.tsx diff --git a/src/components/onboarding/__tests__/units-screen.test.tsx b/src/components/onboarding/__tests__/units-screen.test.tsx new file mode 100644 index 000000000..9dfeb5de5 --- /dev/null +++ b/src/components/onboarding/__tests__/units-screen.test.tsx @@ -0,0 +1,82 @@ +/** + * v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k). + * + * "Weiter" used to be live on an untouched units screen: it wrote + * `{ units: {} }`, which marked the step DONE without recording a preference, + * so the account carried "asked and answered" for a question nobody answered. + * Skip is the way past a question with no opinion, and Skip says so on the + * ledger. + * + * Rendered server-side, this repo's component-test convention. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +import { UnitsScreen } from "../units-screen"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null }), +})); + +function state( + needs: Partial, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs }, + completedAt: null, + firstResult: null, + }; +} + +function render(needs: Partial): string { + return renderToStaticMarkup( + + + + + , + ); +} + +/** + * Is the primary button's own tag disabled? Read off the tag rather than the + * whole page, and off the attribute rather than the string "disabled", which + * also occurs in the button's `disabled:` utility classes. + */ +function nextIsDisabled(html: string): boolean { + const at = html.indexOf('data-slot="onboarding-next"'); + expect(at).toBeGreaterThan(-1); + const open = html.lastIndexOf("", at) + 1).includes('disabled=""'); +} + +describe("", () => { + it("does not let the person continue without choosing a unit", () => { + const html = render({ areas: ["glucose"] }); + expect(nextIsDisabled(html)).toBe(true); + // Skip is still there: it is the honest way past a question with no + // opinion, and it says "skipped" on the ledger. + expect(html).toContain('data-slot="onboarding-skip"'); + }); + + it("lets the person continue once a unit is chosen", () => { + const html = render({ + areas: ["glucose"], + units: { glucoseUnit: "mg/dL", unitPreference: null }, + }); + expect(nextIsDisabled(html)).toBe(false); + }); +}); 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/lib/onboarding/__tests__/question-config.test.ts b/src/lib/onboarding/__tests__/question-config.test.ts index 0151f4204..98c14d46a 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 (I4)", () => { + // 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 (M-k)", () => { + // 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/question-config.ts b/src/lib/onboarding/question-config.ts index 547b28cd0..e3ea93734 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 (Wave C, C3) — an empty tick list is not an answer either (research + * I4). 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 (Wave C, C3) — nothing chosen is not an answer (M-k): an empty + // units body marked Q6 done while recording no preference at all. + return Object.keys(units).length > 0 ? { step: "units", units } : null; } From ce3c844a58993ca77e5065135065fd44c3e060f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:15:11 +0200 Subject: [PATCH 10/34] fix(auth): an invitation never registers over a live session `POST /api/auth/register` read no session at all, so a request carrying a valid `healthlog_session` was handled exactly like an anonymous one and the unconditional `createSession` at the end replaced the caller's cookie with the new 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. The route now resolves the optional session first and refuses with 409 `already_authenticated`, before the rate-limit bucket is spent and before the invitation is consumed, so a signed-in visitor neither burns a use nor locks the door for the person the invitation is for. The register page renders an explaining panel with a sign-out button in place of the form, so nobody has to fill three inputs to discover the refusal; signing out returns to the same invitation rather than to the login screen. Copy lands in all seven locales. --- messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/ko.json | 5 + messages/pl.json | 5 + .../api/auth/register/__tests__/route.test.ts | 109 ++++++++++++++++- src/app/api/auth/register/route.ts | 27 +++- src/app/auth/register/__tests__/page.test.tsx | 115 ++++++++++++++++++ src/app/auth/register/page.tsx | 91 +++++++++++++- 11 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/app/auth/register/__tests__/page.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..15c2e8336 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", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..4fa5a0ba7 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", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..964f472bf 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", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..d1c5e8387 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", diff --git a/messages/it.json b/messages/it.json index d965b3827..c77cff0bc 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", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..33e6e738a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -413,6 +413,11 @@ "loginFailed": "로그인 실패", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", + "alreadySignedIn": { + "title": "이미 로그인되어 있습니다", + "body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.", + "signOut": "로그아웃하고 초대 수락하기" + }, "createAccount": "계정 만들기", "newAccount": "새 계정 만들기", "backToPasskey": "패스키로 돌아가기", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..d4ee7da18 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", diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 870a5e1b2..088f1d040 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("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..c8b8ca48f 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: "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/auth/register/__tests__/page.test.tsx b/src/app/auth/register/__tests__/page.test.tsx new file mode 100644 index 000000000..47272db5a --- /dev/null +++ b/src/app/auth/register/__tests__/page.test.tsx @@ -0,0 +1,115 @@ +/** + * 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 `isAuthenticated` 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"'); + }); +}); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 73c1f10a6..b47fe23ea 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,15 @@ 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. `isLoading` holds the panel back + // until the session question is actually answered — failing closed the + // other way round would flash "you are already signed in" at somebody who + // is not. + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); + const [signingOut, setSigningOut] = useState(false); const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -69,6 +79,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 (isAuthenticated && !authLoading) { + 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 From da58d1664239fc7c4adf3fa221cdb71dfc8f7f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:15:32 +0200 Subject: [PATCH 11/34] fix(onboarding): the connect step reads the sync verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step decided "connected" from the ledger's `state` and had one piece of copy for every outcome: "the first sync is in progress". That sentence was false for a connection delivering for months, and worse for one the person has to repair — the screen said everything was fine while the grant was gone. `connectSourceView` resolves the envelope entry into one of four tiles and says whether the flow may record its result. Only `pending_first_sync` and `fresh` may; `stale` is acknowledged without being claimed as new; `stalled`, `failing`, `reauth_required` and `parked` name the state and link at the page that can change it. An unresolved envelope paints no tile at all. A source the instance cannot connect — a BYO provider with no client pair, a shared-app provider with no app — now points at the credentials instead of offering a "Connect" button onto a card that has none. --- messages/de.json | 40 +++- messages/en.json | 40 +++- messages/es.json | 40 +++- messages/fr.json | 40 +++- messages/it.json | 40 +++- messages/ko.json | 40 +++- messages/pl.json | 40 +++- .../__tests__/first-result-screen.test.tsx | 194 ++++++++++++++++++ .../onboarding/first-result-screen.tsx | 144 +++++++++++-- .../__tests__/first-result-config.test.ts | 181 ++++++++++++++++ src/lib/onboarding/first-result-config.ts | 129 +++++++++++- 11 files changed, 883 insertions(+), 45 deletions(-) create mode 100644 src/components/onboarding/__tests__/first-result-screen.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..a5635870d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4677,12 +4677,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", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..f176687e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4677,12 +4677,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", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..7a5cb4ca3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4677,12 +4677,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", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..aece35130 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4677,12 +4677,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", diff --git a/messages/it.json b/messages/it.json index d965b3827..0f61259f0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4677,12 +4677,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", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..e2b1833e3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4677,12 +4677,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": "첫 약 추가하기", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..5a2085acc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4677,12 +4677,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", diff --git a/src/components/onboarding/__tests__/first-result-screen.test.tsx b/src/components/onboarding/__tests__/first-result-screen.test.tsx new file mode 100644 index 000000000..af21c21d9 --- /dev/null +++ b/src/components/onboarding/__tests__/first-result-screen.test.tsx @@ -0,0 +1,194 @@ +/** + * v1.38.19 (wave B / C1, C2, I1, I2, I3) — the connect arm of the one task + * the flow ends on. + * + * This arm is the only place in the flow that renders a claim about real + * account state, and it shipped without a test. It decided "connected" from + * the ledger's `state` field, whose "no row" default was `connected` — so a + * brand-new account that ticked WHOOP on Q4 was told WHOOP was connected and + * had the flow's one result stamped as achieved on its behalf. It also had a + * single piece of copy, "the first sync is in progress", for every outcome, + * including the two that need the person to act. + * + * Vitest renders without a DOM here (project convention: SSR only), so what + * is pinned is the first paint — which tile the screen paints for each of the + * eight verdicts and what it says. Whether the result is RECORDED is decided + * by `connectSourceView(...).settled` and pinned in + * `src/lib/onboarding/__tests__/first-result-config.test.ts`, where the eight + * verdicts are enumerated against the decision itself rather than its markup. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("../use-onboarding-flow", async () => { + const actual = await vi.importActual( + "../use-onboarding-flow", + ); + return { + ...actual, + useOnboardingAnswer: () => ({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, + }), + }; +}); + +const envelope = vi.hoisted(() => ({ + value: undefined as { isLoading: boolean; data: unknown } | undefined, +})); + +vi.mock("@/components/settings/integrations/shared", async () => { + const actual = await vi.importActual< + typeof import("@/components/settings/integrations/shared") + >("@/components/settings/integrations/shared"); + return { + ...actual, + useIntegrationStatuses: () => envelope.value, + }; +}); + +import { FirstResultScreen } from "../first-result-screen"; + +interface StatusOverrides { + connected?: boolean; + configured?: boolean; + available?: boolean; + state?: string; + verdict?: SyncVerdict; + since?: string | null; + lastSuccessAt?: string | null; +} + +function statuses(overrides: StatusOverrides | null) { + envelope.value = + overrides === null + ? { isLoading: true, data: undefined } + : { + isLoading: false, + data: { + threshold: 3, + integrations: [ + { + integration: "whoop", + connected: overrides.connected ?? false, + configured: overrides.configured ?? true, + available: overrides.available, + state: overrides.state ?? "unknown", + lastSuccessAt: overrides.lastSuccessAt ?? null, + lastAttemptAt: null, + lastError: null, + consecutiveFailuresByKind: null, + syncHealth: { + verdict: overrides.verdict ?? "disconnected", + since: overrides.since ?? null, + }, + metricFreshness: [], + }, + ], + }, + }; +} + +function state(): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { + ...emptyOnboardingNeeds(), + recordTarget: "me", + sources: ["whoop"], + }, + completedAt: null, + firstResult: null, + }; +} + +function render(overrides: StatusOverrides | null) { + statuses(overrides); + return renderToStaticMarkup( + + + , + ); +} + +const VERDICT_SLOT = [ + ["disconnected", "connect"], + ["pending_first_sync", "result"], + ["fresh", "result"], + ["stale", "result"], + ["stalled", "attention"], + ["failing", "attention"], + ["reauth_required", "attention"], + ["parked", "attention"], +] as const; + +describe(" — the connect arm", () => { + it.each(VERDICT_SLOT)( + "paints the %s verdict on the %s tile", + (verdict, slot) => { + const html = render({ + connected: verdict !== "disconnected", + verdict, + since: "2026-09-01T00:00:00.000Z", + lastSuccessAt: "2026-09-01T00:00:00.000Z", + }); + expect(html).toContain(`data-connect-slot="${slot}"`); + }, + ); + + it("never claims a connection for a fresh account's empty ledger", () => { + // The exact envelope a brand-new account publishes: no row for the + // provider, so the ledger state is the synthetic `unknown`. + const html = render({ connected: false, state: "unknown" }); + expect(html).toContain('data-connect-slot="connect"'); + expect(html).not.toContain("is connected"); + expect(html).not.toContain("The first sync is in progress"); + }); + + it("says the first sync is running only while it actually is", () => { + expect( + render({ connected: true, verdict: "pending_first_sync" }), + ).toContain("The first sync is in progress"); + expect( + render({ + connected: true, + verdict: "fresh", + lastSuccessAt: "2026-09-01T00:00:00.000Z", + }), + ).not.toContain("The first sync is in progress"); + }); + + it("names the state a connection that needs repairing is in", () => { + const html = render({ + connected: true, + verdict: "reauth_required", + since: "2026-09-01T00:00:00.000Z", + }); + expect(html).toContain("needs renewing"); + expect(html).toContain("/settings/integrations#whoop"); + }); + + it("points a source the instance cannot connect at the credentials", () => { + const html = render({ connected: false, configured: false }); + expect(html).toContain('data-connect-slot="credentials"'); + expect(html).toContain("no WHOOP app on file yet"); + }); + + it("decides nothing while the status envelope is still in flight", () => { + const html = render(null); + expect(html).not.toContain("data-connect-slot="); + expect(html).toContain('data-slot="onboarding-task-checking"'); + }); +}); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index d77894ab6..d203b0dcf 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -33,7 +33,9 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry"; import { AREA_PAGE_HREF, AREA_READING_TARGETS, + connectSourceView, SOURCE_INTEGRATION, + type ConnectSourceSlot, } from "@/lib/onboarding/first-result-config"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; @@ -193,32 +195,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 +289,8 @@ function ConnectSourceTask({

{t("onboarding.flow.first-result.connect-source.howTo", { @@ -234,21 +298,58 @@ 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}

+ +
+ ); +} + /* ── add a medication ──────────────────────────────────────────────────── */ interface MedicationDetail { @@ -508,11 +609,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 (
{ } }); }); + +/** + * v1.38.19 (wave B / C1, I2, I3) — 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(); + }, + ); +}); diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts index 5b10a99bc..d320e7c6d 100644 --- a/src/lib/onboarding/first-result-config.ts +++ b/src/lib/onboarding/first-result-config.ts @@ -7,6 +7,7 @@ * 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"; @@ -47,19 +48,131 @@ 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; + } +} From be7fbe37d522f760da23ce382ac914c2a173909e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:16:24 +0200 Subject: [PATCH 12/34] docs(access): the grant card says the invitee needs an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sharing invitation carries no link and nothing is e-mailed — it is addressed to an account that already exists on this instance, and the person accepts it from inside their own session. The card's input hint already named the account requirement and left the rest unsaid, so an inviter looking for a link to send had nothing to find and no reason to stop looking. The card now names where the invitation turns up, in all seven locales. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../grant-invite-needs-account.test.tsx | 51 +++++++++++++++++++ .../settings/access/grant-invite-card.tsx | 9 +++- 9 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/access/__tests__/grant-invite-needs-account.test.tsx diff --git a/messages/de.json b/messages/de.json index 15c2e8336..bb63290f5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10327,6 +10327,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 4fa5a0ba7..2ecd85fdf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10327,6 +10327,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 964f472bf..527610e38 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10327,6 +10327,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 d1c5e8387..b8c3b73d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10327,6 +10327,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 c77cff0bc..a1ceb9da9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10327,6 +10327,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 33e6e738a..7772b8576 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10327,6 +10327,7 @@ "invite": { "title": "다른 사람 초대", "description": "상대가 수락하기 전에는 아무것도 공유되지 않아요.", + "needsAccount": "상대방에게는 이 인스턴스의 HealthLog 계정이 필요하며, 초대는 설정 → 공유 접근에서 확인할 수 있습니다.", "identifierLabel": "사용자 이름 또는 이메일", "identifierPlaceholder": "로그인에 쓰는 이름", "identifierHint": "이 인스턴스에 계정이 있어야 해요. 가입하지 않은 이메일 주소로는 초대를 보낼 수 없어요.", diff --git a/messages/pl.json b/messages/pl.json index d4ee7da18..cff8897c2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10327,6 +10327,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/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() {
From 8d81ac07089f58bbea484fe805412be5a4cdcf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:22:18 +0200 Subject: [PATCH 13/34] fix(onboarding): a connected source is acknowledged, not offered `chooseFirstResultTask` read the answers alone, so an account whose WHOOP had been feeding the dashboard for months was sent to go and connect WHOOP. It takes the delivering sources now; the server still picks from the answers, having no status in hand, and the screen re-picks once the envelope resolves. The fall-through stops where there is nowhere to fall to: a person whose one answer is an already-connected source keeps the step and is told what was found, rather than ending the flow on no result at all. --- .../__tests__/first-result-screen.test.tsx | 59 +++++++++++++++++-- .../onboarding/first-result-screen.tsx | 21 ++++++- .../__tests__/first-result-config.test.ts | 37 ++++++++++++ .../onboarding/__tests__/wizard-steps.test.ts | 40 +++++++++++++ src/lib/onboarding/first-result-config.ts | 32 +++++++++- src/lib/onboarding/wizard-steps.ts | 14 ++++- 6 files changed, 194 insertions(+), 9 deletions(-) diff --git a/src/components/onboarding/__tests__/first-result-screen.test.tsx b/src/components/onboarding/__tests__/first-result-screen.test.tsx index af21c21d9..3fead3a5b 100644 --- a/src/components/onboarding/__tests__/first-result-screen.test.tsx +++ b/src/components/onboarding/__tests__/first-result-screen.test.tsx @@ -19,6 +19,7 @@ */ 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"; import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; @@ -101,25 +102,33 @@ function statuses(overrides: StatusOverrides | null) { }; } -function state(): OnboardingStateDto { +function state( + needs: Partial = {}, +): OnboardingStateDto { return { steps: defaultOnboardingSteps(), needs: { ...emptyOnboardingNeeds(), recordTarget: "me", sources: ["whoop"], + ...needs, }, completedAt: null, firstResult: null, }; } -function render(overrides: StatusOverrides | null) { +function render( + overrides: StatusOverrides | null, + needs: Partial = {}, +) { statuses(overrides); return renderToStaticMarkup( - - - , + + + + + , ); } @@ -191,4 +200,44 @@ describe(" — the connect arm", () => { expect(html).not.toContain("data-connect-slot="); expect(html).toContain('data-slot="onboarding-task-checking"'); }); + + /** + * v1.38.19 (wave B / I1) — the heading is chosen from the answers alone on + * the server, because no status is in hand there. Once the envelope has + * resolved the screen re-picks: a source that is already delivering is not + * a task, and "Connect WHOOP" over a WHOOP that has been feeding the + * dashboard for months is the flow talking past the account it can see. + */ + it("stops offering a source that is already delivering", () => { + const html = render( + { + connected: true, + verdict: "fresh", + lastSuccessAt: "2026-09-10T06:35:00.000Z", + }, + { medication: "yes" }, + ); + expect(html).toContain('data-task="add-medication"'); + expect(html).not.toContain("Connect WHOOP"); + }); + + it("acknowledges the connection when there is nothing to fall through to", () => { + // The whole answer is one already-connected source. Dropping the step + // would end the flow on nothing at all, so the screen keeps it and says + // what it found — and that is the result it records. + const html = render({ + connected: true, + verdict: "fresh", + lastSuccessAt: "2026-09-10T06:35:00.000Z", + }); + expect(html).toContain('data-task="connect-source"'); + expect(html).toContain('data-connect-state="fresh"'); + expect(html).toContain("WHOOP is already connected"); + }); + + it("keeps the answer's heading while the envelope is in flight", () => { + const html = render(null, { medication: "yes" }); + expect(html).toContain('data-task="connect-source"'); + expect(html).toContain("Connect WHOOP"); + }); }); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index d203b0dcf..a3bd92ff8 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"; @@ -34,6 +34,7 @@ import { AREA_PAGE_HREF, AREA_READING_TARGETS, connectSourceView, + deliveringSources, SOURCE_INTEGRATION, type ConnectSourceSlot, } from "@/lib/onboarding/first-result-config"; @@ -70,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 diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts index 4ee104c1a..ca6160843 100644 --- a/src/lib/onboarding/__tests__/first-result-config.test.ts +++ b/src/lib/onboarding/__tests__/first-result-config.test.ts @@ -15,6 +15,7 @@ import { AREA_PAGE_HREF, AREA_READING_TARGETS, connectSourceView, + deliveringSources, SOURCE_INTEGRATION, } from "../first-result-config"; import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps"; @@ -219,3 +220,39 @@ describe("connect-source state copy", () => { }, ); }); + +/** + * v1.38.19 (wave B / I1) — 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__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts index bbb82e687..740852a6f 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 (wave B / I1) — 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/first-result-config.ts b/src/lib/onboarding/first-result-config.ts index d320e7c6d..0f2eb588b 100644 --- a/src/lib/onboarding/first-result-config.ts +++ b/src/lib/onboarding/first-result-config.ts @@ -10,7 +10,10 @@ 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. */ @@ -176,3 +179,30 @@ function canStartConnection( 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/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 }; From e9e4a4b44d6abe934f30ea10523e64747cb86196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:24:36 +0200 Subject: [PATCH 14/34] fix(onboarding): confirm names the modules leaving the nav The screen listed what the answers switch on and said everything else stays one click away under Settings, Modules. That is true of reachability and false of the navigation entry: a module the answers do not ask for is written off by the completion's derivation. It now names those modules, read through the registry's own merge rule and intersected with the map the navigation shows today, so nothing is claimed about a module the account payload says nothing about. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../__tests__/confirm-screen.test.tsx | 86 +++++++++++++++++++ src/components/onboarding/confirm-screen.tsx | 14 ++- .../__tests__/confirm-summary.test.ts | 59 +++++++++++++ src/lib/onboarding/confirm-summary.ts | 37 +++++++- 11 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/components/onboarding/__tests__/confirm-screen.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..a7c65acce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4661,6 +4661,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", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..158f6180d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4661,6 +4661,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", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..e631287b2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4661,6 +4661,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", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..4ea236315 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4661,6 +4661,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", diff --git a/messages/it.json b/messages/it.json index d965b3827..647b502a0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4661,6 +4661,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", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..2f6d640e2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4661,6 +4661,7 @@ "alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).", "everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:", "modulesLink": "설정, 모듈", + "switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.", "glucoseUnit": "혈당", "unitPreference": "단위", "language": "언어", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..972235a6a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4661,6 +4661,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", diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx new file mode 100644 index 000000000..bef1dc2ae --- /dev/null +++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx @@ -0,0 +1,86 @@ +/** + * v1.39 (Wave C) — what the confirm screen is allowed to claim. + * + * C4 (research I6): the screen listed what the answers switch ON and said + * everything else "stays one click away under Settings, Modules" — true of + * reachability, false of the navigation entry. A module the answers do not + * ask for leaves the navigation, and nothing said so. + * + * Rendered server-side, this repo's component-test convention. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +let currentUser: AuthUser | null = null; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: currentUser }), + useAccountOnceMounted: () => currentUser, +})); + +const { ConfirmScreen } = await import("../confirm-screen"); + +function state( + overrides: Partial = {}, + needs: Partial = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs }, + completedAt: null, + firstResult: null, + ...overrides, + }; +} + +function render( + flow: OnboardingStateDto, + modules: Partial> = {}, +): string { + currentUser = { + id: "u1", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + unitPreference: "metric", + modules, + } as unknown as AuthUser; + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" — modules leaving the navigation (C4)", () => { + it("names them when the answers do not ask for what the record shows", () => { + const html = render(state({}, { areas: ["sleep"], medication: "no" }), { + medications: true, + }); + expect(html).toContain('data-slot="onboarding-confirm-modules-off"'); + expect(html).toContain("Medications"); + }); + + it("says nothing when the answers take nothing away", () => { + const html = render(state({}, { areas: ["sleep"], medication: "yes" }), { + medications: true, + }); + expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"'); + }); +}); diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 8f55be3a5..afee75e5d 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -112,7 +112,12 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const needs = state.needs; const forSomeoneElse = needs.recordTarget === "someone-else"; - const { chosen, alwaysOn } = confirmedModules(needs); + // v1.39 (Wave C, C4) — `user.modules` is the map the navigation reads, so + // the screen can only name a module the person actually has today. + const { chosen, alwaysOn, wouldSwitchOff } = confirmedModules( + needs, + user?.modules ?? {}, + ); const back = previousScreen(state, "confirm"); const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null; @@ -182,6 +187,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { }) : t("onboarding.flow.confirm.nothingExtra")}

+ {wouldSwitchOff.length > 0 ? ( +

+ {t("onboarding.flow.confirm.switchesOff", { + modules: moduleNames(wouldSwitchOff), + })} +

+ ) : null}

{t("onboarding.flow.confirm.alwaysOn", { modules: moduleNames(alwaysOn), diff --git a/src/lib/onboarding/__tests__/confirm-summary.test.ts b/src/lib/onboarding/__tests__/confirm-summary.test.ts index 3ced0603a..88acc03dd 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 (I6)", () => { + 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/confirm-summary.ts b/src/lib/onboarding/confirm-summary.ts index 1dfb28c9b..260b2faef 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,24 @@ export interface ConfirmedModules { chosen: ModuleKey[]; /** On whatever the answers say. */ alwaysOn: ModuleKey[]; + /** + * v1.39 (Wave C, C4) — what the derivation takes OUT of the navigation: + * modules this record shows today that the answers do not ask for + * (research I6). 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 +61,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, + ), }; } From a6dedffdb8eb3dae904b494652c2f372f9b1258e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:25:28 +0200 Subject: [PATCH 15/34] fix(onboarding): no dead settings links before completion Both Settings links on the confirm screen are page routes, and the onboarding cookie that sends every page route back into the flow is cleared by the completion this very screen calls. On a first run they could not go anywhere. They now render as plain text until the flow has been completed once. The words stay, because where a setting lives is true either way. --- .../__tests__/confirm-screen.test.tsx | 23 +++++++++ src/components/onboarding/confirm-screen.tsx | 48 ++++++++++++++----- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx index bef1dc2ae..274a05b74 100644 --- a/src/components/onboarding/__tests__/confirm-screen.test.tsx +++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx @@ -1,6 +1,12 @@ /** * v1.39 (Wave C) — what the confirm screen is allowed to claim. * + * C5 (research M-l): both of the screen's own Settings links are page routes, + * and `hl_onboarding=pending` is still set while this screen is on — the + * completion is what clears it, and this screen is what calls the completion. + * The proxy sends both straight back to the flow, so on a first run they are + * dead links under a sentence that is not yet true. + * * C4 (research I6): the screen listed what the answers switch ON and said * everything else "stays one click away under Settings, Modules" — true of * reachability, false of the navigation entry. A module the answers do not @@ -84,3 +90,20 @@ describe(" — modules leaving the navigation (C4)", () => { expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"'); }); }); + +describe(" — Settings links on a first run (C5)", () => { + it("renders both as plain text while the completion has not run", () => { + const html = render(state()); + expect(html).not.toContain('href="/settings/modules"'); + expect(html).not.toContain('href="/settings/account"'); + // The words stay: they say where the thing lives, which is true. + expect(html).toContain("Settings, Modules"); + expect(html).toContain("Settings, Account"); + }); + + it("renders them as links once the flow has been completed once", () => { + const html = render(state({ completedAt: "2026-09-10T08:00:00.000Z" }), {}); + expect(html).toContain('href="/settings/modules"'); + expect(html).toContain('href="/settings/account"'); + }); +}); diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index afee75e5d..dae55b0c8 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -27,6 +27,7 @@ import { /** A step the confirm screen may pass on the ledger without an answer. */ type PassableStepId = (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number]; +import { isOnboardingSettled } from "@/lib/onboarding/needs"; import { firstResultApplies, nextScreen, @@ -111,6 +112,17 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const ledger = useRef(state); const needs = state.needs; + /** + * v1.39 (Wave C, C5) — is `hl_onboarding=pending` still set? (research M-l) + * + * Both of this screen's Settings links are page routes, and the cookie is + * cleared by the completion — which is what THIS screen calls. So on a + * first run the proxy 307s both straight back into the flow: two links + * that cannot go anywhere, under a sentence saying where things live. The + * words are true, the links are not, so only the links go. + */ + const settingsReachable = + isOnboardingSettled(state) || state.completedAt !== null; const forSomeoneElse = needs.recordTarget === "someone-else"; // v1.39 (Wave C, C4) — `user.modules` is the map the navigation reads, so // the screen can only name a module the person actually has today. @@ -201,12 +213,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {

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

@@ -241,12 +259,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 ? ( From 982c35287db0f5190e33211846b042c7a0cba657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:26:10 +0200 Subject: [PATCH 16/34] fix(demo): the setup flow's baseline writes are allowlisted A demo visitor who typed a height on the confirm screen got a 403 and a generic toast, and the "someone I look after" arm could only take "Finish without the profile": the profile PUT, the self-context PUT and the managed-profile POST were missing from the demo allowlist, so only the empty-form path completed. All three are added, pinned per method as the rest of the list is. None of them creates health data. --- .../proxy-demo-mode-allowlist.test.ts | 30 +++++++++++++++++++ src/proxy.ts | 13 ++++++++ 2 files changed, 43 insertions(+) diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index d51c61082..0f7a6af0d 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -106,6 +106,36 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(missing, "onboarding writes the demo cannot make").toEqual([]); }); + it("permits the baseline writes the confirm screen makes (M-m)", () => { + // v1.39 (Wave C, C6) — a demo visitor who typed a height got a 403 and a + // generic toast, and "someone I look after" could only take "Finish + // without the profile": the three writes the confirm screen makes were + // not on the list. None of them is health data — the account's own + // profile fields, the encrypted self-context the anamnesis card writes, + // and the managed record the flow's guardian arm creates. + for (const [path, method] of [ + ["/api/auth/profile", "PUT"], + ["/api/coach/about-me", "PUT"], + ["/api/managed-profiles", "POST"], + ] as const) { + expect( + proxy(makeRequest(path, method)).status, + `${method} ${path} is blocked in the demo`, + ).not.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/proxy.ts b/src/proxy.ts index 5184878e7..b73d8c3ed 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -174,6 +174,19 @@ 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 (Wave C, C6) — the three writes the confirm screen makes. Without + // them a demo visitor who typed a height got a 403 and a generic toast, + // and the "someone I look after" arm could only take "Finish without the + // profile" — only the empty-form/Skip path completed, so the demo did not + // in fact walk the same flow (design spec §Principles 5). None of the + // three creates health data: the caller's own profile fields, the + // encrypted self-context behind the optional anamnesis card, and the + // managed record the guardian arm of the flow exists to create. The + // per-profile routes (`/api/managed-profiles/{id}`, its guardians) are + // different paths and stay closed. + { path: "/api/auth/profile", method: "PUT" }, + { path: "/api/coach/about-me", method: "PUT" }, + { path: "/api/managed-profiles", method: "POST" }, ]; // Legacy route redirects (German → English) From dfa5b9a5e08f79055311de7dae79a0d7083da992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:31:59 +0200 Subject: [PATCH 17/34] test(e2e): the connect journey asserts after the status resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No journey reached the connect arm: bp answers `manual`, visit answers `file`, and the child path ends at confirm. The a11y sweep opened the screen and asserted the connect card was visible — which it was, for the few hundred milliseconds before the status envelope resolved, so the sweep passed through the defect rather than catching it. The new journey answers Q4 with a wearable and asserts only after `GET /api/integrations/status` has settled: nothing is claimed for an account with no connection and no result is stamped on its behalf, and a connection seeded as delivering is acknowledged rather than offered. The a11y sweep now waits for the tile the screen settled on instead of the frame before it. --- e2e/setup-flow-a11y.spec.ts | 9 +- e2e/setup-flow-connect.spec.ts | 177 +++++++++++++++++++++++++++++++++ e2e/setup/global-setup.ts | 21 ++++ playwright.config.ts | 1 + 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 e2e/setup-flow-connect.spec.ts diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts index 3891eb7c4..688fe7665 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 (wave B) — 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..9781f3514 --- /dev/null +++ b/e2e/setup-flow-connect.spec.ts @@ -0,0 +1,177 @@ +/** + * v1.38.19 (wave B / C1, C2) — 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. */ +async function reachFirstResult(page: Page): Promise { + await answer(page, { step: "who", recordTarget: "me" }); + await answer(page, { step: "areas", areas: ["sleep"] }); + 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; +} + +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); + + // 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("acknowledges a connection that is already delivering", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedDeliveringOura(); + await page.goto("/onboarding"); + 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 heading is the acknowledgment, not an offer: the connect CTA that + // used to sit under "Oura verbinden" 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/global-setup.ts b/e2e/setup/global-setup.ts index 13bfccc33..7dc203437 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 (wave B) — 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", @@ -507,6 +522,10 @@ 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", @@ -519,6 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_CONNECT, ] as const; async function hashPassword(password: string): Promise { @@ -1261,6 +1281,7 @@ 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_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/playwright.config.ts b/playwright.config.ts index a6231feed..c958c8d77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -232,6 +232,7 @@ 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", ], use: { From bda18f9e2d863e0a9e764ff795c76db593ba5222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:35:17 +0200 Subject: [PATCH 18/34] test(profile): the edge proxy is not a profile submitter The demo allowlist names a path and a method as data, which reads to the submitter matcher exactly like a request expression. proxy.ts submits nothing and has no answer to read, so it is excluded by name. --- .../profile-rejection-surface-guard.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/__tests__/profile-rejection-surface-guard.test.ts b/src/__tests__/profile-rejection-surface-guard.test.ts index 7bed465fd..e18fec0d0 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 (Wave C, C6) — 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 { From e5cfdc9fdff365032ca36275e35b07fbe0cf25d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:36:07 +0200 Subject: [PATCH 19/34] test(e2e): the AI offer follows the shared provider's health Two runs on one account, and the only difference between them is a row in provider_health: a success inside the freshness window brings the offer and the tap leaves an active ai_full receipt; a dead credential inside its cooldown brings 'not answering' and no button at all. The seed is instance-wide because the projection is - a fresh account's own ledger is empty by construction - so the spec runs serially and puts the operator key and the ledger rows back afterwards. The offer path asserts that no provider was called: the flow reads a projection and never probes. Verified as a real check, not a tautology: moving the seeded success from 30 minutes to 25 hours ago makes the offer case fail. --- e2e/onboarding-ai-offer.spec.ts | 170 ++++++++++++++++++++++++++++++++ e2e/setup/ai-offer-fixture.ts | 132 +++++++++++++++++++++++++ e2e/setup/global-setup.ts | 21 ++++ 3 files changed, 323 insertions(+) create mode 100644 e2e/onboarding-ai-offer.spec.ts create mode 100644 e2e/setup/ai-offer-fixture.ts diff --git a/e2e/onboarding-ai-offer.spec.ts b/e2e/onboarding-ai-offer.spec.ts new file mode 100644 index 000000000..0e7bb948c --- /dev/null +++ b/e2e/onboarding-ai-offer.spec.ts @@ -0,0 +1,170 @@ +/** + * v1.38.19 (wave D) — 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. + * + * Two 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 + * + * 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, + 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"]'; + +/** 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(OFFER)).toBeVisible({ timeout: 15_000 }); + 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(UNAVAILABLE)).toBeVisible({ timeout: 15_000 }); + 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); + }); +}); diff --git a/e2e/setup/ai-offer-fixture.ts b/e2e/setup/ai-offer-fixture.ts new file mode 100644 index 000000000..a78bf00d9 --- /dev/null +++ b/e2e/setup/ai-offer-fixture.ts @@ -0,0 +1,132 @@ +/** + * v1.38.19 (wave D) — 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(); + } +} + +/** 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..c3c47b567 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -487,6 +487,21 @@ export const E2E_SETUP_LOCALE = { role: "USER", } as const; +/** + * v1.38.19 (wave D) — 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", @@ -511,6 +526,10 @@ 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 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_AI_OFFER, ] as const; async function hashPassword(password: string): Promise { @@ -1261,6 +1281,7 @@ 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_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`, From 0290554d3688cf88c011b2d89c51a0b78d754ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:40:26 +0200 Subject: [PATCH 20/34] test(e2e): an invite link registers from a cookie-less window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every layer of the invite chain had unit coverage and the chain was broken anyway: the proxy test proved the edge admits the landing, the page test proved it redirects onto the registration form, and neither could see that the redirect arrives in an RSC flight payload after the shell has already flushed. The operator's path — mint an invitation, open the URL in a private window, reach a usable form — was exercised by no spec at any level. The new spec mints a real invitation in the admin UI and opens it twice: once in a genuinely cookie-less context, asserting both where the visitor lands and that they never passed through the sign-in page, and once in the admin's own window, asserting the explaining panel and that the admin's session survives. A third case pins the server half through the wire. Against `origin/main` all three fail — the anonymous visitor lands on `/auth/login`, the panel does not exist, and the registration POST answers 201 over a live session. The minted URL gains a test id so the spec reads it from its own element rather than from the first paragraph in the ticket. --- e2e/invite-registration.spec.ts | 118 ++++++++++++++++++ .../admin/invite-tokens-section.tsx | 5 +- 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 e2e/invite-registration.spec.ts diff --git a/e2e/invite-registration.spec.ts b/e2e/invite-registration.spec.ts new file mode 100644 index 000000000..b37b19dea --- /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("already_authenticated"); + }); +}); 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}