From d731a1b8febd983e678b08f301bfc78b935a2953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 13:04:36 +0200 Subject: [PATCH] feat(backup): classify every User column and carry the account settings Every column of the account row now has a backup verdict in USER_COLUMN_BACKUP_CLASS: SETTING, CREDENTIAL, IDENTITY or OPERATIONAL. A schema-derived guard fails on an unclassified column, and the setting columns each get a codec in account-settings-backup.ts, so a column classified as a setting without a way to travel does not compile. The restore end checks each value the way the settings route that writes it does: AI endpoints against this host's private-origin policy, languages, units and time zones against the known vocabularies, threshold bands against their bounds, the avatar against the upload's sniff and limits. A refused value is left out and named; credentials, identity and the host's own cursors are never admitted. --- .../user-column-backup-classification.test.ts | 70 ++ .../__tests__/account-settings-backup.test.ts | 267 ++++++++ src/lib/export/account-settings-backup.ts | 613 ++++++++++++++++++ src/lib/export/backup-plan.ts | 206 ++++++ 4 files changed, 1156 insertions(+) create mode 100644 src/__tests__/user-column-backup-classification.test.ts create mode 100644 src/lib/export/__tests__/account-settings-backup.test.ts create mode 100644 src/lib/export/account-settings-backup.ts diff --git a/src/__tests__/user-column-backup-classification.test.ts b/src/__tests__/user-column-backup-classification.test.ts new file mode 100644 index 000000000..a5fa12e90 --- /dev/null +++ b/src/__tests__/user-column-backup-classification.test.ts @@ -0,0 +1,70 @@ +/** + * Every `User` column has a backup verdict, read from the schema rather than + * from a list somebody keeps. + * + * The account row mixes the person's choices with sign-in credentials, + * provider keys, the operator's policy for the account and the host's own + * cursors, so it cannot have one verdict the way a model does. A column added + * tomorrow has to land in `USER_COLUMN_BACKUP_CLASS` (`src/lib/export/ + * backup-plan.ts`) or this fails naming it: a new preference that silently + * stays behind on a restore, or a new secret that silently rides along in + * every backup file, are the two failures this exists to stop. + * + * Mutation check: delete any key from the table and the first case goes red + * naming the column; add a `User` column to the schema without classifying it + * and the same case goes red naming that one. + */ +import { describe, expect, it } from "vitest"; + +import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape"; +import { USER_COLUMN_BACKUP_CLASS } from "@/lib/export/backup-plan"; +import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup"; + +const columns = parseWipeSchema().get("User")?.scalars ?? []; +const classified = USER_COLUMN_BACKUP_CLASS as Readonly>; + +describe("User column backup classification", () => { + it("classifies every User column", () => { + // A floor, so a parser that stops matching cannot pass on an empty list. + expect(columns.length).toBeGreaterThan(100); + const unclassified = columns.filter((c) => !Object.hasOwn(classified, c)); + expect( + unclassified, + "these User columns have no backup verdict. Add each to " + + "USER_COLUMN_BACKUP_CLASS as SETTING, CREDENTIAL, IDENTITY or " + + "OPERATIONAL, and if it is a SETTING give it a codec in " + + "account-settings-backup.ts", + ).toEqual([]); + }); + + it("names no column the schema no longer has", () => { + const known = new Set(columns); + expect(Object.keys(classified).filter((c) => !known.has(c))).toEqual([]); + }); + + it("carries exactly the columns classified as settings", () => { + expect([...ACCOUNT_SETTING_COLUMNS].sort()).toEqual( + Object.entries(classified) + .filter(([, verdict]) => verdict === "SETTING") + .map(([column]) => column) + .sort(), + ); + }); + + it("carries no credential-shaped column as a setting", () => { + // The insurance number is the one sealed setting: it is the person's own + // data, sealed at rest for privacy, not a secret that grants anything. + const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) => + /Encrypted$|Token|Secret|password|totp|oidc/i.test(column), + ); + expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]); + }); + + it("never classifies a sealed column other than the insurance number as anything but a credential", () => { + const sealed = columns.filter( + (c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted", + ); + expect(sealed.length).toBeGreaterThan(20); + expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]); + }); +}); diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts new file mode 100644 index 000000000..b0768b7e9 --- /dev/null +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -0,0 +1,267 @@ +/** + * The account-settings section at both ends, without a database. + * + * The integration round trip proves every setting column comes back; this + * file pins what the restore refuses and why, and what a portable file shows + * a person instead of ciphertext. Every refusal here leaves the account's + * current value in place and names the column in the restore's skip report. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { Prisma } from "@/generated/prisma/client"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { + ACCOUNT_SETTING_COLUMNS, + admitAccountSettings, + buildAccountSettingsBackupSection, +} from "@/lib/export/account-settings-backup"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; + +/** A 1x1 PNG, the smallest image the avatar upload accepts. */ +const PNG_1X1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +const ctx = { operatorTrusted: false }; + +function rowWith(overrides: Record = {}) { + const row: Record = Object.fromEntries( + ACCOUNT_SETTING_COLUMNS.map((column) => [column, null]), + ); + return { ...row, ...overrides }; +} + +function prismaReturning(row: Record | null) { + return { user: { findUnique: vi.fn().mockResolvedValue(row) } } as never; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("buildAccountSettingsBackupSection", () => { + it("writes only setting columns, plus the readable insurance number alias", async () => { + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith()), + "u1", + { purpose: "disaster-recovery" }, + ); + const allowed = new Set([ + ...ACCOUNT_SETTING_COLUMNS, + "insuranceNumber", + ]); + expect( + Object.keys(accountSettings!).filter((key) => !allowed.has(key)), + ).toEqual([]); + }); + + it("carries the insurance number sealed in a disaster-recovery file", async () => { + const sealed = encrypt("A123456789"); + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ insuranceNumberEncrypted: sealed })), + "u1", + { purpose: "disaster-recovery" }, + ); + expect(accountSettings).toMatchObject({ + insuranceNumber: null, + insuranceNumberEncrypted: sealed, + }); + }); + + it("carries it readable in a portable file, and never the ciphertext", async () => { + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning( + rowWith({ insuranceNumberEncrypted: encrypt("A123456789") }), + ), + "u1", + { purpose: "portable-export" }, + ); + expect(accountSettings!.insuranceNumber).toBe("A123456789"); + expect(accountSettings).not.toHaveProperty("insuranceNumberEncrypted"); + }); + + it("writes the marker for an insurance number this host cannot open", async () => { + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ insuranceNumberEncrypted: "zz.notreal" })), + "u1", + { purpose: "portable-export" }, + ); + expect(accountSettings!.insuranceNumber).toBe(UNREADABLE_EXPORT_MARKER); + }); + + it("answers null for an account that is not there", async () => { + expect( + await buildAccountSettingsBackupSection(prismaReturning(null), "u1"), + ).toEqual({ accountSettings: null }); + }); +}); + +describe("admitAccountSettings", () => { + it("leaves out what the file does not carry", () => { + expect(admitAccountSettings({}, ctx)).toEqual({ data: {}, refused: [] }); + }); + + it("refuses a language, unit or time zone this release does not know", () => { + const { data, refused } = admitAccountSettings( + { + locale: "xx", + unitPreference: "furlongs", + glucoseUnit: "mmol/L", + timezone: "Mars/Olympus_Mons", + homeTimezone: "Europe/Lisbon", + timeFormat: "H36", + dateFormat: "YMD", + }, + ctx, + ); + expect(refused.sort()).toEqual([ + "locale", + "timeFormat", + "timezone", + "unitPreference", + ]); + expect(data).toEqual({ + glucoseUnit: "mmol/L", + homeTimezone: "Europe/Lisbon", + dateFormat: "YMD", + }); + }); + + it("refuses an AI endpoint on a private address this host has not allowed", () => { + vi.stubEnv("AI_PRIVATE_ORIGINS", ""); + const { data, refused } = admitAccountSettings( + { + aiProvider: "LOCAL", + aiBaseUrl: "http://192.168.1.20:11434", + aiCompatBaseUrl: "http://169.254.169.254/latest", + }, + ctx, + ); + expect(refused.sort()).toEqual(["aiBaseUrl", "aiCompatBaseUrl"]); + expect(data).toEqual({ aiProvider: "LOCAL" }); + }); + + it("keeps a private AI endpoint the operator allowed here", () => { + vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434"); + const { data, refused } = admitAccountSettings( + { aiProvider: "LOCAL", aiBaseUrl: "http://ollama.lan:11434" }, + ctx, + ); + expect(refused).toEqual([]); + expect(data.aiBaseUrl).toBe("http://ollama.lan:11434"); + }); + + it("does not attach a base URL to a provider other than Local", () => { + const { data, refused } = admitAccountSettings( + { aiProvider: "OPENAI", aiBaseUrl: "https://llm.example.com/v1" }, + ctx, + ); + expect(refused).toEqual(["aiBaseUrl"]); + expect(data).toEqual({ aiProvider: "OPENAI" }); + }); + + it("reads the provider the account already has when the file names none", () => { + expect( + admitAccountSettings( + { aiBaseUrl: "https://llm.example.com/v1" }, + ctx, + "LOCAL", + ).data, + ).toEqual({ aiBaseUrl: "https://llm.example.com/v1" }); + }); + + it("keeps the valid threshold bands and names the ones it drops", () => { + const { data, refused } = admitAccountSettings( + { + thresholdsJson: { + WEIGHT: { min: 60, max: 80 }, + PULSE: { min: 100, max: 50 }, + RETIRED_METRIC: { min: 1, max: 2 }, + }, + }, + ctx, + ); + expect(data.thresholdsJson).toEqual({ WEIGHT: { min: 60, max: 80 } }); + expect(refused.sort()).toEqual([ + "thresholdsJson.PULSE", + "thresholdsJson.RETIRED_METRIC", + ]); + }); + + it("writes a database null for a JSON setting the file clears", () => { + expect( + admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data + .dashboardWidgetsJson, + ).toBe(Prisma.DbNull); + }); + + it("drops an injection site this release does not know and keeps the rest", () => { + const { data, refused } = admitAccountSettings( + { globalExcludedInjectionSites: ["THIGH_LEFT", "EARLOBE"] }, + ctx, + ); + expect(data.globalExcludedInjectionSites).toEqual(["THIGH_LEFT"]); + expect(refused).toEqual(["globalExcludedInjectionSites.EARLOBE"]); + }); + + it("takes the avatar's type from its bytes, not from the file", () => { + const { data, refused } = admitAccountSettings( + { + avatarBytes: PNG_1X1, + avatarContentType: "text/html", + avatarUpdatedAt: "2026-06-30T12:00:00.000Z", + }, + ctx, + ); + expect(refused).toEqual([]); + expect(data.avatarContentType).toBe("image/png"); + expect(data.avatarUpdatedAt).toEqual(new Date("2026-06-30T12:00:00.000Z")); + expect(data.avatarBytes).toBeInstanceOf(Uint8Array); + }); + + it("refuses an avatar that is not an image the upload accepts", () => { + const { data, refused } = admitAccountSettings( + { + avatarBytes: Buffer.from("....").toString( + "base64", + ), + avatarContentType: "image/svg+xml", + }, + ctx, + ); + expect(refused).toEqual(["avatarBytes"]); + expect(data).toEqual({}); + }); + + it("seals a readable insurance number under this host's key", () => { + const { data } = admitAccountSettings({ insuranceNumber: "A123" }, ctx); + expect(decrypt(data.insuranceNumberEncrypted as string)).toBe("A123"); + }); + + it("refuses the unreadable marker as an insurance number", () => { + const { data, refused } = admitAccountSettings( + { insuranceNumber: UNREADABLE_EXPORT_MARKER }, + ctx, + ); + expect(refused).toEqual(["insuranceNumberEncrypted"]); + expect(data).toEqual({}); + }); + + it("never admits a column that is not a setting, whatever the file says", () => { + const { data } = admitAccountSettings( + { + passwordHash: "x", + role: "ADMIN", + email: "someone@example.test", + aiOpenaiKeyEncrypted: encrypt("sk-test"), + managedProfileAt: "2026-01-01T00:00:00.000Z", + syncResetAt: "2026-01-01T00:00:00.000Z", + heightCm: 180, + }, + ctx, + ); + expect(data).toEqual({ heightCm: 180 }); + }); +}); diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts new file mode 100644 index 000000000..b2d5c9703 --- /dev/null +++ b/src/lib/export/account-settings-backup.ts @@ -0,0 +1,613 @@ +/** + * The account's own settings, with both backup ends in one file. + * + * Which `User` columns these are is decided in `USER_COLUMN_BACKUP_CLASS` + * (`src/lib/export/backup-plan.ts`); this file is how each one travels. The + * table below is keyed by that type, so a column classified as a setting + * without a way to carry it does not compile, and neither does a way to carry + * a column that is not one. + * + * ── What the restore does with them ──────────────────────────────────────── + * + * Writes them to the account being restored, column by column, and nothing + * else on the row. The value from the file is not trusted just because this + * host once wrote it: the file may come from another host, from an older + * release, or from an operator's text editor, so each column is checked the + * way the settings route that writes it checks it. A value that fails is not + * written, the account keeps what it had, and the column is named in the + * restore's skip report, so a setting that did not come back is never + * reported as one that did. The checks that can actually fail on a real file: + * + * - an AI endpoint on a private address the receiving host has not allowed + * (`AI_PRIVATE_ORIGINS`), or one the account's role no longer covers; + * - a language, unit or time zone this release does not know; + * - a threshold for a metric this release does not range, or out of range; + * - an avatar that is not one of the image types the upload accepts. + * + * A setting naming something the receiving host does not have (a provider + * with no key, a source that is not connected, a module the operator has + * turned off) is kept: the app already reads that state as "not available" + * everywhere, because it is exactly the state after a key is removed or an + * integration disconnected. + * + * ── Encryption ───────────────────────────────────────────────────────────── + * + * One column is sealed, the insurance number, and it follows the split every + * other section uses: a disaster-recovery file carries the ciphertext + * verbatim (so the restore's key check sees it), a portable file carries the + * readable value and the restore seals it under this host's key. + */ +import { Buffer } from "node:buffer"; + +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import { + DateFormatPreference, + InjectionSite, + TimeFormatPreference, +} from "@/generated/prisma/enums"; +import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; +import { + AVATAR_MAX_BYTES, + detectAvatarMimeType, + readAvatarDimensions, +} from "@/lib/avatar"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { + USER_COLUMN_BACKUP_CLASS, + type AccountSettingColumn, +} from "@/lib/export/backup-plan"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { locales } from "@/lib/i18n/config"; +import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; +import { isPublicUrl } from "@/lib/validations/notifications"; +import { + glucoseUnitPatchSchema, + unitPreferencePatchSchema, +} from "@/lib/validations/user-prefs"; + +export interface AccountSettingsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** The section as it rides the wire: setting columns by name, JSON-safe. */ +export type AccountSettingsBackupEntry = Record; + +export interface AccountSettingsBackupSection { + accountSettings: AccountSettingsBackupEntry | null; +} + +/** Every setting column, in the order the table below declares them. */ +export const ACCOUNT_SETTING_COLUMNS = Object.entries(USER_COLUMN_BACKUP_CLASS) + .filter(([, verdict]) => verdict === "SETTING") + .map(([column]) => column as AccountSettingColumn); + +type SettingsRow = Record; + +/** What the restore knows about the account it writes into. */ +export interface AccountSettingsRestoreContext { + /** Whether the account is an admin, which widens the AI endpoint policy. */ + operatorTrusted: boolean; +} + +/** One column's two ends. */ +interface SettingCodec { + /** The wire fragment for this column, read from the database row. */ + write(row: SettingsRow, disasterRecovery: boolean): Record; + /** + * The database value for this column from the file, `undefined` when the + * file does not carry it (the account keeps what it has), or a refusal. + */ + read( + entry: AccountSettingsBackupEntry, + ctx: AccountSettingsRestoreContext, + ): unknown; +} + +/** A value from the file that this host will not write. */ +class Refused { + constructor(readonly key: string) {} +} + +/** A value that came back in part, with the parts that did not named. */ +class PartlyRefused { + constructor( + readonly value: unknown, + readonly refused: string[], + ) {} +} + +function has(entry: AccountSettingsBackupEntry, key: string): boolean { + return Object.hasOwn(entry, key) && entry[key] !== undefined; +} + +/** Carried as it is: a string, number, boolean or list, or null. */ +function plain(column: AccountSettingColumn): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry) => (has(entry, column) ? entry[column] : undefined), + }; +} + +/** A timestamp, carried as ISO-8601. */ +function instant(column: AccountSettingColumn): SettingCodec { + return { + write: (row) => ({ + [column]: + row[column] instanceof Date + ? (row[column] as Date).toISOString() + : null, + }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + return value === null ? null : new Date(value as string); + }, + }; +} + +/** + * A JSON column, carried as it is stored. Every reader of these parses + * fail-soft (a key it does not know is ignored, a malformed blob reads as the + * default), so a value written by another release restores rather than being + * refused here on a stricter opinion than the app's own. + */ +function json(column: AccountSettingColumn): SettingCodec { + return { + write: (row) => ({ [column]: row[column] ?? null }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + return value === null ? Prisma.DbNull : value; + }, + }; +} + +/** A closed vocabulary. A value outside it is refused, not coerced. */ +function oneOf( + column: AccountSettingColumn, + values: readonly string[], + nullable: boolean, +): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + if (value === null && nullable) return null; + if (typeof value === "string" && values.includes(value)) return value; + return new Refused(column); + }, + }; +} + +function isTimeZone(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || value.length > 64) { + return false; + } + try { + Intl.DateTimeFormat(undefined, { timeZone: value }); + return true; + } catch { + return false; + } +} + +/** An IANA zone this runtime knows, the check the settings routes apply. */ +function timeZone( + column: AccountSettingColumn, + nullable: boolean, +): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + if (value === null && nullable) return null; + return isTimeZone(value) ? value : new Refused(column); + }, + }; +} + +/** + * An AI endpoint, held to the rule `PATCH /api/user/ai-provider` applies when + * it is saved: a public address, or a private one the operator of THIS host + * allowed for this account. The address arrived from a file, so it has never + * been through that check here. + */ +function aiEndpoint(column: "aiBaseUrl" | "aiCompatBaseUrl"): SettingCodec { + return { + write: (row) => ({ [column]: row[column] }), + read: (entry, ctx) => { + if (!has(entry, column)) return undefined; + const value = entry[column]; + if (value === null) return null; + if (typeof value !== "string") return new Refused(column); + const trimmed = value.trim(); + if (trimmed === "") return null; + const allowed = + isLocalAiHostAllowed(trimmed, { + operatorTrusted: ctx.operatorTrusted, + }) || isPublicUrl(trimmed); + return allowed ? trimmed : new Refused(column); + }, + }; +} + +/** + * The threshold overrides, metric by metric. The readers that apply a band + * cast the column rather than parse it, so a band this release does not + * range, or one outside the metric's bounds, is dropped here and named, + * while the valid bands beside it still come back. + */ +const thresholds: SettingCodec = { + write: (row) => ({ thresholdsJson: row.thresholdsJson ?? null }), + read: (entry) => { + if (!has(entry, "thresholdsJson")) return undefined; + const value = entry.thresholdsJson; + if (value === null) return Prisma.DbNull; + if (typeof value !== "object" || Array.isArray(value)) { + return new Refused("thresholdsJson"); + } + const kept: Record = {}; + const refused: string[] = []; + for (const [metric, band] of Object.entries(value)) { + if (thresholdsUpdateSchema.safeParse({ [metric]: band }).success) { + kept[metric] = band; + } else { + refused.push(`thresholdsJson.${metric}`); + } + } + return new PartlyRefused( + Object.keys(kept).length === 0 ? Prisma.DbNull : kept, + refused, + ); + }, +}; + +const injectionSites = Object.values(InjectionSite) as string[]; + +/** The injection sites to avoid everywhere; an unknown site is dropped. */ +const excludedSites: SettingCodec = { + write: (row) => ({ + globalExcludedInjectionSites: row.globalExcludedInjectionSites, + }), + read: (entry) => { + if (!has(entry, "globalExcludedInjectionSites")) return undefined; + const value = entry.globalExcludedInjectionSites; + if (!Array.isArray(value)) + return new Refused("globalExcludedInjectionSites"); + const kept = value.filter( + (site): site is string => + typeof site === "string" && injectionSites.includes(site), + ); + const refused = value + .filter((site) => !kept.includes(site as string)) + .map((site) => `globalExcludedInjectionSites.${String(site)}`); + return new PartlyRefused(kept, refused); + }, +}; + +/** + * The picture, re-checked the way the upload route checks one: sniffed from + * its bytes, within the size and dimension limits. The stored content type + * is the sniffed one, never the file's, because the avatar is served with it. + * The three avatar columns travel as one: the bytes decide, the type and the + * stamp follow them. + */ +const avatar: SettingCodec = { + write: (row) => ({ + avatarBytes: + row.avatarBytes instanceof Uint8Array + ? Buffer.from(row.avatarBytes).toString("base64") + : null, + avatarContentType: row.avatarContentType, + avatarUpdatedAt: + row.avatarUpdatedAt instanceof Date + ? row.avatarUpdatedAt.toISOString() + : null, + }), + read: (entry) => { + if (!has(entry, "avatarBytes")) return undefined; + const value = entry.avatarBytes; + if (value === null) { + return { + avatarBytes: null, + avatarContentType: null, + avatarUpdatedAt: null, + }; + } + if (typeof value !== "string") return new Refused("avatarBytes"); + const buffer = Buffer.from(value, "base64"); + if (buffer.byteLength === 0 || buffer.byteLength > AVATAR_MAX_BYTES) { + return new Refused("avatarBytes"); + } + const mime = detectAvatarMimeType(buffer); + const dimensions = mime ? readAvatarDimensions(buffer, mime) : null; + if (!mime || !dimensions) return new Refused("avatarBytes"); + if (dimensions.width > 2048 || dimensions.height > 2048) { + return new Refused("avatarBytes"); + } + const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength)); + bytes.set(buffer); + const stamp = entry.avatarUpdatedAt; + return { + avatarBytes: bytes, + avatarContentType: mime, + avatarUpdatedAt: typeof stamp === "string" ? new Date(stamp) : new Date(), + }; + }, +}; + +/** Carried by the avatar codec above. */ +const ridesWithAvatar: SettingCodec = { + write: () => ({}), + read: () => undefined, +}; + +/** + * The insurance number. Sealed at rest; see the file comment for the split. + * A portable file written while this host could not open the value carries + * the unreadable marker, and the marker is not an insurance number, so it is + * refused rather than sealed as one. + */ +const insuranceNumber: SettingCodec = { + write: (row, disasterRecovery) => { + const sealed = row.insuranceNumberEncrypted as string | null; + if (disasterRecovery) { + return { insuranceNumber: null, insuranceNumberEncrypted: sealed }; + } + if (sealed === null) return { insuranceNumber: null }; + try { + return { insuranceNumber: decrypt(sealed) }; + } catch { + return { insuranceNumber: UNREADABLE_EXPORT_MARKER }; + } + }, + read: (entry) => { + if (has(entry, "insuranceNumberEncrypted")) { + const sealed = entry.insuranceNumberEncrypted; + if (sealed === null) return null; + return typeof sealed === "string" + ? sealed + : new Refused("insuranceNumberEncrypted"); + } + if (!has(entry, "insuranceNumber")) return undefined; + const readable = entry.insuranceNumber; + if (readable === null || readable === "") return null; + if (typeof readable !== "string" || readable === UNREADABLE_EXPORT_MARKER) { + return new Refused("insuranceNumberEncrypted"); + } + return encrypt(readable); + }, +}; + +const ACCOUNT_SETTING_CODECS: Readonly< + Record +> = { + heightCm: plain("heightCm"), + dateOfBirth: instant("dateOfBirth"), + gender: oneOf("gender", ["MALE", "FEMALE", "OTHER"], true), + hasDiabetes: plain("hasDiabetes"), + displayName: plain("displayName"), + fullName: plain("fullName"), + insurerName: plain("insurerName"), + insuranceNumberEncrypted: insuranceNumber, + insurerIkNumber: plain("insurerIkNumber"), + lastReportPracticeName: plain("lastReportPracticeName"), + avatarBytes: avatar, + avatarContentType: ridesWithAvatar, + avatarUpdatedAt: ridesWithAvatar, + homeLat: plain("homeLat"), + homeLon: plain("homeLon"), + homeLabel: plain("homeLabel"), + homeTimezone: timeZone("homeTimezone", true), + homeSince: instant("homeSince"), + timezone: timeZone("timezone", false), + locale: oneOf("locale", locales, true), + // The vocabularies the two preference routes accept, read from their own + // schemas so the restore cannot drift from them. + unitPreference: oneOf( + "unitPreference", + unitPreferencePatchSchema.shape.unitPreference.options, + true, + ), + glucoseUnit: oneOf( + "glucoseUnit", + glucoseUnitPatchSchema.shape.glucoseUnit.options, + true, + ), + timeFormat: oneOf("timeFormat", Object.values(TimeFormatPreference), false), + dateFormat: oneOf("dateFormat", Object.values(DateFormatPreference), false), + modulePreferencesJson: json("modulePreferencesJson"), + thresholdsJson: thresholds, + healthScoreConfigJson: json("healthScoreConfigJson"), + sourcePriorityJson: json("sourcePriorityJson"), + dashboardWidgetsJson: json("dashboardWidgetsJson"), + insightsLayoutJson: json("insightsLayoutJson"), + medicationListLayoutJson: json("medicationListLayoutJson"), + moodTagLayoutJson: json("moodTagLayoutJson"), + reportSelectionJson: json("reportSelectionJson"), + globalExcludedInjectionSites: excludedSites, + healthKitConfigJson: json("healthKitConfigJson"), + notificationPrefs: json("notificationPrefs"), + moodReminderEnabled: plain("moodReminderEnabled"), + aiProvider: plain("aiProvider"), + aiModel: plain("aiModel"), + aiBaseUrl: aiEndpoint("aiBaseUrl"), + aiCompatBaseUrl: aiEndpoint("aiCompatBaseUrl"), + aiCompatModel: plain("aiCompatModel"), + aiProviderChain: json("aiProviderChain"), + aiResponseTimeoutSeconds: plain("aiResponseTimeoutSeconds"), + useCentralCodex: plain("useCentralCodex"), + insightsPrivacyMode: oneOf( + "insightsPrivacyMode", + ["aggregated", "raw"], + false, + ), + insightsExcludeMetrics: plain("insightsExcludeMetrics"), + disableCoach: plain("disableCoach"), + coachPrefsJson: json("coachPrefsJson"), + documentsAutoAiRead: plain("documentsAutoAiRead"), + labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), + onboardingCompletedAt: instant("onboardingCompletedAt"), + onboardingTourCompleted: plain("onboardingTourCompleted"), + onboardingTourProgressJson: json("onboardingTourProgressJson"), + disclaimerAcknowledgedAt: instant("disclaimerAcknowledgedAt"), + disclaimerAcknowledgedVersion: plain("disclaimerAcknowledgedVersion"), + passkeyUpgradeNudgeDismissed: plain("passkeyUpgradeNudgeDismissed"), +}; + +/** Exactly the setting columns, so the read is the declaration. */ +const SETTINGS_SELECT = Object.fromEntries( + ACCOUNT_SETTING_COLUMNS.map((column) => [column, true]), +) as Record; + +/** + * Build the account-settings slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders. + */ +export async function buildAccountSettingsBackupSection( + prisma: Pick, + userId: string, + options: AccountSettingsBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + const row = (await prisma.user.findUnique({ + where: { id: userId }, + select: SETTINGS_SELECT, + })) as SettingsRow | null; + if (!row) return { accountSettings: null }; + + const entry: AccountSettingsBackupEntry = {}; + for (const column of ACCOUNT_SETTING_COLUMNS) { + Object.assign( + entry, + ACCOUNT_SETTING_CODECS[column].write(row, disasterRecovery), + ); + } + return { accountSettings: entry }; +} + +/** What the file's settings section turned into, before anything is written. */ +export interface AdmittedAccountSettings { + /** Column -> the value to write. Only setting columns ever appear here. */ + data: Partial>; + /** Columns (or `column.part`) the file carried and this host refused. */ + refused: string[]; +} + +/** + * Decide, without writing, what the file's settings section may put on the + * account. Pure, so every refusal rule is testable without a database. + * + * `currentAiProvider` is the account's provider before the restore, for the + * one rule that spans two columns: only the Local provider uses + * `aiBaseUrl`, and the settings route clears the address whenever the + * provider moves away from Local so that a cloud key is never sent to it. + */ +export function admitAccountSettings( + entry: AccountSettingsBackupEntry, + ctx: AccountSettingsRestoreContext, + currentAiProvider: string | null = null, +): AdmittedAccountSettings { + const data: Partial> = {}; + const refused: string[] = []; + + for (const column of ACCOUNT_SETTING_COLUMNS) { + const value = ACCOUNT_SETTING_CODECS[column].read(entry, ctx); + if (value === undefined) continue; + if (value instanceof Refused) { + refused.push(value.key); + continue; + } + if (value instanceof PartlyRefused) { + data[column] = value.value; + refused.push(...value.refused); + continue; + } + if (column === "avatarBytes") { + // The avatar codec answers for all three of its columns at once. + Object.assign(data, value); + continue; + } + data[column] = value; + } + + const provider = + "aiProvider" in data + ? (data.aiProvider as string | null) + : currentAiProvider; + if (typeof data.aiBaseUrl === "string" && provider !== "LOCAL") { + delete data.aiBaseUrl; + refused.push("aiBaseUrl"); + } + + return { data, refused }; +} + +/** The slice of a parsed backup this restore consumes. */ +export interface AccountSettingsRestoreInput { + /** Absent from every file written before the section existed. */ + accountSettings?: AccountSettingsBackupEntry | null; +} + +export interface AccountSettingsRestoreResult { + /** How many setting columns were written. */ + applied: number; + /** What the file carried and the account did not take, by column. */ + refused: string[]; + /** Whether a unit preference changed, so stored texts quote old units. */ + unitsChanged: boolean; +} + +/** + * Put the account's settings back, inside the caller's transaction. + * + * Only the columns `admitAccountSettings` admitted, and only setting columns + * can be admitted. Identity, credentials and the host's own bookkeeping on + * the row are never in the write, so they stay exactly as the receiving + * account has them. A file without the section leaves the row untouched, + * which is how every backup written before the section existed restores. + */ +export async function restoreAccountSettings( + tx: Prisma.TransactionClient, + ownerId: string, + payload: AccountSettingsRestoreInput, +): Promise { + const entry = payload.accountSettings; + if (!entry) return { applied: 0, refused: [], unitsChanged: false }; + + const current = await tx.user.findUniqueOrThrow({ + where: { id: ownerId }, + select: { + role: true, + aiProvider: true, + unitPreference: true, + glucoseUnit: true, + }, + }); + const { data, refused } = admitAccountSettings( + entry, + { operatorTrusted: current.role === "ADMIN" }, + current.aiProvider, + ); + + const columns = Object.keys(data); + if (columns.length > 0) { + await tx.user.update({ + where: { id: ownerId }, + data: data as Prisma.UserUpdateInput, + }); + } + + return { + applied: columns.length, + refused, + unitsChanged: + ("unitPreference" in data && + data.unitPreference !== current.unitPreference) || + ("glucoseUnit" in data && data.glucoseUnit !== current.glucoseUnit), + }; +} diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 21301b399..bebdc9649 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -738,6 +738,212 @@ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ "InviteRedemption", ]; +/** + * Every column of the `User` row, and whether it travels. + * + * The models above are decided one table at a time. The account row cannot + * be: it holds the person's own choices next to their sign-in credentials, + * the integration keys, the operator's policy for the account and a dozen + * cursors, and a single verdict for the whole row would either leave the + * choices behind or carry the credentials with them. Before this table the + * row was simply not in the backup, so a restore brought the record back and + * dropped units, language, time zone, modules, reminder thresholds and the + * rest of how the account had been set up. + * + * `SETTING` — something the person chose. Exported in the + * `accountSettings` section and written back to the account + * being restored, column by column, after the same checks + * the settings routes apply. Both ends live in + * `src/lib/export/account-settings-backup.ts`. + * `CREDENTIAL` — a secret, a token, a provider registration or an external + * identity binding, and the state that only means something + * next to one. A backup file is not a credential store; the + * same reasoning as `ApiToken` above. + * `IDENTITY` — what the account IS rather than how it is set up: its key, + * its sign-in name, its role, whether it is a managed record, + * and the limits the operator set for it. A restore puts data + * back into an account; it never changes which account that + * is or what the operator allowed it. + * `OPERATIONAL` — cursors, caches and timestamps this host wrote while + * working for the account. Carrying them would assert work + * the receiving host never did, the reason `IntegrationStatus` + * and `ProviderHealth` are excluded above. + * + * `src/__tests__/user-column-backup-classification.test.ts` reads the + * schema and fails on a column that is missing here, so a new column gets a + * decision rather than a default. + */ +export const USER_COLUMN_BACKUP_CLASS = { + // ── Identity ────────────────────────────────────────────────────────────── + id: "IDENTITY", + username: "IDENTITY", + email: "IDENTITY", + role: "IDENTITY", + createdAt: "IDENTITY", + updatedAt: "IDENTITY", + // Whether somebody else runs this record. Account shape, and the gate for + // the guardian's reach: the wipe keeps it for the same reason. + managedProfileAt: "IDENTITY", + // The operator's per-account policy and quota, set by the operator and not + // by the person; a file from Monday must not undo Tuesday's decision. + mfaEnforced: "IDENTITY", + documentQuotaBytes: "IDENTITY", + + // ── Credentials ─────────────────────────────────────────────────────────── + passwordHash: "CREDENTIAL", + oidcIssuer: "CREDENTIAL", + oidcSub: "CREDENTIAL", + totpSecretEncrypted: "CREDENTIAL", + totpConfirmedAt: "CREDENTIAL", + totpLastStep: "CREDENTIAL", + codexAccessTokenEncrypted: "CREDENTIAL", + codexRefreshTokenEncrypted: "CREDENTIAL", + codexTokenExpiresAt: "CREDENTIAL", + codexConnectedAt: "CREDENTIAL", + codexConnectionStatus: "CREDENTIAL", + aiAnthropicKeyEncrypted: "CREDENTIAL", + aiLocalKeyEncrypted: "CREDENTIAL", + aiOpenaiKeyEncrypted: "CREDENTIAL", + aiCompatKeyEncrypted: "CREDENTIAL", + // The bot token, the chat it is bound to, and the switch that only means + // something with both: the same reasoning as `NotificationChannel`. + telegramBotToken: "CREDENTIAL", + telegramChatId: "CREDENTIAL", + telegramEnabled: "CREDENTIAL", + withingsClientIdEncrypted: "CREDENTIAL", + withingsClientSecretEncrypted: "CREDENTIAL", + whoopClientIdEncrypted: "CREDENTIAL", + whoopClientSecretEncrypted: "CREDENTIAL", + fitbitClientIdEncrypted: "CREDENTIAL", + fitbitClientSecretEncrypted: "CREDENTIAL", + googleHealthClientIdEncrypted: "CREDENTIAL", + googleHealthClientSecretEncrypted: "CREDENTIAL", + // The address is stored sealed beside its token because it can carry one, + // and the private-host exemption is a security decision about that one + // connection, not a preference that should arrive ahead of it. + nightscoutUrlEncrypted: "CREDENTIAL", + nightscoutTokenEncrypted: "CREDENTIAL", + nightscoutAllowPrivateHost: "CREDENTIAL", + polarAccessTokenEncrypted: "CREDENTIAL", + polarUserIdEncrypted: "CREDENTIAL", + polarClientIdEncrypted: "CREDENTIAL", + polarClientSecretEncrypted: "CREDENTIAL", + ouraAccessTokenEncrypted: "CREDENTIAL", + ouraRefreshTokenEncrypted: "CREDENTIAL", + ouraClientIdEncrypted: "CREDENTIAL", + ouraClientSecretEncrypted: "CREDENTIAL", + stravaClientIdEncrypted: "CREDENTIAL", + stravaClientSecretEncrypted: "CREDENTIAL", + stravaAccessTokenEncrypted: "CREDENTIAL", + stravaRefreshTokenEncrypted: "CREDENTIAL", + stravaAthleteId: "CREDENTIAL", + + // ── Operational ─────────────────────────────────────────────────────────── + insightsCachedAt: "OPERATIONAL", + insightsCachedText: "OPERATIONAL", + insightsCachedLocale: "OPERATIONAL", + insightsSnapshotHash: "OPERATIONAL", + insightsWarmFailedAt: "OPERATIONAL", + insightsBriefingRerollDate: "OPERATIONAL", + morningDigestRefreshedOn: "OPERATIONAL", + coachLastSeenAt: "OPERATIONAL", + stravaLastActivityAt: "OPERATIONAL", + stravaBackfillCompletedAt: "OPERATIONAL", + healthKitLastSyncedAt: "OPERATIONAL", + healthKitLastSyncTrigger: "OPERATIONAL", + healthKitLastBackgroundSyncAt: "OPERATIONAL", + lastSyncedAt: "OPERATIONAL", + // The restore stamps its own: paired clients' cursors from before it must + // be refused, which a carried value would undo. + syncResetAt: "OPERATIONAL", + + // ── Settings ────────────────────────────────────────────────────────────── + // The body and the person, for the reference ranges, the score and the + // doctor's report. No other section carries them. + heightCm: "SETTING", + dateOfBirth: "SETTING", + gender: "SETTING", + hasDiabetes: "SETTING", + displayName: "SETTING", + fullName: "SETTING", + insurerName: "SETTING", + insuranceNumberEncrypted: "SETTING", + insurerIkNumber: "SETTING", + lastReportPracticeName: "SETTING", + avatarBytes: "SETTING", + avatarContentType: "SETTING", + avatarUpdatedAt: "SETTING", + // Home, which the environment history is resolved against. + homeLat: "SETTING", + homeLon: "SETTING", + homeLabel: "SETTING", + homeTimezone: "SETTING", + homeSince: "SETTING", + // How the interface reads. + timezone: "SETTING", + locale: "SETTING", + unitPreference: "SETTING", + glucoseUnit: "SETTING", + timeFormat: "SETTING", + dateFormat: "SETTING", + // What is switched on, what is shown where, and what counts as in range. + modulePreferencesJson: "SETTING", + thresholdsJson: "SETTING", + healthScoreConfigJson: "SETTING", + sourcePriorityJson: "SETTING", + dashboardWidgetsJson: "SETTING", + insightsLayoutJson: "SETTING", + medicationListLayoutJson: "SETTING", + moodTagLayoutJson: "SETTING", + reportSelectionJson: "SETTING", + globalExcludedInjectionSites: "SETTING", + healthKitConfigJson: "SETTING", + // Reminders and notifications, as preferences rather than channels. + notificationPrefs: "SETTING", + moodReminderEnabled: "SETTING", + // The AI choices, never the keys: which provider, which model, which + // order, how long to wait, what may be sent, and whether to use it at all. + // The keys stay behind, so a restored choice of provider reads as "not + // configured" until a key is entered, which is also how it reads after a + // key is removed in Settings. + aiProvider: "SETTING", + aiModel: "SETTING", + aiBaseUrl: "SETTING", + aiCompatBaseUrl: "SETTING", + aiCompatModel: "SETTING", + aiProviderChain: "SETTING", + aiResponseTimeoutSeconds: "SETTING", + useCentralCodex: "SETTING", + insightsPrivacyMode: "SETTING", + insightsExcludeMetrics: "SETTING", + disableCoach: "SETTING", + coachPrefsJson: "SETTING", + documentsAutoAiRead: "SETTING", + labsLocalOcrEnabled: "SETTING", + // Where the person is in the app's introductions. Without these a restored + // account is sent through setup and the tour again, and asked to accept the + // disclaimer it already accepted (a newer disclaimer still asks, because the + // version is compared). + onboardingCompletedAt: "SETTING", + onboardingTourCompleted: "SETTING", + onboardingTourProgressJson: "SETTING", + disclaimerAcknowledgedAt: "SETTING", + disclaimerAcknowledgedVersion: "SETTING", + passkeyUpgradeNudgeDismissed: "SETTING", +} as const satisfies Readonly< + Record +>; + +export type UserColumnBackupClass = + (typeof USER_COLUMN_BACKUP_CLASS)[keyof typeof USER_COLUMN_BACKUP_CLASS]; + +/** The `User` columns the `accountSettings` section carries. */ +export type AccountSettingColumn = { + [ + K in keyof typeof USER_COLUMN_BACKUP_CLASS + ]: (typeof USER_COLUMN_BACKUP_CLASS)[K] extends "SETTING" ? K : never; +}[keyof typeof USER_COLUMN_BACKUP_CLASS]; + export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; /**