Files
HealthLog/src/lib/export/account-settings-classes.ts
T
Marc-André Bombeck dcf49dd14e refactor(backup): let the account-settings domain own the User column verdicts
The SETTING verdicts are now the keys of the codec table, so a column
becomes a setting by getting a codec and nothing else has to name it. The
verdicts for every other User column move beside it into
account-settings-classes.ts, and the restore half into
account-settings-restore.ts. The backup plan keeps the model verdicts.

The classification guard now also refuses a column that has both a codec
and another verdict, since the composed table would quietly carry it as a
setting.
2026-10-22 06:50:58 +02:00

157 lines
7.7 KiB
TypeScript

/**
* 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<string, Exclude<UserColumnBackupClass, "SETTING">>
>;