The E2E caught a real leak: a non-nsfw (even anonymous) account could download
an adult artifact. In PocketBase, file protection is a per-FIELD flag, not
derived from the collection view rule — the original migration left releases.file
unprotected, so its URL was public despite the gated read rules.
- Add protected:true to the file field (correct for fresh installs).
- 1786500001_releases_protect_file.js: alter the field on the already-deployed
instance (applied migrations don't re-run, so the fix needs its own migration).
- Doc: correct the gating explanation (protection is the field flag; the file
token grant then re-checks the view rule).
With this, a protected file needs a file token whose grant re-checks the view
rule, so a clean account is denied the adult artifact.
Adds the server side of auto-update (issue #16, was epic #6's [H]).
- pb_migrations/1786500000_releases.js: a superuser-only 'releases' collection
(platform, variant, version, buildNumber, file, sha256, size, notes). Read is
gated in the rules — any signed-in account sees clean builds; only an
nsfwEnabled account sees adult ones — so PocketBase's native protected-file
serving hands adult bytes only to flagged accounts (no custom streaming).
- pb_hooks/update.pb.js: GET /api/update/manifest?platform=… (auth). Picks the
variant server-side from the caller's nsfwEnabled (adult) vs clean — the client
can't request adult — and returns the latest build's version/buildNumber/notes/
sha256/size + the protected downloadPath.
- scripts/publish-release.sh: uploads a built artifact as a superuser (computes
sha256 + size, multipart POST). Token or email+password via env.
- docs/auto-update-contract.md: the collection, endpoint, download flow, gating.
Migration + hook + script syntax-checked. Live verification pends deploying this
to the PB (collection auto-applies on boot, hook loads from pb_hooks/).
PocketBase runs every routerAdd/cronAdd handler in its OWN isolated JSVM, so the
file-level helpers/consts (nowSeconds, genCode, CODE_ALPHABET, the TTLs, and the
DEVICE_APPROVE_HTML page) were invisible inside the callbacks — every route threw
`ReferenceError: <name> is not defined` at request time (POST /request, the
cleanup cron, GET /device). A runtime-only trap that only surfaces on a live
PocketBase, which the original PR couldn't exercise.
Define what each handler needs *inside* it (local scope works). No behavior
change; syntax-checked with `node --check`.
Fixes the deploy of device-auth (amber-app #12).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.
- device_auth collection (migration): code (unique), deviceName, status,
user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
Owner-scoped list/view/delete only (the device list + revoke); create/
update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
poll routes (the TV is unauthenticated), auth-gated approve (mints
e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
that the routes are served.
Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The #9 schema sketched these collections before the client existed. Reconciling
two devices turned up three gaps:
- `meta` (json) on watch_state/watchlist. Local rows carry display fields —
episode, title, cover art, MAL id, completed/dismissed — with nowhere to live
server-side, so a freshly-signed-in device pulled resume points it couldn't
render.
- A client-owned clock. `updatedAt`/`addedAt` were autodate, i.e. stamped on
server receipt, so a device that edited offline and pushed a day later beat a
device that edited afterwards and synced at once. LWW needs the time the user
acted. The server autodate lives on as `updated` and is now the pull cursor —
that one must be server-side, or a skewed client clock would write rows behind
another device's cursor and stay invisible to it.
- `deletedAt` tombstones on watchlist. Hard deletes are an absence, and an
absence isn't pullable — the next device to push just resurrects the title.
The type change means dropping and re-adding the fields, which drops their column
data, so the migration snapshots the old timestamps and writes them back. Live is
believed empty, but blanking `updated` would leave rows invisible to every future
pull, which is too quiet a failure to risk on an assumption.
Verified against a local docker compose on both paths — a fresh install, and an
upgrade over the init-only schema with rows already in it (timestamps preserved).
scripts/verify.py grows the field-shape assertions plus behavioural checks: the
client clock round-trips unrestamped, duplicate (profile,itemId) is rejected, and
delete/re-add reuses the row instead of duplicating. 36/36 pass.
Refs richiexec/myanime-app#11
Coolify attaches a root-owned volume/bind at /pb_data; the non-root pb user
couldn't write it, so SQLite failed with "unable to open database file (14)".
Drop the pb user and run as root. Verified against a root-owned bind mount
locally: server starts clean, migrations apply, data.db persists to the mount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for the user-accounts platform. Self-hosted PocketBase v0.39.6,
version-pinned from the official release binary, schema-as-code so the
collections auto-apply on boot and never drift from hand-clicking.
Collections: users (auth, admin-only nsfwEnabled), profiles, watch_state,
watchlist, prefs. Per-owner access rules traverse profile.user; nsfwEnabled
is never client-writable (superuser-only). Relations cascadeDelete; unique
indexes keep one resume/watchlist row per (profile,itemId) and one prefs per
profile.
Verified locally against v0.39.6: migration applies clean, and scripts/verify.py
proves two users can't read/write each other's data and can't set their own
nsfwEnabled (19/19 checks). Coolify deploy (domain/TLS, superuser, SMTP,
backups) documented in README as the manual half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>