Compare commits
2 commits
f9300ceec5
...
ed25627f5f
| Author | SHA1 | Date | |
|---|---|---|---|
| ed25627f5f | |||
| f3084731a6 |
3 changed files with 132 additions and 9 deletions
27
README.md
27
README.md
|
|
@ -21,17 +21,19 @@ profiles, recommendations, and gated auto-update (epic
|
||||||
```
|
```
|
||||||
users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per profile+item)
|
users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per profile+item)
|
||||||
(auth)│ ├─* watchlist (1 row per profile+item)
|
(auth)│ ├─* watchlist (1 row per profile+item)
|
||||||
│ └─1 prefs (playback/audio/subtitle blob)
|
│ ├─1 prefs (playback/audio/subtitle blob)
|
||||||
|
│ └─1 addon_config (client-encrypted addon blob; ciphertext only)
|
||||||
└ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16)
|
└ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Collection | Type | Key fields |
|
| Collection | Type | Key fields |
|
||||||
|---------------|------|------------|
|
|----------------|------|------------|
|
||||||
| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) |
|
| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) |
|
||||||
| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) |
|
| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) |
|
||||||
| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) |
|
| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) |
|
||||||
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) |
|
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) |
|
||||||
| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) |
|
| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) |
|
||||||
|
| `addon_config` | base | `profile`→profiles, `blob` (**ciphertext**), `salt`, `kdf`, `updatedAt` (client), `updated` (server) |
|
||||||
|
|
||||||
**Access rules.** A user only ever reads/writes their own `users` row, their own
|
**Access rules.** A user only ever reads/writes their own `users` row, their own
|
||||||
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
|
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
|
||||||
|
|
@ -39,7 +41,26 @@ accepted from a client (`@request.body.nsfwEnabled:isset = false` on create and
|
||||||
update) — only a **superuser** sets it, from the admin UI. Relations
|
update) — only a **superuser** sets it, from the admin UI. Relations
|
||||||
`cascadeDelete`, so deleting a user removes their profiles and all child rows.
|
`cascadeDelete`, so deleting a user removes their profiles and all child rows.
|
||||||
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
||||||
`prefs` row per profile.
|
`prefs`/`addon_config` row per profile.
|
||||||
|
|
||||||
|
### Encrypted addon config (issue #20)
|
||||||
|
|
||||||
|
The `addon_config` blob is the one piece of account data that is **not** readable
|
||||||
|
by the server. The addon "tokens" are base64'd *plaintext credentials* (prehraj.to
|
||||||
|
login, TorBox API key) plus the TMDB key, so — unlike watch history — they are
|
||||||
|
encrypted **client-side** before upload. The server stores only:
|
||||||
|
|
||||||
|
- `blob` — AES-GCM ciphertext (nonce ‖ ciphertext ‖ tag), base64; opaque here.
|
||||||
|
- `salt` — the per-account KDF salt (base64). Not secret; a salt never is. It
|
||||||
|
lives server-side so a second device can derive the **same** key from the
|
||||||
|
user's password. The key itself is never sent and never leaves the device.
|
||||||
|
- `kdf` — the key-derivation descriptor (e.g. `pbkdf2-sha256-210000`).
|
||||||
|
|
||||||
|
Because the key is derived from the password, a **password reset** (done on
|
||||||
|
PocketBase's hosted page, off-device) leaves the blob undecryptable — expected and
|
||||||
|
acceptable for re-enterable addon config, and the client detects it, clears it, and
|
||||||
|
prompts a re-entry. A normal in-app *change password* re-encrypts the blob, so only
|
||||||
|
reset loses it. This does **not** change the no-E2E stance for the rest of the data.
|
||||||
|
|
||||||
### The two clocks (issue #11)
|
### The two clocks (issue #11)
|
||||||
|
|
||||||
|
|
|
||||||
67
pb_migrations/1785200000_addon_config.js
Normal file
67
pb_migrations/1785200000_addon_config.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/// <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 */ }
|
||||||
|
})
|
||||||
|
|
@ -88,6 +88,23 @@ st, pf = req("GET", "/api/collections/prefs", admin)
|
||||||
check("prefs.updatedAt is a client-set date", st,
|
check("prefs.updatedAt is a client-set date", st,
|
||||||
(field(pf, "updatedAt") or {}).get("type") == "date")
|
(field(pf, "updatedAt") or {}).get("type") == "date")
|
||||||
|
|
||||||
|
# --- encrypted addon config (issue #20) ---
|
||||||
|
st, ac = req("GET", "/api/collections/addon_config", admin)
|
||||||
|
rules_owned = all("@request.auth.id" in (ac.get(k) or "") for k in
|
||||||
|
("listRule","viewRule","createRule","updateRule","deleteRule"))
|
||||||
|
check("addon_config exists with per-owner rules on all 5 verbs", st, rules_owned)
|
||||||
|
check("addon_config.blob is a required text field (ciphertext)", st,
|
||||||
|
(field(ac, "blob") or {}).get("type") == "text" and (field(ac, "blob") or {}).get("required"))
|
||||||
|
check("addon_config has salt + kdf text fields", st,
|
||||||
|
(field(ac, "salt") or {}).get("type") == "text" and
|
||||||
|
(field(ac, "kdf") or {}).get("type") == "text")
|
||||||
|
check("addon_config.updatedAt is a client-set date", st,
|
||||||
|
(field(ac, "updatedAt") or {}).get("type") == "date")
|
||||||
|
check("addon_config.updated is a server autodate (pull cursor)", st,
|
||||||
|
(field(ac, "updated") or {}).get("type") == "autodate")
|
||||||
|
check("addon_config has unique (profile) index", st,
|
||||||
|
any("idx_addon_config_profile" in i for i in ac.get("indexes", [])))
|
||||||
|
|
||||||
# --- create two users (superuser create; nsfwEnabled set by admin) ---
|
# --- create two users (superuser create; nsfwEnabled set by admin) ---
|
||||||
def make_user(email, uname):
|
def make_user(email, uname):
|
||||||
st, r = req("POST", "/api/collections/users/records", admin, {
|
st, r = req("POST", "/api/collections/users/records", admin, {
|
||||||
|
|
@ -153,6 +170,20 @@ st, r = req("GET", "/api/collections/watchlist/records", alice)
|
||||||
check("exactly one watchlist row after delete+re-add", st,
|
check("exactly one watchlist row after delete+re-add", st,
|
||||||
st == 200 and r.get("totalItems") == 1)
|
st == 200 and r.get("totalItems") == 1)
|
||||||
|
|
||||||
|
# --- addon_config: store ciphertext + round-trip the client clock -----------
|
||||||
|
st, acrow = req("POST", "/api/collections/addon_config/records", alice,
|
||||||
|
{"profile": pa["id"], "blob": "bm9uY2VjaXBoZXJ0YWc=",
|
||||||
|
"salt": "c29tZXNhbHQ=", "kdf": "pbkdf2-sha256-210000",
|
||||||
|
"updatedAt": CLIENT_TS})
|
||||||
|
check("alice stores an encrypted addon_config blob", st, st == 200)
|
||||||
|
check("addon_config.updatedAt round-trips the client's value", st,
|
||||||
|
acrow.get("updatedAt", "").startswith("2026-01-02 03:04:05"))
|
||||||
|
# One row per profile — a second create must collide, not duplicate.
|
||||||
|
st, dup = req("POST", "/api/collections/addon_config/records", alice,
|
||||||
|
{"profile": pa["id"], "blob": "b3RoZXI=", "salt": "c2FsdA==",
|
||||||
|
"kdf": "pbkdf2-sha256-210000", "updatedAt": CLIENT_TS})
|
||||||
|
check("duplicate addon_config for a profile is rejected", st, st == 400)
|
||||||
|
|
||||||
# --- ISOLATION: bob must not see or touch alice's data ---
|
# --- ISOLATION: bob must not see or touch alice's data ---
|
||||||
st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob)
|
st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob)
|
||||||
check("bob CANNOT view alice's profile (404)", st, st == 404)
|
check("bob CANNOT view alice's profile (404)", st, st == 404)
|
||||||
|
|
@ -161,6 +192,10 @@ check("bob's watch_state list is empty", st, st == 200 and r.get("totalItems") =
|
||||||
st, r = req("PATCH", f"/api/collections/watch_state/records/{wsrow['id']}", bob,
|
st, r = req("PATCH", f"/api/collections/watch_state/records/{wsrow['id']}", bob,
|
||||||
{"position": 999})
|
{"position": 999})
|
||||||
check("bob CANNOT update alice's watch_state (404)", st, st == 404)
|
check("bob CANNOT update alice's watch_state (404)", st, st == 404)
|
||||||
|
st, r = req("GET", "/api/collections/addon_config/records", bob)
|
||||||
|
check("bob's addon_config list is empty", st, st == 200 and r.get("totalItems") == 0)
|
||||||
|
st, r = req("GET", f"/api/collections/addon_config/records/{acrow['id']}", bob)
|
||||||
|
check("bob CANNOT view alice's addon_config blob (404)", st, st == 404)
|
||||||
|
|
||||||
# --- bob cannot create a profile owned by alice ---
|
# --- bob cannot create a profile owned by alice ---
|
||||||
st, r = req("POST", "/api/collections/profiles/records", bob,
|
st, r = req("POST", "/api/collections/profiles/records", bob,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue