Adds the addon_config collection so a logged-in user's addon configuration (TorBox key, Czech-dub creds, adult addon, TMDB key) can follow their account to a fresh device — encrypted client-side, so the server only ever holds ciphertext. - pb_migrations/1785200000_addon_config.js: one row per profile (unique index), OWNS access rules like the other per-profile collections. Stores blob (AES-GCM ciphertext), salt (per-account KDF salt; not secret), kdf (derivation descriptor), plus the same two-clock model as #11 (client updatedAt for LWW, server updated as the pull cursor). - README.md: data model + a section on why this one blob is encrypted. - scripts/verify.py: schema assertions + ciphertext round-trip + cross-user isolation checks for addon_config. Stacks on the #11 sync-fields backend work (PR #1). Client half is myanime-app's encrypted addon-config sync PR (issue #20). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
67 lines
3.2 KiB
JavaScript
67 lines
3.2 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
|
|
// 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 */ }
|
|
})
|