/// // Encrypted addon-config sync (epic #6, issue #20). // // #11 syncs watchlist / resume / prefs so a fresh sign-in restores your list and // progress — but not the *addons*, so a fresh TV would have nothing to play from. // The obvious fix is "sync the addon config too", except the addon "tokens" are // base64'd **plaintext credentials** (prehraj.to login, TorBox API key) plus the // TMDB key. The account is deliberately not E2E — an easy call for watch history, // the wrong one for passwords — so this one blob is encrypted **client-side** // before it ever reaches here. // // This collection therefore holds ciphertext the server can't read: // - `blob` — AES-GCM ciphertext (nonce ‖ ciphertext ‖ tag), base64. Opaque. // - `salt` — per-account KDF salt, base64. NOT secret (a salt never is); it // lives here so a second device can derive the same key from the // user's password. The key itself never leaves the device. // - `kdf` — key-derivation descriptor (e.g. "pbkdf2-sha256-210000") so the // params can move forward without guessing how an old blob was made. // // Same two-clock model as the #11 collections: client-set `updatedAt` resolves // last-write-wins; the server autodate `updated` is the pull cursor. One row per // profile (unique index), owned via `profile.user = auth.id` like everything else. migrate((app) => { const profiles = app.findCollectionByNameOrId("profiles") const addonConfig = new Collection({ type: "base", name: "addon_config", // Rows belong to a profile; ownership traverses profile.user, exactly as the // watch_state / watchlist / prefs collections do. listRule: "@request.auth.id != '' && profile.user = @request.auth.id", viewRule: "@request.auth.id != '' && profile.user = @request.auth.id", createRule: "@request.auth.id != '' && profile.user = @request.auth.id", updateRule: "@request.auth.id != '' && profile.user = @request.auth.id", deleteRule: "@request.auth.id != '' && profile.user = @request.auth.id", fields: [ { type: "relation", name: "profile", required: true, collectionId: profiles.id, cascadeDelete: true, maxSelect: 1, minSelect: 0, }, // Ciphertext only. Small in practice (a few URLs + keys), but leave room. { type: "text", name: "blob", required: true, max: 100000 }, { type: "text", name: "salt", required: true, max: 200 }, { type: "text", name: "kdf", required: true, max: 100 }, // Client-set clock: when the config was actually changed (last-write-wins). { type: "date", name: "updatedAt", required: true }, // Server autodate: the pull cursor (monotonic in server time). { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, ], indexes: [ "CREATE UNIQUE INDEX `idx_addon_config_profile` ON `addon_config` (`profile`)", ], }) app.save(addonConfig) }, (app) => { // ---- Down migration ---------------------------------------------------- try { app.delete(app.findCollectionByNameOrId("addon_config")) } catch (_) { /* already gone */ } })