Status: frozen baseline as of app v15,
SCHEMA_VERSION = 1. This documents the on-device data shape so future changes stay additive andrunMigrations()(app.js) has a known starting point. Do not change a v1 field's meaning or type — add new fields / a newtype, bumpSCHEMA_VERSION, and write an ordered migration instead. Every claim here was read fromapp.js(not recalled).
- IndexedDB database:
protocol-tracker, version2(app.js:6,11). - Object stores (created in
onupgradeneeded,app.js:14-15):records— keyPathid. All user data (PHI) lives here, encrypted at rest.meta— keyPathk. Small unencrypted control rows (auth params, schema version).
- KDF: PBKDF2-SHA-256,
KDF_ITERS = 200000iterations (app.js:40,45). - Cipher: AES-GCM-256, random 12-byte IV per encryption (
app.js:46,50). - The passphrase is never stored.
meta.authholds only:salt(b64, 16 bytes),iters(the KDF iteration count used — read back on unlock so params can evolve without bricking existing data), and averifier= the constant tokenprotocol-tracker-verify-v1encrypted under the derived key (app.js:64-74). - Unlock re-derives the key and decrypts the verifier; match ⇒ correct passphrase
(
app.js:78-87).unlock()honors the storediters(meta.iters || KDF_ITERS), not the current constant. - Session key lives in memory only (
sessionKey); lost on close or Lock. No recovery. The encrypted backup/restore (below) is the only safety net.
Every row is stored encrypted and looks like (app.js:95-98):
{ id: <string, plaintext>, enc: 1, iv: <b64>, ct: <b64> }
idstays plaintext (it is a random uuid or a derived key likelog-YYYY-MM-DD, never PHI) so rows can beput/deleted by key.- All other fields live inside the AES-GCM ciphertext
ct. Decrypted, a record has atypediscriminator and the per-type fields below.
type |
id |
Fields (verified from writers) |
|---|---|---|
profile |
'profile' (singleton) |
name, weight, weightUnit ('kg' default), condition (app.js:246-249) |
supp |
uid() |
suppId/catalog ref, name, form, dose, unit, dayParts[], timingKey (default 'with-food') (app.js:364-371) |
log |
'log-YYYY-MM-DD' |
date, t0 (Start-my-day epoch ms, null until started), planned (dose count at T0 = adherence denominator), taken map (app.js:189,422-424) |
symptom |
uid() |
date, ts (ISO), label, severity (1–10 Number), note (app.js:437) |
water |
uid() |
date, ts (ISO), ml (app.js:459) |
meal |
uid() |
date, ts (ISO), kind, note (app.js:478) |
log.takenis a map keyed"<suppId>::<daypart>"⇒true(app.js:568,956). A dose is "taken" iff itssuppId::daypartkey is present.log.plannedis set once, at Start-my-day, to the number of scheduled dose-items that day (app.js:424); it is the fixed denominator for adherence history.
k |
Payload |
|---|---|
'auth' |
{ k:'auth', v:1, salt, iters, verifier:{iv,ct} } (app.js:74) |
'schema' |
{ k:'schema', v: SCHEMA_VERSION } — current on-disk schema version (app.js:203,209-210) |
encryptBackup() / decryptBackup() (app.js:124-137) produce a self-describing blob
with its own passphrase (independent of the unlock passphrase):
{
app: 'protocol-tracker',
v: 1,
kdf: { name: 'PBKDF2', hash: 'SHA-256', iters: 200000 },
salt: <b64, 16 bytes>,
iv: <b64>,
ciphertext: <b64>, // AES-GCM of the full records array
createdAt: <ISO string>
}
- Restore validates
blob.app === 'protocol-tracker'before touching anything, and derives with the blob's ownkdf.iters(falls back toKDF_ITERS) — so a backup taken under one iteration count still restores after the constant changes.
- Never repurpose a v1 field. Add new fields / a new
typeonly. - Bump
SCHEMA_VERSIONinapp.js. - Add an ordered
cur -> SCHEMA_VERSIONstep insiderunMigrations()— it runs after every unlock, behind decryption, and must be non-destructive (app.js:200-211). - Add a harness check for the migration in
test/harness.mjs.