From 21f6f5796ba92a413721f5764da0bd364ad4f3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:06:52 +0100 Subject: [PATCH 1/8] fix(timeline): cap lab markers instead of refusing the request A values list naming more than 24 lab markers answered 422. The list is a device preference, so once a 25th marker was ticked every later visit failed: the page rendered its error card, and the values menu that could untick it only renders on success. The server now keeps the first 24 markers and leaves the rest out, as it already does for a marker the record no longer has; a malformed key still answers 422. The page applies the same cap before it asks, and the values menu holds further unchosen markers back at 24 with a line saying why, while a chosen one can always be removed. --- docs/api/openapi.yaml | 18 ++--- 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/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + .../__tests__/timeline-labs.browser.test.tsx | 66 +++++++++++++++++++ src/components/timeline/timeline-menus.tsx | 17 ++++- src/components/timeline/timeline-view.tsx | 5 +- src/lib/day/contract.ts | 17 ++++- src/lib/day/wire-schemas.ts | 2 +- src/lib/timeline/__tests__/lab-series.test.ts | 15 ++++- src/lib/timeline/load-timeline.ts | 15 ++--- tests/integration/timeline-lab-series.test.ts | 25 ++++++- 18 files changed, 164 insertions(+), 25 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e02690ce0..29cc4ea93 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26190,17 +26190,19 @@ paths: - in: query name: values schema: - description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the - names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike - names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is - left out of `labSeries` without an error, so a stored choice never breaks the page. + names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab + markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones + left out without an error. A lab marker that is not in the record (deleted, or another record's) is + likewise left out of `labSeries`, so a stored choice never breaks the page." type: string - description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the - names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike - names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left - out of `labSeries` without an error, so a stored choice never breaks the page. + names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers + are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out + without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out + of `labSeries`, so a stored choice never breaks the page." responses: "200": description: The timeline for the requested zoom and window. diff --git a/messages/de.json b/messages/de.json index 08080a48e..aaffa5bcc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Laborwerte", "otherPanel": "Weitere", + "full": "Bis zu {max} Laborwerte gleichzeitig. Entferne einen, um einen anderen hinzuzufügen.", "above": "über dem Referenzbereich", "below": "unter dem Referenzbereich", "reference": "Referenz {range}", diff --git a/messages/en.json b/messages/en.json index 300570a96..e64c16a4b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Lab values", "otherPanel": "Other", + "full": "Up to {max} lab values at once. Remove one to add another.", "above": "above range", "below": "below range", "reference": "reference {range}", diff --git a/messages/es.json b/messages/es.json index 69768f6be..498dc6b0b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Valores de laboratorio", "otherPanel": "Otros", + "full": "Hasta {max} valores de laboratorio a la vez. Quita uno para añadir otro.", "above": "por encima del intervalo", "below": "por debajo del intervalo", "reference": "referencia {range}", diff --git a/messages/fr.json b/messages/fr.json index e383d3264..27a2fb692 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Valeurs de laboratoire", "otherPanel": "Autres", + "full": "Jusqu’à {max} valeurs de laboratoire à la fois. Retirez-en une pour en ajouter une autre.", "above": "au-dessus de l’intervalle", "below": "en dessous de l’intervalle", "reference": "référence {range}", diff --git a/messages/it.json b/messages/it.json index 91d8ce687..e292975f0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Valori di laboratorio", "otherPanel": "Altri", + "full": "Fino a {max} valori di laboratorio alla volta. Rimuovine uno per aggiungerne un altro.", "above": "sopra l’intervallo", "below": "sotto l’intervallo", "reference": "riferimento {range}", diff --git a/messages/ko.json b/messages/ko.json index ea29acfee..2772754cb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "검사 수치", "otherPanel": "기타", + "full": "검사 수치는 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 수치를 추가하려면 하나를 제거하세요.", "above": "범위 초과", "below": "범위 미만", "reference": "참고 범위 {range}", diff --git a/messages/nl.json b/messages/nl.json index 00b4e2b9e..137b3f252 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Labwaarden", "otherPanel": "Overig", + "full": "Maximaal {max} labwaarden tegelijk. Verwijder er een om een andere toe te voegen.", "above": "boven het bereik", "below": "onder het bereik", "reference": "referentie {range}", diff --git a/messages/pl.json b/messages/pl.json index 3246df662..bec5c7352 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Wyniki badań", "otherPanel": "Inne", + "full": "Naraz można pokazać do {max} wyników badań. Usuń jeden, aby dodać inny.", "above": "powyżej zakresu", "below": "poniżej zakresu", "reference": "zakres {range}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 27f894ae0..3747bf718 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -13733,6 +13733,7 @@ "labs": { "menuTitle": "Valores de exames", "otherPanel": "Outros", + "full": "Até {max} valores de exames ao mesmo tempo. Remova um para adicionar outro.", "above": "acima da faixa", "below": "abaixo da faixa", "reference": "referência {range}", diff --git a/src/components/timeline/__tests__/timeline-labs.browser.test.tsx b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx index 1be1f5a7f..1860f3470 100644 --- a/src/components/timeline/__tests__/timeline-labs.browser.test.tsx +++ b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx @@ -295,3 +295,69 @@ describe("lab lines on the timeline page", () => { expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth); }); }); + +describe("the lab-marker cap on the timeline page", () => { + const id = (i: number) => `m${String(i).padStart(2, "0")}`; + const marker = (i: number) => ({ + key: `lab:${id(i)}`, + biomarkerId: id(i), + name: `Marker ${String(i).padStart(2, "0")}`, + panel: null, + unit: "U/L", + }); + + /** A record with 26 markers, a stored choice naming 30 (four deleted). */ + async function mountMany() { + const stored = Array.from({ length: 30 }, (_, i) => `lab:${id(i)}`); + window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored)); + setSearch("?zoom=all"); + const net = stubFetch() + .on("GET", "/api/timeline", () => ({ + data: { + ...timeline([]), + availableLabs: Array.from({ length: 26 }, (_, i) => marker(i)), + }, + })) + .on("GET", "/api/timeline/readiness", { + data: { verdict: "carries", since: "2024-01-01", lanes: [] }, + }) + .on("GET", "/api/life-events", { data: { events: [] } }); + const { screen } = await renderWithApp(, { + user: { + timezone: "Europe/Berlin", + modules: { timeline: true, labs: true }, + }, + }); + return { net, screen }; + } + + it("asks for no more markers than the server takes, and holds further ones back until one is removed", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mountMany(); + const first24 = Array.from({ length: 24 }, (_, i) => `lab:${id(i)}`); + await expect.poll(() => lastTimelineValues(net)).toEqual(first24); + + await screen.getByRole("button", { name: /Values \(24\)/ }).click(); + const extra = screen.getByRole("menuitemcheckbox", { name: "Marker 24" }); + await expect.element(extra).toHaveAttribute("aria-checked", "false"); + await expect.element(extra).toHaveAttribute("aria-disabled", "true"); + await expect + .element( + screen.getByText("Up to 24 lab values at once", { exact: false }), + ) + .toBeVisible(); + // A chosen marker can always be removed. + const chosen = screen.getByRole("menuitemcheckbox", { name: "Marker 00" }); + expect(chosen.element().getAttribute("aria-disabled")).not.toBe("true"); + + await chosen.click(); + await expect.element(chosen).toHaveAttribute("aria-checked", "false"); + expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual( + first24.slice(1), + ); + await expect.element(extra).not.toHaveAttribute("aria-disabled", "true"); + expect( + document.querySelector('[data-slot="timeline-values-labs-full"]'), + ).toBeNull(); + }); +}); diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index a20abefac..62faa0dfe 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -9,7 +9,9 @@ * not listed again beside it: each one is a named row under the lanes, and * the menu marks it with its colour. Below the measurements it offers the * lab markers the record has values for (#1299), under the groups the labs - * list sorts them into; a lab line is chosen and stored like any other. + * list sorts them into; a lab line is chosen and stored like any other, up + * to `TIMELINE_LAB_SERIES_MAX` at once: past it the unchosen markers are + * held back with a line that says why, and a chosen one can always go. */ import { ChartLine, ChevronDown, Layers } from "lucide-react"; @@ -25,6 +27,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { + TIMELINE_LAB_SERIES_MAX, timelineLabMarkerId, type TimelineLaneKey, type TimelineResponse, @@ -61,12 +64,16 @@ export function ValueSeriesMenu({ iconOnly?: boolean; }) { const { t } = useTranslations(); + const labsFull = + selected.filter((key) => timelineLabMarkerId(key) !== null).length >= + TIMELINE_LAB_SERIES_MAX; const option = (key: string) => { const checked = selected.includes(key); return ( event.preventDefault()} @@ -121,6 +128,14 @@ export function ValueSeriesMenu({ {t("timeline.labs.menuTitle")} + {labsFull && ( +

+ {t("timeline.labs.full", { max: TIMELINE_LAB_SERIES_MAX })} +

+ )} {labGroups.map((group) => ( storedValues.filter(allowed), + () => capTimelineLabKeys(storedValues.filter(allowed)), [storedValues, allowed], ); // One colour per chosen line, shared by the chart, the selection bar, the diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index 97651415e..e87001167 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -437,7 +437,7 @@ export type TimelineLabMarker = TimelineResponse["availableLabs"][number]; export const TIMELINE_LAB_KEY_PREFIX = "lab:"; /** - * The most lab markers one request names. Measurement names are a closed + * The most lab markers one request draws. Measurement names are a closed * set and bound themselves; marker ids are not. */ export const TIMELINE_LAB_SERIES_MAX = 24; @@ -449,6 +449,21 @@ export function timelineLabMarkerId(key: string): string | null { return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null; } +/** + * `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` left + * out, everything else in its place. The server applies it to what it is + * asked for and the page to what it asks, so a stored list longer than the + * cap (another device, an older build) loads its first markers instead of + * failing the page. + */ +export function capTimelineLabKeys(keys: readonly string[]): string[] { + let labs = 0; + return keys.filter( + (key) => + timelineLabMarkerId(key) === null || ++labs <= TIMELINE_LAB_SERIES_MAX, + ); +} + /** The `values` entry for a lab marker. */ export function timelineLabKey(biomarkerId: string): string { return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`; diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 4a5c892c3..eba8ef32c 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -370,7 +370,7 @@ export const timelineQuerySchema = z .string() .optional() .describe( - "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left out of `labSeries` without an error, so a stored choice never breaks the page.", + "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.", ), }) .refine((q) => q.zoom !== "range" || (q.from && q.to), { diff --git a/src/lib/timeline/__tests__/lab-series.test.ts b/src/lib/timeline/__tests__/lab-series.test.ts index 1a29fdc5f..311fcda46 100644 --- a/src/lib/timeline/__tests__/lab-series.test.ts +++ b/src/lib/timeline/__tests__/lab-series.test.ts @@ -272,12 +272,23 @@ describe("a lab marker in `values`", () => { expect(parseSeriesKeys("lab:../etc")).toBeNull(); }); - it("is capped, because marker ids are not a closed set", () => { + it("keeps the first markers up to the cap and leaves the rest out, so a long stored list still loads", () => { const many = (n: number) => Array.from({ length: n }, (_, i) => `lab:m${i}`).join(","); expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX))).toHaveLength( TIMELINE_LAB_SERIES_MAX, ); - expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX + 1))).toBeNull(); + const capped = parseSeriesKeys( + `WEIGHT,${many(TIMELINE_LAB_SERIES_MAX + 6)},MOOD`, + ); + expect(capped).toEqual([ + "WEIGHT", + ...Array.from({ length: TIMELINE_LAB_SERIES_MAX }, (_, i) => `lab:m${i}`), + "MOOD", + ]); + // A malformed key past the cap still refuses: the cap is not a filter. + expect( + parseSeriesKeys(`${many(TIMELINE_LAB_SERIES_MAX)},lab:../x`), + ).toBeNull(); }); }); diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index 4e07ee67b..8de5bc5c1 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -17,8 +17,8 @@ import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums"; import type { MeasurementType } from "@/generated/prisma/enums"; import { + capTimelineLabKeys, DAY_NOTABLE_MAX_SPAN_DAYS, - TIMELINE_LAB_SERIES_MAX, TIMELINE_LANE_KEYS, timelineLabMarkerId, type TimelineBucket, @@ -118,7 +118,10 @@ const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( * lanes. A lab marker (`lab:`, #1299) is checked for its shape * only: whether the record has it is the loader's question, and the answer * to a stale one is no line rather than an error. Marker ids are not a - * closed set, so their count is capped. + * closed set, so their count is capped: the first + * `TIMELINE_LAB_SERIES_MAX` are kept and the rest left out. A refusal would + * be stored with the choice and fail the page on every visit, with the menu + * that could undo it never drawn. */ export function parseSeriesKeys(raw: string | undefined): string[] | null { if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES]; @@ -130,15 +133,11 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null { .filter((k) => k.length > 0), ), ]; - let labs = 0; for (const key of keys) { - if (timelineLabMarkerId(key) !== null) { - labs += 1; - continue; - } + if (timelineLabMarkerId(key) !== null) continue; if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null; } - return labs > TIMELINE_LAB_SERIES_MAX ? null : keys; + return capTimelineLabKeys(keys); } /** diff --git a/tests/integration/timeline-lab-series.test.ts b/tests/integration/timeline-lab-series.test.ts index 9b6f54278..5fc0767c6 100644 --- a/tests/integration/timeline-lab-series.test.ts +++ b/tests/integration/timeline-lab-series.test.ts @@ -299,12 +299,31 @@ describe("lab lines on the timeline", () => { expect(timeline.labSeries).toEqual([]); expect(timeline.availableLabs).toEqual([]); - // A list of ids is capped, and a malformed one is refused. - const many = Array.from({ length: 25 }, (_, i) => `lab:m${i}`).join(","); - expect((await getTimeline(`values=${many}`)).status).toBe(422); + // A malformed id is refused. expect((await getTimeline(`values=lab:..%2Fx`)).status).toBe(422); }); + it("draws the first 24 markers of a longer list and leaves the rest out, rather than refusing", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + await signIn(owner.id); + + const filler = Array.from({ length: 24 }, (_, i) => `lab:m${i}`); + // Past the cap: the record's own marker is the 25th, so it is not drawn. + const past = await getTimeline( + `zoom=all&values=WEIGHT,${[...filler, `lab:${marker.id}`].join(",")}`, + ); + expect(past.status).toBe(200); + expect((await data(past)).labSeries).toEqual([]); + // Within the cap it is. + const within = await data( + await getTimeline( + `zoom=all&values=${[...filler.slice(1), `lab:${marker.id}`].join(",")}`, + ), + ); + expect(within.labSeries.map((s) => s.key)).toEqual([`lab:${marker.id}`]); + }); + it("refuses a grant scoped to some sections, and shares the lines on a whole-record grant", async () => { const owner = await makeUser("owner"); const { marker } = await seedFerritin(owner.id); From 648810745a94ff2d9027b4db4d661c910146e8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:14:25 +0100 Subject: [PATCH 2/8] fix(backup): let an account fresh from setup take an erased account's copy The empty-account check counted every backed-up model, and signing up and answering the setup questions always leaves a setup row, plus a cycle profile when the cycle area is chosen. The account of someone who has signed up again, the case the restore target is for, was therefore always refused. Those two models are one row per account and are deleted and re-created from the copy by every restore, so they no longer count. A reading or medication entered on setup's last screen still refuses the account, because the restore would delete it. A guard holds the exempt models to that shape, and the erased-account integration file now drives the real setup routes before the restore. --- docs/ops/backup-restore.md | 7 +- .../__tests__/restore-target-models.test.ts | 37 ++++- src/lib/export/restore-target.ts | 36 ++++- .../backup-erased-account-restore.test.ts | 128 ++++++++++++++++++ 4 files changed, 203 insertions(+), 5 deletions(-) diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2ab5215f3..6f3e1baaa 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -108,7 +108,12 @@ exists, and the console then asks where the records should go: account and type its username to confirm. The account must hold no records yet: a restore replaces an account's records and does not merge them, so an account with even one record of any kind a copy carries is refused, with the - kinds named. Managed profiles and accounts pending deletion are not offered. + kinds named. What signing up and the setup questions leave behind does not + count (the setup answers and the cycle settings): the restore replaces both + with the copy's. A reading or medication entered on setup's last screen is a + record, and the account is refused until it is deleted, because the restore + would delete it too. Managed profiles and accounts pending deletion are not + offered. The file is then uploaded a second time, and its owner is changed to the chosen account as it is read. Nothing else in the file changes. The stored copy is diff --git a/src/lib/export/__tests__/restore-target-models.test.ts b/src/lib/export/__tests__/restore-target-models.test.ts index f6bfffaaf..c7e1a25f2 100644 --- a/src/lib/export/__tests__/restore-target-models.test.ts +++ b/src/lib/export/__tests__/restore-target-models.test.ts @@ -7,17 +7,26 @@ * * Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first * case names it (no `userId`); add an owned model to it and the second does. + * Add a model with many rows per account (`Measurement`) to the setup list + * and the third does. */ import { describe, expect, it } from "vitest"; -import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape"; +import { readFileSync } from "node:fs"; + +import { + parseWipeSchema, + WIPE_SCHEMA_PATH, +} from "@/__tests__/helpers/wipe-schema-shape"; import { BACKED_UP_MODELS } from "@/lib/export/backup-plan"; import { BACKED_UP_CHILD_MODELS, RESTORE_TARGET_OWNED_MODELS, + RESTORE_TARGET_SETUP_MODELS, } from "@/lib/export/restore-target"; const models = parseWipeSchema(); +const SCHEMA = readFileSync(WIPE_SCHEMA_PATH, "utf8"); describe("restore target models", () => { it("reads every owned backed-up model by its userId", () => { @@ -42,4 +51,30 @@ describe("restore target models", () => { expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent); } }); + + it("leaves out only one-row-per-account setup state that no other backed-up model hangs off", () => { + const setup = Object.keys(RESTORE_TARGET_SETUP_MODELS); + expect(setup.sort()).toEqual(["CycleProfile", "OnboardingRecord"]); + for (const model of setup) { + expect(RESTORE_TARGET_OWNED_MODELS, model).toContain(model); + // One row per account: a list of things the person recorded is not + // setup state. + const body = SCHEMA.match( + new RegExp(`^model ${model} \\{([\\s\\S]*?)^\\}`, "m"), + )?.[1]; + expect( + /^\s*userId\s+String\s+@unique/m.test(body ?? "") || + /@@unique\(\[userId\]\)/.test(body ?? ""), + model, + ).toBe(true); + // Nothing backed up hangs off it, so leaving it out of the check + // leaves no record unchecked behind it. + for (const other of BACKED_UP_MODELS) { + expect( + models.get(other)?.relations.some((r) => r.target === model), + `${other} -> ${model}`, + ).not.toBe(true); + } + } + }); }); diff --git a/src/lib/export/restore-target.ts b/src/lib/export/restore-target.ts index 3b5d4a33b..4f14be052 100644 --- a/src/lib/export/restore-target.ts +++ b/src/lib/export/restore-target.ts @@ -8,7 +8,11 @@ * that account holds none of those rows, and the check is exactly that: a row * of any model the backup carries ({@link BACKED_UP_MODELS}) under the * account refuses it. Everything a restore leaves alone (credentials, - * sessions, devices, grants) does not count. + * sessions, devices, grants) does not count, and neither does the setup state + * signing up leaves behind ({@link RESTORE_TARGET_SETUP_MODELS}): the person + * did not enter it as a record, and the restore replaces it with the copy's. + * A reading or a medication entered during setup is a record like any other + * and still refuses, because the restore would delete it. * * The other two refusals keep the account rules intact. A managed profile is * its Guardians' record, which an admin does not write into (the users list @@ -47,6 +51,29 @@ export const BACKED_UP_CHILD_MODELS: Readonly> = { export const RESTORE_TARGET_OWNED_MODELS: readonly string[] = BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS)); +/** + * The backed-up models an account holds a row of after signing up and going + * through setup, before anything has been recorded, each with the writer. + * One row per account, settings-shaped, and deleted and re-created from the + * copy by every restore, so the copy's state replaces it and nothing the + * person entered is lost. Without this list the account of someone who has + * signed up again was always refused, which is the case the target exists + * for. `restore-target-models.test.ts` holds each to that shape. + */ +export const RESTORE_TARGET_SETUP_MODELS: Readonly> = { + // `PATCH /api/onboarding/answers` and `POST /api/onboarding/complete`. + OnboardingRecord: "the setup answers", + // Created by the setup confirm when the cycle area is chosen, and by the + // first read of the cycle settings. + CycleProfile: "the cycle settings", +}; + +/** The models whose rows make an account a refused restore target. */ +const RESTORE_TARGET_CHECKED_MODELS: readonly string[] = + RESTORE_TARGET_OWNED_MODELS.filter( + (model) => !(model in RESTORE_TARGET_SETUP_MODELS), + ); + interface OwnedDelegate { findFirst(args: { where: { userId: string }; @@ -65,13 +92,16 @@ function ownedDelegate(db: object, model: string): OwnedDelegate { return delegate; } -/** The backed-up models under which `userId` holds at least one row. */ +/** + * The backed-up models under which `userId` holds at least one row, setup + * state left out. + */ export async function modelsHoldingRecords( db: Prisma.TransactionClient, userId: string, ): Promise { const held: string[] = []; - for (const model of RESTORE_TARGET_OWNED_MODELS) { + for (const model of RESTORE_TARGET_CHECKED_MODELS) { const row = await ownedDelegate(db, model).findFirst({ where: { userId }, select: { userId: true }, diff --git a/tests/integration/backup-erased-account-restore.test.ts b/tests/integration/backup-erased-account-restore.test.ts index 39f6afc56..ef0530a20 100644 --- a/tests/integration/backup-erased-account-restore.test.ts +++ b/tests/integration/backup-erased-account-restore.test.ts @@ -22,6 +22,9 @@ * empty account (typed confirmation) and into a new account it creates, * every section comes back, the audit trail says what happened, and * nothing names the erased account, the refused first upload included. + * An account that has just been through the setup routes counts as + * empty, and the copy's setup state replaces its own; one whose setup + * ended on a reading of its own does not. * 6. A target that holds records, is a managed profile, is pending deletion * or is not confirmed is refused before the file is read; so is a target * for a file whose own account is still here, and a new account made for @@ -290,6 +293,52 @@ async function checkTarget(userId: string) { }; } +function jsonRequest(path: string, method: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * `userId` signs up again and goes through setup the way the flow does it: + * every question answered, the units written, cycle tracking asked for, and + * the confirm screen's profile fields. The session is left on `userId`. + */ +async function goThroughOnboarding(prisma: PrismaClient, userId: string) { + cookieJar.clear(); + await signIn(prisma, userId); + const { PATCH } = await import("@/app/api/onboarding/answers/route"); + const answer = async (body: unknown) => { + const res = await (PATCH as (r: Request) => Promise)( + jsonRequest("/api/onboarding/answers", "PATCH", body), + ); + expect(res.status, await res.clone().text()).toBe(200); + }; + await answer({ step: "who", recordTarget: "me" }); + await answer({ step: "areas", areas: ["blood-pressure", "cycle", "labs"] }); + await answer({ step: "medication", medication: "yes" }); + await answer({ step: "sources", status: "skipped" }); + await answer({ step: "visit", visit: "no" }); + await answer({ + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }); + const { POST } = await import("@/app/api/onboarding/complete/route"); + const done = await (POST as (r: Request) => Promise)( + jsonRequest("/api/onboarding/complete", "POST", { + displayName: "Returning", + heightCm: 172, + dateOfBirth: "1980-05-04", + gender: "FEMALE", + }), + ); + expect(done.status, await done.clone().text()).toBe(200); + await answer({ step: "first-result", status: "skipped" }); + await answer({ step: "done" }); +} + /** The stored copy as the restore will read it, as text. */ async function storedCopyText(prisma: PrismaClient, backupId: string) { const row = await prisma.dataBackup.findUniqueOrThrow({ @@ -453,6 +502,85 @@ describe("restoring the backup of an erased account into a chosen account", () = SLOW, ); + it( + "into an account that has just been through setup: the setup state does not count, and the copy's replaces it", + async () => { + const prisma = getPrismaClient(); + const { admin, json, counts } = await eraseAfterExport(prisma); + const person = await prisma.user.create({ + data: { + username: "returning-person", + email: "returning-person@example.test", + }, + }); + await goThroughOnboarding(prisma, person.id); + // What setup left behind: its own row, and the cycle profile the cycle + // answer switched on. + expect( + await prisma.onboardingRecord.count({ where: { userId: person.id } }), + ).toBe(1); + expect( + await prisma.cycleProfile.findUniqueOrThrow({ + where: { userId: person.id }, + select: { cycleTrackingEnabled: true }, + }), + ).toEqual({ cycleTrackingEnabled: true }); + + cookieJar.clear(); + await signIn(prisma, admin.id); + const check = await checkTarget(person.id); + expect(check.body.data).toMatchObject({ + eligible: true, + reason: null, + models: [], + }); + + const stored = await uploadInto(json, { + into: "existing", + userId: person.id, + confirm: "returning-person", + }); + expect(stored.status, JSON.stringify(stored.body)).toBe(201); + const restored = await restore(stored.body.data!.id); + expect(restored.status, JSON.stringify(restored.body)).toBe(200); + // Replaced, not merged: the counts are the file's, setup rows included. + expect(await countsOf(prisma, person.id)).toEqual(counts); + expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]); + }, + SLOW, + ); + + it( + "refuses an account whose setup ended on a reading of its own, because the restore would delete it", + async () => { + const prisma = getPrismaClient(); + const { admin } = await eraseAfterExport(prisma); + const person = await prisma.user.create({ + data: { username: "logged-one", email: "logged-one@example.test" }, + }); + await goThroughOnboarding(prisma, person.id); + // The first-result step's reading goes through the ordinary write. + await prisma.measurement.create({ + data: { + userId: person.id, + type: "BLOOD_PRESSURE_SYS", + value: 128, + unit: "mmHg", + measuredAt: new Date("2026-10-28T08:00:00.000Z"), + }, + }); + + cookieJar.clear(); + await signIn(prisma, admin.id); + expect((await checkTarget(person.id)).body.data).toMatchObject({ + eligible: false, + reason: "holds_records", + models: ["Measurement"], + }); + }, + SLOW, + ); + it( "into a new account: created without a password, filled, and signed into through the admin reset", async () => { From dfe34386be5942d4c207dd5d191be17405acc6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:25:41 +0100 Subject: [PATCH 3/8] fix(ai): read a Codex sign-in's memory only under its own account Every write to the per-sign-in Codex memory checked the account digest, but the reads did not. A credential reconnected to another ChatGPT account therefore tried the old account's working model first, sorted by its refusals and trusted its model list, until the first write rebound the entry, and the vision gate kept answering from the old account in the meantime. The client reads (working model, refusals, model list) now take the credential and treat an entry learned under another account as empty. The vision gate holds no account id, so the connect routes rebind the entry and the disconnect routes drop it, and the gate reads the entry as its process last bound it. --- docs/codex-protocol-spec.md | 2 +- .../admin/central-codex/device-poll/route.ts | 10 ++ src/app/api/admin/central-codex/route.ts | 6 + src/app/api/auth/codex/device-poll/route.ts | 10 ++ src/app/api/auth/codex/disconnect/route.ts | 6 + .../ai/__tests__/codex-live-models.test.ts | 53 +++++-- .../ai/__tests__/codex-model-catalog.test.ts | 6 +- .../ai/__tests__/codex-sign-in-routes.test.ts | 36 +++++ src/lib/ai/__tests__/codex-slug-cache.test.ts | 116 ++++++++++----- src/lib/ai/__tests__/codex-slug-chain.test.ts | 25 ++++ .../ai/__tests__/codex-slug-fallback.test.ts | 10 +- src/lib/ai/codex-client.ts | 2 +- src/lib/ai/codex-model-catalog.ts | 13 +- src/lib/ai/codex-slug-cache.ts | 134 ++++++++++++++---- src/lib/ai/codex-slug-chain.ts | 16 +-- 15 files changed, 353 insertions(+), 92 deletions(-) create mode 100644 src/lib/ai/__tests__/codex-sign-in-routes.test.ts diff --git a/docs/codex-protocol-spec.md b/docs/codex-protocol-spec.md index 0baf43877..ba81bf20f 100644 --- a/docs/codex-protocol-spec.md +++ b/docs/codex-protocol-spec.md @@ -692,7 +692,7 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep | `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) | | `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) | -**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a credential reconnected to another account starts empty. Two sign-ins on different plans no longer overwrite each other's working slug. +**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug. **Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds. diff --git a/src/app/api/admin/central-codex/device-poll/route.ts b/src/app/api/admin/central-codex/device-poll/route.ts index 62d9df175..18d7b70d3 100644 --- a/src/app/api/admin/central-codex/device-poll/route.ts +++ b/src/app/api/admin/central-codex/device-poll/route.ts @@ -10,6 +10,10 @@ import { getClientIp, apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; +import { + codexCredentialKey, + startCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; /** * Operator-shared central Codex — device-code poll (admin only). @@ -93,6 +97,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); cookieStore.delete("central_codex_device"); + // What this process learned about the old sign-in's models no longer + // describes a different account (`codex-slug-cache.ts`). + startCodexSignIn({ + key: codexCredentialKey.central, + accountId: result.creds.accountId, + }); // A new central sign-in: every account's ledger rows describe the refused // one, and the central connection is one credential shared by all of them. // #1279 — and every record's note windows close, as on the admin AI diff --git a/src/app/api/admin/central-codex/route.ts b/src/app/api/admin/central-codex/route.ts index fc766d0c2..eb5c25a7a 100644 --- a/src/app/api/admin/central-codex/route.ts +++ b/src/app/api/admin/central-codex/route.ts @@ -5,6 +5,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + codexCredentialKey, + forgetCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; /** * Operator-shared central Codex (ChatGPT subscription) — status + disconnect. @@ -55,6 +59,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { create: { id: "singleton", adminCodexConnectionStatus: "disconnected" }, }); + forgetCodexSignIn(codexCredentialKey.central); + await auditLog("admin.central-codex.disconnected", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/api/auth/codex/device-poll/route.ts b/src/app/api/auth/codex/device-poll/route.ts index e4fb52758..9c3a34ac9 100644 --- a/src/app/api/auth/codex/device-poll/route.ts +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context"; import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher"; import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; +import { + codexCredentialKey, + startCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; /** * v1.4.7.1: Device-code flow polling endpoint. @@ -90,6 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); cookieStore.delete("codex_device"); + // What this process learned about the old sign-in's models no longer + // describes a different account (`codex-slug-cache.ts`). + startCodexSignIn({ + key: codexCredentialKey.user(user.id), + accountId: result.creds.accountId, + }); // A new sign-in: whatever the ledger held against the old one no longer // describes this credential, and (#1279) the person's notes stop waiting // out windows it earned, as on a save of their provider settings. diff --git a/src/app/api/auth/codex/disconnect/route.ts b/src/app/api/auth/codex/disconnect/route.ts index 4302d4a5f..1fcb786ea 100644 --- a/src/app/api/auth/codex/disconnect/route.ts +++ b/src/app/api/auth/codex/disconnect/route.ts @@ -6,6 +6,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher"; +import { + codexCredentialKey, + forgetCodexSignIn, +} from "@/lib/ai/codex-slug-cache"; export const DELETE = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -27,6 +31,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { }, }); + forgetCodexSignIn(codexCredentialKey.user(user.id)); + await auditLog("codex.oauth.disconnected", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/lib/ai/__tests__/codex-live-models.test.ts b/src/lib/ai/__tests__/codex-live-models.test.ts index 9e310c50a..8f30a8de2 100644 --- a/src/lib/ai/__tests__/codex-live-models.test.ts +++ b/src/lib/ai/__tests__/codex-live-models.test.ts @@ -245,13 +245,21 @@ describe("Codex keeps the working model per sign-in", () => { await ask(a); await ask(b); - expect(getCachedCodexSlug(codexCredentialKey.user("a"))).toBe( - "gpt-5.6-terra", - ); - expect(getCachedCodexSlug(codexCredentialKey.user("b"))).toBe( - "gpt-6.1-sol", - ); - expect(getCachedCodexSlug(codexCredentialKey.central)).toBe("gpt-6.1-sol"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("a"), + accountId: "pro", + }), + ).toBe("gpt-5.6-terra"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("b"), + accountId: "edu", + }), + ).toBe("gpt-6.1-sol"); + expect( + getCachedCodexSlug({ key: codexCredentialKey.central, accountId: "edu" }), + ).toBe("gpt-6.1-sol"); // Not one refused request: each sign-in only ever asked what it is offered. expect(log.filter((l) => l.includes("responses"))).toEqual([ "pro responses:gpt-5.6-terra", @@ -291,6 +299,28 @@ describe("Codex keeps the working model per sign-in", () => { expect(log).toEqual(["stale models", "stale responses:gpt-6-sol"]); }); + it("a credential reconnected to another account tries nothing the old account learned", async () => { + const log = stubBackend({ + pro: { + models: planList("gpt-5.6-terra", "gpt-6-sol"), + accepts: ["gpt-5.6-terra", "gpt-6-sol"], + }, + // Another plan: terra is neither offered nor accepted. + edu: { models: planList("gpt-6.1-sol"), accepts: ["gpt-6.1-sol"] }, + }); + const key = codexCredentialKey.user("a"); + await ask(client(key, "pro")); + expect(log).toEqual(["pro models", "pro responses:gpt-5.6-terra"]); + + // The same stored credential, signed in again on the other account, in + // a process that has not seen the connect (no write has rebound it). + log.length = 0; + const again = client(key, "edu"); + await ask(again); + expect(log).toEqual(["edu models", "edu responses:gpt-6.1-sol"]); + expect(again.getLastDiagnostics()).toMatchObject({ cacheState: "miss" }); + }); + it("the working model that stops answering is dropped and the walk goes on", async () => { const accepts = ["gpt-5.6-terra", "gpt-6-sol"]; const log = stubBackend({ @@ -298,9 +328,12 @@ describe("Codex keeps the working model per sign-in", () => { }); const c = client(codexCredentialKey.user("u1"), "plus"); await ask(c); - expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBe( - "gpt-5.6-terra", - ); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u1"), + accountId: "plus", + }), + ).toBe("gpt-5.6-terra"); // terra retires between two requests. accepts.splice(0, 1); diff --git a/src/lib/ai/__tests__/codex-model-catalog.test.ts b/src/lib/ai/__tests__/codex-model-catalog.test.ts index e8893b50d..0e3c23c21 100644 --- a/src/lib/ai/__tests__/codex-model-catalog.test.ts +++ b/src/lib/ai/__tests__/codex-model-catalog.test.ts @@ -251,7 +251,7 @@ describe("readCodexModelCatalog", () => { const second = await readCodexModelCatalog(PERSONAL, AUTH); expect(second.from).toBe("cache"); expect(safeFetch).toHaveBeenCalledTimes(1); - expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); + expect(cachedCodexModels(PERSONAL)).toHaveLength(9); vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1); expect((await readCodexModelCatalog(PERSONAL, AUTH)).from).toBe("live"); @@ -363,8 +363,8 @@ describe("readCodexModelCatalog", () => { const second = { key: codexCredentialKey.user("u2"), accountId: "acct-b" }; await readCodexModelCatalog(PERSONAL, AUTH); await readCodexModelCatalog(second, { ...AUTH, accountId: "acct-b" }); - expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9); - expect(cachedCodexModels(second.key)?.map((m) => m.slug)).toEqual([ + expect(cachedCodexModels(PERSONAL)).toHaveLength(9); + expect(cachedCodexModels(second)?.map((m) => m.slug)).toEqual([ "gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", diff --git a/src/lib/ai/__tests__/codex-sign-in-routes.test.ts b/src/lib/ai/__tests__/codex-sign-in-routes.test.ts new file mode 100644 index 000000000..928963744 --- /dev/null +++ b/src/lib/ai/__tests__/codex-sign-in-routes.test.ts @@ -0,0 +1,36 @@ +/** + * The routes that connect or disconnect a stored Codex credential tell the + * per-sign-in memory, so the vision gate, which holds no account id, stops + * reading what the previous sign-in learned (`codex-slug-cache.ts`). The + * account-checked client reads cover every other path; this file holds the + * four call sites. + */ +import { describe, expect, it } from "vitest"; + +import { readSource } from "@/__tests__/helpers/source-files"; + +describe("Codex sign-in routes", () => { + it("tells the sign-in memory about every connect and disconnect", () => { + const routes: Array<[string, RegExp]> = [ + [ + "src/app/api/auth/codex/device-poll/route.ts", + /startCodexSignIn\(\{\s*key: codexCredentialKey\.user\(user\.id\),\s*accountId: result\.creds\.accountId/, + ], + [ + "src/app/api/admin/central-codex/device-poll/route.ts", + /startCodexSignIn\(\{\s*key: codexCredentialKey\.central,\s*accountId: result\.creds\.accountId/, + ], + [ + "src/app/api/auth/codex/disconnect/route.ts", + /forgetCodexSignIn\(codexCredentialKey\.user\(user\.id\)\)/, + ], + [ + "src/app/api/admin/central-codex/route.ts", + /forgetCodexSignIn\(codexCredentialKey\.central\)/, + ], + ]; + for (const [file, call] of routes) { + expect(readSource(file, call), file).toMatch(call); + } + }); +}); diff --git a/src/lib/ai/__tests__/codex-slug-cache.test.ts b/src/lib/ai/__tests__/codex-slug-cache.test.ts index 819b4277f..f77994dff 100644 --- a/src/lib/ai/__tests__/codex-slug-cache.test.ts +++ b/src/lib/ai/__tests__/codex-slug-cache.test.ts @@ -8,12 +8,15 @@ import { clearCodexSlugCache, codexCredentialCacheSize, codexCredentialKey, + codexGateMemory, + forgetCodexSignIn, getCachedCodexSlug, getCodexCatalogState, rejectedCodexSlugs, rememberRejectedCodexSlug, setCachedCodexSlug, setCodexCatalogState, + startCodexSignIn, } from "../codex-slug-cache"; /** @@ -35,19 +38,19 @@ describe("Codex per-sign-in cache", () => { }); it("starts empty", () => { - expect(getCachedCodexSlug(A.key)).toBeNull(); - expect(rejectedCodexSlugs(A.key).size).toBe(0); - expect(getCodexCatalogState(A.key)).toBeNull(); + expect(getCachedCodexSlug(A)).toBeNull(); + expect(rejectedCodexSlugs(A).size).toBe(0); + expect(getCodexCatalogState(A)).toBeNull(); }); it("holds the working slug for exactly the TTL", () => { setCachedCodexSlug(A, "gpt-6-sol", 1_000); - expect(getCachedCodexSlug(A.key, 1_000)).toBe("gpt-6-sol"); - expect(getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( + expect(getCachedCodexSlug(A, 1_000)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe( "gpt-6-sol", ); expect( - getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1), + getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1), ).toBeNull(); expect(CODEX_SLUG_CACHE_TTL_MS).toBe(60 * 60 * 1000); }); @@ -57,59 +60,89 @@ describe("Codex per-sign-in cache", () => { setCachedCodexSlug(A, "gpt-5.6-terra"); setCachedCodexSlug(B, "gpt-6.1-sol"); setCachedCodexSlug(CENTRAL, "gpt-6-sol"); - expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra"); - expect(getCachedCodexSlug(B.key)).toBe("gpt-6.1-sol"); - expect(getCachedCodexSlug(CENTRAL.key)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra"); + expect(getCachedCodexSlug(B)).toBe("gpt-6.1-sol"); + expect(getCachedCodexSlug(CENTRAL)).toBe("gpt-6-sol"); rememberRejectedCodexSlug(B, "gpt-6.1-sol"); - expect(getCachedCodexSlug(B.key)).toBeNull(); - expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra"); - expect(rejectedCodexSlugs(A.key).size).toBe(0); + expect(getCachedCodexSlug(B)).toBeNull(); + expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra"); + expect(rejectedCodexSlugs(A).size).toBe(0); }); it("a refusal drops the working slot and is held for its own TTL", () => { setCachedCodexSlug(A, "gpt-5.6-terra", 0); rememberRejectedCodexSlug(A, "gpt-5.6-terra", 10); - expect(getCachedCodexSlug(A.key, 10)).toBeNull(); - expect([...rejectedCodexSlugs(A.key, 10)]).toEqual(["gpt-5.6-terra"]); - expect([ - ...rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS), - ]).toEqual(["gpt-5.6-terra"]); + expect(getCachedCodexSlug(A, 10)).toBeNull(); + expect([...rejectedCodexSlugs(A, 10)]).toEqual(["gpt-5.6-terra"]); + expect([...rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS)]).toEqual( + ["gpt-5.6-terra"], + ); expect( - rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size, + rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size, ).toBe(0); }); it("a slug that works again leaves the refused set", () => { rememberRejectedCodexSlug(A, "gpt-6-sol"); setCachedCodexSlug(A, "gpt-6-sol"); - expect(rejectedCodexSlugs(A.key).size).toBe(0); + expect(rejectedCodexSlugs(A).size).toBe(0); }); - it("a credential reconnected to another account starts afresh", () => { + it("a credential reconnected to another account reads as empty before anything is written", () => { setCachedCodexSlug(A, "gpt-5.6-terra"); rememberRejectedCodexSlug(A, "gpt-6-sol"); setCodexCatalogState(A, { kind: "offered", models: [], at: Date.now() }); - // Same stored credential, a different ChatGPT account (another plan). - setCachedCodexSlug({ key: A.key, accountId: "acct-other" }, "gpt-6-luna"); - expect(getCachedCodexSlug(A.key)).toBe("gpt-6-luna"); - expect(rejectedCodexSlugs(A.key).size).toBe(0); - expect(getCodexCatalogState(A.key)).toBeNull(); + // Same stored credential, a different ChatGPT account (another plan): + // its first read finds nothing the old account learned. + const other = { key: A.key, accountId: "acct-other" }; + expect(getCachedCodexSlug(other)).toBeNull(); + expect(rejectedCodexSlugs(other).size).toBe(0); + expect(getCodexCatalogState(other)).toBeNull(); + + // Its first write starts the entry afresh, and the old account's memory + // is gone with it. + setCachedCodexSlug(other, "gpt-6-luna"); + expect(getCachedCodexSlug(other)).toBe("gpt-6-luna"); + expect(rejectedCodexSlugs(other).size).toBe(0); + expect(getCachedCodexSlug(A)).toBeNull(); + expect(getCodexCatalogState(A)).toBeNull(); + }); + + it("a connect rebinds the gate's memory and a disconnect forgets it", () => { + setCachedCodexSlug(A, "gpt-5.6-terra"); + rememberRejectedCodexSlug(A, "gpt-6-sol"); + expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra"); + + // A reconnect to the same account keeps what it learned. + startCodexSignIn(A); + expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra"); + + // A reconnect to another account starts it afresh. + startCodexSignIn({ key: A.key, accountId: "acct-other" }); + expect(codexGateMemory(A.key)).toEqual({ + working: null, + rejected: new Set(), + catalog: null, + }); + + setCachedCodexSlug(B, "gpt-6-sol"); + forgetCodexSignIn(B.key); + expect(codexGateMemory(B.key).working).toBeNull(); + expect(getCachedCodexSlug(B)).toBeNull(); }); it("holds a list for its TTL and a failed read for the shorter retry window", () => { setCodexCatalogState(A, { kind: "offered", models: [], at: 0 }); - expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS)?.kind).toBe( - "offered", - ); - expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS + 1)).toBeNull(); + expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS)?.kind).toBe("offered"); + expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS + 1)).toBeNull(); setCodexCatalogState(B, { kind: "unreadable", reason: "timeout", at: 0 }); - expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS)?.kind).toBe( + expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS)?.kind).toBe( "unreadable", ); - expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS + 1)).toBeNull(); + expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS + 1)).toBeNull(); }); it("reports the first unreadable read of a stretch, and re-arms on success", () => { @@ -135,13 +168,28 @@ describe("Codex per-sign-in cache", () => { ); } // Touch the oldest so the second-oldest is the one to go. - expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u0"), + accountId: "acct-0", + }), + ).toBe("gpt-6-sol"); setCachedCodexSlug( { key: codexCredentialKey.user("new"), accountId: "acct-new" }, "gpt-6-sol", ); expect(codexCredentialCacheSize()).toBe(CODEX_CREDENTIAL_CACHE_MAX); - expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol"); - expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBeNull(); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u0"), + accountId: "acct-0", + }), + ).toBe("gpt-6-sol"); + expect( + getCachedCodexSlug({ + key: codexCredentialKey.user("u1"), + accountId: "acct-1", + }), + ).toBeNull(); }); }); diff --git a/src/lib/ai/__tests__/codex-slug-chain.test.ts b/src/lib/ai/__tests__/codex-slug-chain.test.ts index dd21fd811..4c35c46c6 100644 --- a/src/lib/ai/__tests__/codex-slug-chain.test.ts +++ b/src/lib/ai/__tests__/codex-slug-chain.test.ts @@ -26,9 +26,11 @@ import { import { clearCodexSlugCache, codexCredentialKey, + forgetCodexSignIn, rememberRejectedCodexSlug, setCachedCodexSlug, setCodexCatalogState, + startCodexSignIn, } from "../codex-slug-cache"; const OFFERED = parseCodexModelList( @@ -278,6 +280,29 @@ describe("vision for a Codex sign-in", () => { expect(codexSignInReadsImages(B.key)).toBe(true); }); + it("answers from the new sign-in as soon as the credential is connected to another account", () => { + // The old account: its working model listed as text-only. + setCodexCatalogState(A, { + kind: "offered", + models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }], + at: Date.now(), + }); + setCachedCodexSlug(A, "gpt-6-sol"); + expect(codexSignInReadsImages(A.key)).toBe(false); + + // What the device-poll route records on a reconnect. + startCodexSignIn({ key: A.key, accountId: "acct-new" }); + expect(resolveCodexVisionSlug(A.key)).toBe(DEFAULT_SLUG_FALLBACK_CHAIN[0]); + expect(codexSignInReadsImages(A.key)).toBe(true); + expect(chainEntryReadsImages("codex", "a", null)).toBe(true); + + // And on a disconnect. + setCachedCodexSlug(B, "o3-mini"); + expect(codexSignInReadsImages(B.key)).toBe(false); + forgetCodexSignIn(B.key); + expect(codexSignInReadsImages(B.key)).toBe(true); + }); + it("falls back to the model family when the list says nothing", () => { setCodexCatalogState(A, { kind: "offered", diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index badd9f330..ac0aa8c1a 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -137,7 +137,7 @@ describe("Codex slug fallback chain", () => { "gpt-6-sol", ]); expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol"); - expect(getCachedCodexSlug(KEY)).toBe("gpt-6-sol"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-6-sol"); } finally { if (origModel === undefined) delete process.env.CODEX_MODEL; else process.env.CODEX_MODEL = origModel; @@ -169,7 +169,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.cacheState).toBe("miss"); expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); - expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 'not supported when using Codex with a ChatGPT account' 400", async () => { @@ -199,7 +199,7 @@ describe("Codex slug fallback chain", () => { expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex"); // The working slug — not the rejected one — is now cached. - expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex"); }); it("walks past first slug on 400 with model_not_found body", async () => { @@ -374,7 +374,7 @@ describe("Codex slug fallback chain", () => { it("cache invalidated when cached slug starts rejecting", async () => { setCachedCodexSlug(CREDENTIAL, "gpt-5-codex"); - expect(getCachedCodexSlug(KEY)).toBe("gpt-5-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5-codex"); const mockFetch = vi .fn() @@ -393,7 +393,7 @@ describe("Codex slug fallback chain", () => { // After walking to "gpt-5.3-codex" (the next entry after the // cached one), the cache is updated to the new working slug. - expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex"); + expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex"); }); it("isSlugRejection helper recognises canonical rejection bodies", () => { diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 7655f5b03..da239552f 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -297,7 +297,7 @@ export class CodexClient implements AIProvider { ): Promise { // The working slug alone first: the account's list is read only when // there is none, or when it stops working. - const cached = getCachedCodexSlug(this.selection.credentialKey); + const cached = getCachedCodexSlug(this.credential); const cacheState = cached === null ? "miss" : "hit"; const loadChain = () => loadCodexAttemptChain({ diff --git a/src/lib/ai/codex-model-catalog.ts b/src/lib/ai/codex-model-catalog.ts index 0ea5781b6..d95b1abd5 100644 --- a/src/lib/ai/codex-model-catalog.ts +++ b/src/lib/ai/codex-model-catalog.ts @@ -39,6 +39,7 @@ import { codexCredentialKey, getCodexCatalogState, setCodexCatalogState, + type CodexCatalogState, type CodexCredential, } from "./codex-slug-cache"; @@ -228,7 +229,7 @@ export function readCodexModelCatalog( credential: CodexCredential, auth: CodexCatalogAuth, ): Promise { - const state = getCodexCatalogState(credential.key); + const state = getCodexCatalogState(credential); if (state?.kind === "offered") { return Promise.resolve({ models: state.models, @@ -289,9 +290,15 @@ export function readCodexModelCatalog( /** The offered models from an earlier read, without touching the network. */ export function cachedCodexModels( - key: string, + credential: CodexCredential, now: number = Date.now(), ): readonly CodexOfferedModel[] | null { - const state = getCodexCatalogState(key, now); + return offeredModels(getCodexCatalogState(credential, now)); +} + +/** The models a list read offered, or null when it offered none. */ +export function offeredModels( + state: CodexCatalogState | null, +): readonly CodexOfferedModel[] | null { return state?.kind === "offered" ? state.models : null; } diff --git a/src/lib/ai/codex-slug-cache.ts b/src/lib/ai/codex-slug-cache.ts index b06d12c77..8e6198629 100644 --- a/src/lib/ai/codex-slug-cache.ts +++ b/src/lib/ai/codex-slug-cache.ts @@ -12,10 +12,21 @@ * - `central` for the operator-shared connection (`admin-codex`). * * An entry also remembers a digest of the ChatGPT account id it was learned - * under. A credential reconnected to a different account (a different plan) - * starts from an empty entry the first time a client runs on it, so nothing - * learned on the old account carries over. No token, and no raw account id, - * is ever a key or a value here. + * under, and a client reads it only under that account: a credential + * reconnected to a different account (a different plan) reads as empty from + * its first request, before anything is written under the new account, so no + * slug, refusal or list learned on the old account carries over. That holds + * in every process, including one that never saw the reconnect. + * + * The vision gate is the one reader without an account id (the provider + * presence probe decrypts nothing), so it reads the entry as this process last + * bound it ({@link codexGateMemory}). The routes that connect or disconnect a + * credential rebind or drop the entry ({@link startCodexSignIn}, + * {@link forgetCodexSignIn}), so in the process that handled the change the + * gate answers from the new sign-in at once; elsewhere it catches up with that + * process's first request on the new sign-in, and a wrong guess in between is + * what the runtime probe and the OCR fallback already cover. No token, and no + * raw account id, is ever a key or a value here. * * Bounded both ways: every part of an entry expires on its own TTL, and the * map holds at most `CODEX_CREDENTIAL_CACHE_MAX` sign-ins, the least recently @@ -76,7 +87,7 @@ function accountDigest(accountId: string): string { .slice(0, 32); } -/** Read an entry without binding it to an account; LRU-touches a hit. */ +/** Read an entry whatever account it was learned under; LRU-touches a hit. */ function peek(key: string): CredentialEntry | undefined { const entry = entries.get(key); if (!entry) return undefined; @@ -109,12 +120,22 @@ function bind(credential: CodexCredential): CredentialEntry { return fresh; } -/** The model that last worked for this sign-in, while it is fresh. */ -export function getCachedCodexSlug( - key: string, - now: number = Date.now(), +/** + * The entry learned under this sign-in's account, or none: an entry left by + * another account on the same credential reads as empty. + */ +function read(credential: CodexCredential): CredentialEntry | undefined { + const entry = peek(credential.key); + if (!entry) return undefined; + return entry.accountDigest === accountDigest(credential.accountId) + ? entry + : undefined; +} + +function liveWorking( + entry: CredentialEntry | undefined, + now: number, ): string | null { - const entry = peek(key); if (!entry?.working) return null; if (now - entry.working.at > CODEX_SLUG_CACHE_TTL_MS) { entry.working = null; @@ -123,6 +144,40 @@ export function getCachedCodexSlug( return entry.working.slug; } +function liveRejected( + entry: CredentialEntry | undefined, + now: number, +): ReadonlySet { + if (!entry) return new Set(); + for (const [slug, at] of entry.rejected) { + if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug); + } + return new Set(entry.rejected.keys()); +} + +function liveCatalog( + entry: CredentialEntry | undefined, + now: number, +): CodexCatalogState | null { + const state = entry?.catalog; + if (!entry || !state) return null; + const ttl = + state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS; + if (now - state.at > ttl) { + entry.catalog = null; + return null; + } + return state; +} + +/** The model that last worked for this sign-in, while it is fresh. */ +export function getCachedCodexSlug( + credential: CodexCredential, + now: number = Date.now(), +): string | null { + return liveWorking(read(credential), now); +} + export function setCachedCodexSlug( credential: CodexCredential, slug: string, @@ -151,32 +206,57 @@ export function rememberRejectedCodexSlug( /** The models this sign-in was refused recently. */ export function rejectedCodexSlugs( - key: string, + credential: CodexCredential, now: number = Date.now(), ): ReadonlySet { - const entry = peek(key); - if (!entry) return new Set(); - for (const [slug, at] of entry.rejected) { - if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug); - } - return new Set(entry.rejected.keys()); + return liveRejected(read(credential), now); } /** The last read of the account's model list, while it is still trusted. */ export function getCodexCatalogState( - key: string, + credential: CodexCredential, now: number = Date.now(), ): CodexCatalogState | null { + return liveCatalog(read(credential), now); +} + +/** What the vision gate reasons from for one stored credential. */ +export interface CodexGateMemory { + working: string | null; + rejected: ReadonlySet; + catalog: CodexCatalogState | null; +} + +/** + * The credential's memory as this process last bound it, for the vision gate + * alone, which holds no account id (see the module header). Every other + * reader goes through the account-checked reads above. + */ +export function codexGateMemory( + key: string, + now: number = Date.now(), +): CodexGateMemory { const entry = peek(key); - const state = entry?.catalog; - if (!entry || !state) return null; - const ttl = - state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS; - if (now - state.at > ttl) { - entry.catalog = null; - return null; - } - return state; + return { + working: liveWorking(entry, now), + rejected: liveRejected(entry, now), + catalog: liveCatalog(entry, now), + }; +} + +/** + * A credential has just been connected to `accountId`: a reconnect to another + * account starts the entry afresh, so the vision gate in this process stops + * reading what the old account learned; a reconnect to the same account keeps + * it. + */ +export function startCodexSignIn(credential: CodexCredential): void { + bind(credential); +} + +/** A credential has been disconnected: forget what it learned. */ +export function forgetCodexSignIn(key: string): void { + entries.delete(key); } /** diff --git a/src/lib/ai/codex-slug-chain.ts b/src/lib/ai/codex-slug-chain.ts index 8b345e62d..766051598 100644 --- a/src/lib/ai/codex-slug-chain.ts +++ b/src/lib/ai/codex-slug-chain.ts @@ -8,14 +8,14 @@ */ import { annotate } from "@/lib/logging/context"; import { - cachedCodexModels, + offeredModels, readCodexModelCatalog, type CodexCatalogRead, type CodexOfferedModel, } from "./codex-model-catalog"; import { codexCredentialKey, - getCachedCodexSlug, + codexGateMemory, rejectedCodexSlugs, type CodexCredential, } from "./codex-slug-cache"; @@ -172,7 +172,7 @@ export async function loadCodexAttemptChain(input: { slugChain?: readonly string[]; readModels?: CodexModelReader; }): Promise { - const rejected = rejectedCodexSlugs(input.credential.key); + const rejected = rejectedCodexSlugs(input.credential); if (input.slugChain && input.slugChain.length > 0) { return [ ...input.slugChain.filter((s) => !rejected.has(s)), @@ -218,13 +218,13 @@ export async function loadCodexAttemptChain(input: { * fallback cover. */ export function resolveCodexVisionSlug(credentialKey: string): string | null { - const cached = getCachedCodexSlug(credentialKey); - if (cached) return cached; + const memory = codexGateMemory(credentialKey); + if (memory.working) return memory.working; const { chain } = buildCodexSlugChain({ offered: operatorReplacedCodexChain() ? null - : cachedCodexModels(credentialKey), - rejected: rejectedCodexSlugs(credentialKey), + : offeredModels(memory.catalog), + rejected: memory.rejected, }); return chain[0] ?? null; } @@ -237,7 +237,7 @@ export function resolveCodexVisionSlug(credentialKey: string): string | null { export function codexSignInReadsImages(credentialKey: string): boolean { const slug = resolveCodexVisionSlug(credentialKey); if (!slug) return false; - const offered = cachedCodexModels(credentialKey)?.find( + const offered = offeredModels(codexGateMemory(credentialKey).catalog)?.find( (m) => m.slug === slug, ); if (offered?.inputModalities) { From 16d1b542a5df58ab01ab9cf1bd09bb0016d11a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:35:10 +0100 Subject: [PATCH 4/8] fix(backup): keep an erased account's copy out of admin accounts A restore read the account settings under the operator's AI endpoint policy whenever the receiving account was an admin. For a copy put into a chosen account, the settings were written by the erased account's owner, not by the operator, yet an admin account could be chosen as the target, and an ordinary target promoted later got the same trust. Admin accounts are now refused as a target, in the check and the picker, with backup.target.admin_account. A copy uploaded into a chosen account is stored as RETARGETED_UPLOAD_, and its restore never reads its settings as the operator's, whatever the account's role is by then. --- docs/api/openapi.yaml | 38 +++++----- docs/ops/backup-restore.md | 17 +++-- 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/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + src/app/api/admin/backups/upload/route.ts | 15 ++-- .../__tests__/backup-target-dialog.test.tsx | 8 +- .../admin/backups/backup-target-dialog.tsx | 10 ++- .../account-settings-restore.test.ts | 66 +++++++++++++++++ src/lib/export/account-settings-restore.ts | 16 +++- src/lib/export/backup-domains.ts | 1 + src/lib/export/backup-upload-type.ts | 23 ++++++ src/lib/export/restore-backup.ts | 10 +++ src/lib/export/restore-context.ts | 5 ++ src/lib/export/restore-target.ts | 27 ++++++- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/admin-backups.ts | 2 +- .../backup-erased-account-restore.test.ts | 74 +++++++++++++++++-- 24 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 src/lib/export/__tests__/account-settings-restore.test.ts create mode 100644 src/lib/export/backup-upload-type.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 29cc4ea93..a4b9df983 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25189,10 +25189,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The chosen target cannot take the copy, and nothing was stored: a managed profile - (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an - account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username - already taken (`backup.target.username_taken`), or a file whose own account is still here - (`backup.owner.exists`)." + (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin + account (`backup.target.admin_account`), an account that already holds records + (`backup.target.holds_records`, `meta.models` naming them), a username already taken + (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`)." content: application/json: schema: @@ -38717,21 +38717,21 @@ components: auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, - backup.target.holds_records, backup.target.managed_profile, backup.target.not_found, - backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken, - backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — - coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, - coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, - coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, - coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — - cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; - data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected, - dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, - dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + backup.target.admin_account, backup.target.holds_records, backup.target.managed_profile, + backup.target.not_found, backup.target.pending_deletion, backup.target.unconfirmed, + backup.target.username_taken, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — + body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, + consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — + dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, + dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 6f3e1baaa..94bd55a96 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -112,14 +112,21 @@ exists, and the console then asks where the records should go: count (the setup answers and the cycle settings): the restore replaces both with the copy's. A reading or medication entered on setup's last screen is a record, and the account is refused until it is deleted, because the restore - would delete it too. Managed profiles and accounts pending deletion are not - offered. + would delete it too. Managed profiles, accounts pending deletion and admin + accounts are not offered: an admin account is the operator's, its settings + are read under the operator's wider AI endpoint policy, and its role reaches + every account, so another person's record does not go into it. The file is then uploaded a second time, and its owner is changed to the chosen account as it is read. Nothing else in the file changes. The stored copy is -then that account's copy in every respect: its preview, its download and its -restore are the ordinary ones, and **Restore** on its row brings everything -back under the chosen account. The erased account's id is not stored, not in +then that account's copy in every respect but one: its preview, its download +and its restore are the ordinary ones, and **Restore** on its row brings +everything back under the chosen account. The one difference is that it is +stored as `RETARGETED_UPLOAD_