diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 43b2d9c5c..32d3dd21d 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 129, - "total": 156466, + "total": 156231, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -68,7 +68,7 @@ "lib/dashboard/snapshot.ts": 1549, "lib/day/records.ts": 1175, "lib/doctor-report/collect.ts": 803, - "lib/export/backup-plan.ts": 1089, + "lib/export/backup-plan.ts": 854, "lib/export/coach-backup.ts": 1070, "lib/export/full-backup-payload.ts": 1520, "lib/export/profile-backup.ts": 1019, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index d54528c42..2ab4f6d3f 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2962, - "total": 10592, + "total": 10588, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2042,7 +2042,7 @@ "lib/export/__tests__/records-backup.test.ts": 2, "lib/export/__tests__/restore-backup-round-trip.test.ts": 1, "lib/export/account-settings-backup.ts": 2, - "lib/export/backup-plan.ts": 13, + "lib/export/backup-plan.ts": 9, "lib/export/coach-backup.ts": 13, "lib/export/full-backup-payload.ts": 8, "lib/export/intraday-profile-backup.ts": 1, diff --git a/src/__tests__/insights-cached-text-access-guard.test.ts b/src/__tests__/insights-cached-text-access-guard.test.ts index f3bd38ee4..68b95b00f 100644 --- a/src/__tests__/insights-cached-text-access-guard.test.ts +++ b/src/__tests__/insights-cached-text-access-guard.test.ts @@ -25,7 +25,7 @@ const SEALED_ALLOWED: Readonly> = { "lib/insights/cached-text-cipher.ts": "the one module that owns the column", "lib/crypto/encrypted-columns.ts": "the registry of every ciphertext-bearing column", - "lib/export/backup-plan.ts": + "lib/export/account-settings-classes.ts": "the backup classification names every User column", "lib/data-wipe/wipe-plan.ts": "the wipe reset names every User column", }; diff --git a/src/__tests__/user-column-backup-classification.test.ts b/src/__tests__/user-column-backup-classification.test.ts index d43d3cf26..274fc15bc 100644 --- a/src/__tests__/user-column-backup-classification.test.ts +++ b/src/__tests__/user-column-backup-classification.test.ts @@ -5,8 +5,10 @@ * 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 + * tomorrow gets a verdict in the account-settings domain — a codec in + * `src/lib/export/account-settings-backup.ts` makes it a setting, an entry in + * `src/lib/export/account-settings-classes.ts` gives it any other verdict — + * 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. * @@ -17,8 +19,11 @@ 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"; +import { + ACCOUNT_SETTING_COLUMNS, + USER_COLUMN_BACKUP_CLASS, +} from "@/lib/export/account-settings-backup"; +import { USER_COLUMNS_OUTSIDE_SETTINGS } from "@/lib/export/account-settings-classes"; const columns = parseWipeSchema().get("User")?.scalars ?? []; const classified = USER_COLUMN_BACKUP_CLASS as Readonly>; @@ -31,9 +36,9 @@ describe("User column backup classification", () => { 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", + "account-settings-classes.ts as CREDENTIAL, IDENTITY or " + + "OPERATIONAL, or give it a codec in account-settings-backup.ts, " + + "which makes it a SETTING", ).toEqual([]); }); @@ -42,6 +47,17 @@ describe("User column backup classification", () => { expect(Object.keys(classified).filter((c) => !known.has(c))).toEqual([]); }); + it("gives no column both a codec and another verdict", () => { + // The composed table spreads the settings over the other verdicts, so a + // column in both would silently travel as a setting. + expect(ACCOUNT_SETTING_COLUMNS.length).toBeGreaterThan(50); + expect( + ACCOUNT_SETTING_COLUMNS.filter((column) => + Object.hasOwn(USER_COLUMNS_OUTSIDE_SETTINGS, column), + ), + ).toEqual([]); + }); + it("carries exactly the columns classified as settings", () => { expect([...ACCOUNT_SETTING_COLUMNS].sort()).toEqual( Object.entries(classified) diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 6a0ad5f02..235827f93 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -55,9 +55,9 @@ import { import { decrypt, encrypt } from "@/lib/crypto"; import { readLocation, sealLocation } from "@/lib/environment/location-cipher"; import { - USER_COLUMN_BACKUP_CLASS, - type AccountSettingColumn, -} from "@/lib/export/backup-plan"; + USER_COLUMNS_OUTSIDE_SETTINGS, + type UserColumnBackupClass, +} from "@/lib/export/account-settings-classes"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { locales } from "@/lib/i18n/config"; import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; @@ -79,12 +79,7 @@ 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; +type SettingsRow = Record; /** What the restore knows about the account it writes into. */ export interface AccountSettingsRestoreContext { @@ -124,7 +119,7 @@ function has(entry: AccountSettingsBackupEntry, key: string): boolean { } /** Carried as it is: a string, number, boolean or list, or null. */ -function plain(column: AccountSettingColumn): SettingCodec { +function plain(column: string): SettingCodec { return { write: (row) => ({ [column]: row[column] }), read: (entry) => (has(entry, column) ? entry[column] : undefined), @@ -132,7 +127,7 @@ function plain(column: AccountSettingColumn): SettingCodec { } /** A timestamp, carried as ISO-8601. */ -function instant(column: AccountSettingColumn): SettingCodec { +function instant(column: string): SettingCodec { return { write: (row) => ({ [column]: @@ -154,7 +149,7 @@ function instant(column: AccountSettingColumn): SettingCodec { * 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 { +function json(column: string): SettingCodec { return { write: (row) => ({ [column]: row[column] ?? null }), read: (entry) => { @@ -167,7 +162,7 @@ function json(column: AccountSettingColumn): SettingCodec { /** A closed vocabulary. A value outside it is refused, not coerced. */ function oneOf( - column: AccountSettingColumn, + column: string, values: readonly string[], nullable: boolean, ): SettingCodec { @@ -197,10 +192,7 @@ function isTimeZone(value: unknown): value is string { } /** An IANA zone this runtime knows, the check the settings routes apply. */ -function timeZone( - column: AccountSettingColumn, - nullable: boolean, -): SettingCodec { +function timeZone(column: string, nullable: boolean): SettingCodec { return { write: (row) => ({ [column]: row[column] }), read: (entry) => { @@ -490,9 +482,15 @@ const homeLocation: SettingCodec = { }, }; -const ACCOUNT_SETTING_CODECS: Readonly< - Record -> = { +/** + * Every setting the account row carries, and how each one travels. The + * keys of this table ARE the `SETTING` verdicts of `USER_COLUMN_BACKUP_CLASS` + * below: a column becomes a setting by getting a codec here, and a codec + * cannot exist for anything else. + */ +const ACCOUNT_SETTING_CODECS = { + // The body and the person, for the reference ranges, the score and the + // doctor's report. No other section carries them. heightCm: plain("heightCm"), dateOfBirth: instant("dateOfBirth"), gender: oneOf("gender", ["MALE", "FEMALE", "OTHER"], true), @@ -506,10 +504,14 @@ const ACCOUNT_SETTING_CODECS: Readonly< avatarBytes: avatar, avatarContentType: ridesWithAvatar, avatarUpdatedAt: ridesWithAvatar, + // Home, which the environment history is resolved against. homeTimezone: timeZone("homeTimezone", true), homeSince: instant("homeSince"), + // The sealed home location (the only copy since v1.43), + // and the account's switch for the air-quality part of the module. homeLocationEncrypted: homeLocation, environmentAirQualityEnabled: plain("environmentAirQualityEnabled"), + // How the interface reads. timezone: timeZone("timezone", false), locale: oneOf("locale", locales, true), // The vocabularies the two preference routes accept, read from their own @@ -526,6 +528,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< ), timeFormat: oneOf("timeFormat", Object.values(TimeFormatPreference), false), dateFormat: oneOf("dateFormat", Object.values(DateFormatPreference), false), + // What is switched on, what is shown where, and what counts as in range. modulePreferencesJson: json("modulePreferencesJson"), thresholdsJson: thresholds, healthScoreConfigJson: json("healthScoreConfigJson"), @@ -541,8 +544,14 @@ const ACCOUNT_SETTING_CODECS: Readonly< reportSelectionJson: json("reportSelectionJson"), globalExcludedInjectionSites: excludedSites, healthKitConfigJson: json("healthKitConfigJson"), + // Reminders and notifications, as preferences rather than channels. notificationPrefs: json("notificationPrefs"), moodReminderEnabled: plain("moodReminderEnabled"), + // 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: plain("aiProvider"), aiModel: plain("aiModel"), aiBaseUrl: aiEndpoint("aiBaseUrl"), @@ -558,19 +567,53 @@ const ACCOUNT_SETTING_CODECS: Readonly< ), insightsExcludeMetrics: plain("insightsExcludeMetrics"), disableCoach: plain("disableCoach"), + // The account's own AI usage level; NULL follows the + // instance default, and a restored level above this host's ceiling is + // capped at read time like any stored choice. aiUsageLevel: oneOf("aiUsageLevel", Object.values(AiUsageLevel), true), coachPrefsJson: json("coachPrefsJson"), documentsAutoAiRead: plain("documentsAutoAiRead"), labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), storeWorkoutRoutes: plain("storeWorkoutRoutes"), + // 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: instant("onboardingCompletedAt"), onboardingTourCompleted: plain("onboardingTourCompleted"), onboardingTourProgressJson: json("onboardingTourProgressJson"), disclaimerAcknowledgedAt: instant("disclaimerAcknowledgedAt"), disclaimerAcknowledgedVersion: plain("disclaimerAcknowledgedVersion"), passkeyUpgradeNudgeDismissed: plain("passkeyUpgradeNudgeDismissed"), +} as const satisfies Readonly>; + +/** The `User` columns the `accountSettings` section carries. */ +export type AccountSettingColumn = keyof typeof ACCOUNT_SETTING_CODECS; + +/** + * Every column of the `User` row, and whether it travels: the settings from + * the codec table above, every other column from + * `account-settings-classes.ts`, which says what each verdict means. + * `src/__tests__/user-column-backup-classification.test.ts` reads the schema + * and fails on a column that has neither. + */ +export const USER_COLUMN_BACKUP_CLASS: Readonly< + Record< + keyof typeof USER_COLUMNS_OUTSIDE_SETTINGS | AccountSettingColumn, + UserColumnBackupClass + > +> = { + ...USER_COLUMNS_OUTSIDE_SETTINGS, + ...(Object.fromEntries( + Object.keys(ACCOUNT_SETTING_CODECS).map((column) => [column, "SETTING"]), + ) as Record), }; +/** Every setting column, in the order the codec table declares them. */ +export const ACCOUNT_SETTING_COLUMNS = Object.keys( + ACCOUNT_SETTING_CODECS, +) as AccountSettingColumn[]; + /** Exactly the setting columns, so the read is the declaration. */ const SETTINGS_SELECT = Object.fromEntries( ACCOUNT_SETTING_COLUMNS.map((column) => [column, true]), @@ -661,68 +704,3 @@ export function admitAccountSettings( 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/account-settings-classes.ts b/src/lib/export/account-settings-classes.ts new file mode 100644 index 000000000..0e3064b4e --- /dev/null +++ b/src/lib/export/account-settings-classes.ts @@ -0,0 +1,156 @@ +/** + * The verdict for every `User` column that is not a setting. + * + * Part of the account-settings domain: `account-settings-backup.ts` composes + * these with the settings its codec table declares into + * `USER_COLUMN_BACKUP_CLASS`. + */ +/** + * Every column of the `User` row, and whether it travels. + * + * The models (`backup-plan.ts`) 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. A setting is a column with a + * codec in `ACCOUNT_SETTING_CODECS` + * (`account-settings-backup.ts`), which is where its verdict + * comes from; this file holds every other verdict. + * `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` in the backup plan. + * `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 from the backup. + * + * `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 type UserColumnBackupClass = + "SETTING" | "CREDENTIAL" | "IDENTITY" | "OPERATIONAL"; + +/** The `User` columns that are not settings, each with its verdict. */ +export const USER_COLUMNS_OUTSIDE_SETTINGS = { + // ── 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", + // The Dawarich connection, credentials like the Nightscout pair. The + // sealed preferences name a place id on that instance, which means nothing + // without the key, so they stay behind with it. + dawarichUrlEncrypted: "CREDENTIAL", + dawarichApiKeyEncrypted: "CREDENTIAL", + dawarichPrefsEncrypted: "CREDENTIAL", + dawarichAllowPrivateHost: "CREDENTIAL", + polarAccessTokenEncrypted: "CREDENTIAL", + polarUserIdEncrypted: "CREDENTIAL", + polarClientIdEncrypted: "CREDENTIAL", + polarClientSecretEncrypted: "CREDENTIAL", + ouraAccessTokenEncrypted: "CREDENTIAL", + ouraRefreshTokenEncrypted: "CREDENTIAL", + ouraClientIdEncrypted: "CREDENTIAL", + ouraClientSecretEncrypted: "CREDENTIAL", + // What the Oura grant on this host does not cover; meaningless beside a + // grant that does not travel, and found out again by the first sync. + ouraScopeDenied: "CREDENTIAL", + ouraUserId: "CREDENTIAL", + // Names the guardian whose provider app the record borrows; the app itself + // does not travel, so neither does the marker. + clientAppSharedBy: "CREDENTIAL", + stravaClientIdEncrypted: "CREDENTIAL", + stravaClientSecretEncrypted: "CREDENTIAL", + stravaAccessTokenEncrypted: "CREDENTIAL", + stravaRefreshTokenEncrypted: "CREDENTIAL", + stravaAthleteId: "CREDENTIAL", + + // ── Operational ─────────────────────────────────────────────────────────── + insightsCachedAt: "OPERATIONAL", + insightsCachedTextEncrypted: "OPERATIONAL", + insightsCachedLocale: "OPERATIONAL", + insightsSnapshotHash: "OPERATIONAL", + insightsWarmFailedAt: "OPERATIONAL", + insightsBriefingRerollDate: "OPERATIONAL", + morningDigestRefreshedOn: "OPERATIONAL", + coachLastSeenAt: "OPERATIONAL", + stravaLastActivityAt: "OPERATIONAL", + stravaBackfillCompletedAt: "OPERATIONAL", + stravaBackfillResumeBefore: "OPERATIONAL", + // The restored account's Oura backfill reads its history again from the + // provider; a carried note would claim history this host never imported. + ouraBackfillProgress: "OPERATIONAL", + ouraBackfillCompletedAt: "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", + // The air-quality history backfill's progress; the restored + // account's job recounts it on its next run. + environmentAqHistoryJson: "OPERATIONAL", +} as const satisfies Readonly< + Record> +>; diff --git a/src/lib/export/account-settings-restore.ts b/src/lib/export/account-settings-restore.ts new file mode 100644 index 000000000..b98b3e688 --- /dev/null +++ b/src/lib/export/account-settings-restore.ts @@ -0,0 +1,75 @@ +/** + * The restore half of the account-settings domain: write what + * `admitAccountSettings` admits onto the account row, inside the restore's + * transaction. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { + admitAccountSettings, + type AccountSettingsBackupEntry, +} from "@/lib/export/account-settings-backup"; + +/** 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 86dca6347..b981fea4c 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -836,241 +836,6 @@ 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", - // v1.43 — the Dawarich connection, credentials like the Nightscout pair. The - // sealed preferences name a place id on that instance, which means nothing - // without the key, so they stay behind with it. - dawarichUrlEncrypted: "CREDENTIAL", - dawarichApiKeyEncrypted: "CREDENTIAL", - dawarichPrefsEncrypted: "CREDENTIAL", - dawarichAllowPrivateHost: "CREDENTIAL", - polarAccessTokenEncrypted: "CREDENTIAL", - polarUserIdEncrypted: "CREDENTIAL", - polarClientIdEncrypted: "CREDENTIAL", - polarClientSecretEncrypted: "CREDENTIAL", - ouraAccessTokenEncrypted: "CREDENTIAL", - ouraRefreshTokenEncrypted: "CREDENTIAL", - ouraClientIdEncrypted: "CREDENTIAL", - ouraClientSecretEncrypted: "CREDENTIAL", - // What the Oura grant on this host does not cover; meaningless beside a - // grant that does not travel, and found out again by the first sync. - ouraScopeDenied: "CREDENTIAL", - ouraUserId: "CREDENTIAL", - // Names the guardian whose provider app the record borrows; the app itself - // does not travel, so neither does the marker. - clientAppSharedBy: "CREDENTIAL", - stravaClientIdEncrypted: "CREDENTIAL", - stravaClientSecretEncrypted: "CREDENTIAL", - stravaAccessTokenEncrypted: "CREDENTIAL", - stravaRefreshTokenEncrypted: "CREDENTIAL", - stravaAthleteId: "CREDENTIAL", - - // ── Operational ─────────────────────────────────────────────────────────── - insightsCachedAt: "OPERATIONAL", - insightsCachedTextEncrypted: "OPERATIONAL", - insightsCachedLocale: "OPERATIONAL", - insightsSnapshotHash: "OPERATIONAL", - insightsWarmFailedAt: "OPERATIONAL", - insightsBriefingRerollDate: "OPERATIONAL", - morningDigestRefreshedOn: "OPERATIONAL", - coachLastSeenAt: "OPERATIONAL", - stravaLastActivityAt: "OPERATIONAL", - stravaBackfillCompletedAt: "OPERATIONAL", - stravaBackfillResumeBefore: "OPERATIONAL", - // The restored account's Oura backfill reads its history again from the - // provider; a carried note would claim history this host never imported. - ouraBackfillProgress: "OPERATIONAL", - ouraBackfillCompletedAt: "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", - // v1.42 — the air-quality history backfill's progress; the restored - // account's job recounts it on its next run. - environmentAqHistoryJson: "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. - homeTimezone: "SETTING", - homeSince: "SETTING", - // v1.42 (#615) — the sealed home location (the only copy since v1.43), - // and the account's switch for the air-quality part of the module. - homeLocationEncrypted: "SETTING", - environmentAirQualityEnabled: "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", - documentsLayoutJson: "SETTING", - moduleListLayoutJson: "SETTING", - previewFeaturesJson: "SETTING", - activityGoalsJson: "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", - // v1.43 (#1217) — the account's own AI usage level; NULL follows the - // instance default, and a restored level above this host's ceiling is - // capped at read time like any stored choice. - aiUsageLevel: "SETTING", - coachPrefsJson: "SETTING", - documentsAutoAiRead: "SETTING", - labsLocalOcrEnabled: "SETTING", - storeWorkoutRoutes: "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 ->; - -/** 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"; /** diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 81c0e0c34..5531f32ce 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -100,7 +100,7 @@ import { restoreOnboardingData } from "@/lib/export/onboarding-backup"; import { restoreAccountSettings, type AccountSettingsRestoreResult, -} from "@/lib/export/account-settings-backup"; +} from "@/lib/export/account-settings-restore"; import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 9a0c59c51..fcd577d42 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -74,12 +74,11 @@ import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids"; import { createBackupPreviewCollector } from "@/lib/export/backup-preview"; import { parseBackupPayload } from "@/lib/validations/backup"; import { summarizeBackup } from "@/lib/validations/backup-summary"; +import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; import { - TWO_ENDED_MODELS, + ACCOUNT_SETTING_COLUMNS, USER_COLUMN_BACKUP_CLASS, - type TwoEndedModel, -} from "@/lib/export/backup-plan"; -import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup"; +} from "@/lib/export/account-settings-backup"; import { parseOnboardingSteps } from "@/lib/onboarding/needs"; import { POST } from "./restore-job-driver"; import { diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index d89a6a4e2..c3a5f55f4 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -4,8 +4,8 @@ * restored into a fresh account, and read back there. * * The table below has one entry per write, and every `User` column the backup - * classifies as a `SETTING` (`USER_COLUMN_BACKUP_CLASS` in - * `src/lib/export/backup-plan.ts`) appears in exactly one entry. A new setting + * classifies as a `SETTING` (a codec in `ACCOUNT_SETTING_CODECS`, + * `src/lib/export/account-settings-backup.ts`) appears in exactly one entry. A new setting * column fails the inventory case until it is given an entry, which is the * point: the class of bug this file exists for is a setting that is stored but * not read back, read back but not published, or published but dropped by the @@ -29,7 +29,7 @@ import type { User } from "@/generated/prisma/client"; import { USER_COLUMN_BACKUP_CLASS, type AccountSettingColumn, -} from "@/lib/export/backup-plan"; +} from "@/lib/export/account-settings-backup"; import { GET as getMe } from "@/app/api/auth/me/route"; import * as profile from "@/app/api/user/profile/route";