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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 13:04:36 +02:00
1 parent 28054e69c2
commit d731a1b8fe
4 files changed
+1156

No files matched your search

@@ -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<Record<string, string>>;
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([]);
});
});
@@ -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<string, unknown> = {}) {
const row: Record<string, unknown> = Object.fromEntries(
ACCOUNT_SETTING_COLUMNS.map((column) => [column, null]),
);
return { ...row, ...overrides };
}
function prismaReturning(row: Record<string, unknown> | 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<string>([
...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("<svg onload=alert(1)>....").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 });
});
});
+613
View File
@@ -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<string, unknown>;
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<AccountSettingColumn, unknown>;
/** 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<string, unknown>;
/**
* 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<string, unknown> = {};
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<AccountSettingColumn, SettingCodec>
> = {
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<AccountSettingColumn, true>;
/**
* 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<PrismaClient, "user">,
userId: string,
options: AccountSettingsBackupOptions = {},
): Promise<AccountSettingsBackupSection> {
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<Record<AccountSettingColumn, unknown>>;
/** 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<Record<AccountSettingColumn, unknown>> = {};
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<AccountSettingsRestoreResult> {
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),
};
}
+206
View File
@@ -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<string, "SETTING" | "CREDENTIAL" | "IDENTITY" | "OPERATIONAL">
>;
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";
/**