Commit graph

73 commits

Author SHA1 Message Date
e781557694 Add /settings web editor for the encrypted addon config
A login → decrypt → edit → re-encrypt page so account settings (the
addon URLs + TMDB key) can be changed from any browser instead of typing
them on a TV. Doubles as the per-user config-update tool (editing a live
account's config, which the one-time onboarding template can't do).

- pb_public/settings.html: auth-with-password, load the owner's
  addon_config (rules already scope to profile.user), decrypt the blob
  in-browser with the password (PBKDF2→AES-GCM, byte-identical to the app
  + onboarding page), edit fields, re-encrypt and PATCH.
- Crucial: re-encrypt reusing the record's EXISTING salt, so the derived
  key is unchanged and an already-logged-in device (which cached that key
  and has no password on a restored session) decrypts the edited blob
  with no re-entry. A fresh salt would force a TV re-entry — the opposite
  of the point.
- 18+ fields (adult addon URL + enable toggle) render only for an account
  with nsfwEnabled; a clean account never sees them and their stored
  values are carried through untouched on save.
- pb_hooks/settings.pb.js: /settings → /settings.html redirect.

No server logic beyond the redirect — all crypto/IO is client-side against
the existing collection API. Crypto round-trip + salt-reuse + wrong-key
rejection verified against the app's format (WebCrypto, Node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:20:24 +02:00
2c1c4dde0b Merge pull request 'client_logs: background device diagnostics collection' (#8) from feature/client-logs-telemetry into main
Reviewed-on: #8
2026-07-23 11:19:42 +00:00
418f566603 Add client_logs collection for background device diagnostics (feedback 2 §4)
The family runs Amber on hardware I can't debug directly, so the app batches
a low-volume diagnostic stream here: uncaught errors, player error codes,
per-session playback summaries, and the audio-delay a user dials in to fix
desync (the "a track falls behind" signal).

- Migration 1788000000_client_logs.js: owner-create-only, superuser-read-only
  (list/view/update/delete = null); fields kind/event/message/meta/appVersion/
  platform/device/ts + created.
- client_logs.pb.js: nightly cron trims rows older than 14 days.
- docs/telemetry-contract.md: event catalogue, how I query it, and the
  privacy/redaction guarantees (host-only, never a token or full URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:49:04 +02:00
0e33a83d90 Merge pull request 'Family onboarding site: invite-gated signup, template config, /get/tv' (#7) from feature/onboarding-site into main
Reviewed-on: #7
2026-07-22 16:24:49 +00:00
0b65fd7f49 Family onboarding site: invite-gated signup, template config, /get/tv
Distribution site served by PocketBase itself (pb_public/, same origin):
- Landing (/): create-account CTA, TV sign-in steps, login-gated PC
  downloads via the existing update manifest + file tokens
- Wizard (/onboarding.html): invite code -> account -> named default
  profile -> family template fetched and encrypted IN THE BROWSER under
  the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with
  the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified)
  -> pushed as their addon_config ciphertext. Adult fields never included.
- /get/tv: public direct download of the latest clean Android APK
  (Downloader-friendly; adult builds stay account-gated)

Backend:
- onboarding_template collection (admin-only, maintained via admin UI)
- AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header
  or ?invite=) and the template route (per-IP rate limit 10/5min);
  fail-closed when unset. Note: this closes the previously-open in-app
  registration too.
- Dockerfile ships pb_public + --publicDir; compose passes the env var

Contract + Coolify steps (second domain amber.petruzalekr.cz) in
docs/onboarding-contract.md. Verified locally end-to-end against
pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:23:05 +02:00
a957cc59f8 Add /api/amber/set-pin: hidden pinHash rejects client writes (#13/#14)
Testing verify-pin against the live DB showed the correct PIN failing:
the server never held a hash at all. PocketBase silently drops client
PATCHes to hidden fields, so the app's best-effort `PATCH {pinHash}`
had never landed — the device-local verifier masked it on the PC. The
new owner-scoped route writes the hash from superuser context (which
can set hidden fields); pin "" clears it. Hash shape matches the app
and verify-pin: sha256("<profileId>:<pin>") hex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:29:02 +02:00
e96183bba3 Add /api/amber/verify-pin: server-side child-PIN check (#13/#14)
profiles.pinHash is hidden by design (a readable hash = offline brute
force of a 4-digit PIN), which left a device that never saw the PIN set
— the TV signing in after the parent set it on the PC — with nothing to
verify against: the gate always answered "wrong PIN" there. This route
compares the candidate PIN server-side and answers yes/no; the hash
never leaves the server, and failures are rate-limited (5 per 5 minutes
per account+profile, tracked in the shared app store — file-scope state
doesn't survive the per-handler JSVM isolation).

The app mirrors a successful verify into local secure storage, so each
device pays the round-trip once per profile and works offline after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:26:34 +02:00
cabb6233a3 Add profiles.maxRating for per-profile content tiers (#14)
Each profile may carry a maxRating token ("kids" | "twelve" | "fifteen" |
"unrestricted") capping which movies/TV it can browse and play. Empty means
"no explicit tier" — the client falls back to the account default (a child
profile resolves to "kids"). Plain text, opaque to the server: all filtering
is client-side like the kids filter, and a string column lets the app add
tiers without another migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:42:40 +02:00
9d442ee853 Merge pull request 'Auto-update backend (H1): releases collection + gated manifest' (#6) from feature/auto-update into main
Reviewed-on: #6
2026-07-20 11:50:31 +00:00
6be486b275 Fix auto-update file gating: mark releases.file protected (#16)
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.
2026-07-20 11:48:07 +02:00
785a621c49 Auto-update backend: releases collection + gated manifest (#16)
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/).
2026-07-20 11:36:03 +02:00
a1982fd760 Merge pull request 'Device approve page: manual code-entry for the QR-less path' (#5) from fix/device-approve-code-entry into main
Reviewed-on: #5
2026-07-20 09:08:52 +00:00
15be3bd5e5 device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.

Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).

Docs: note the optional ?code= + normalization in device-auth-contract.md.

Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 11:06:51 +02:00
3c8d60e614 Merge pull request 'fix(device-auth): inline helpers into each hook handler (JSVM scope)' (#4) from fix/device-auth-jsvm-scope into main
Reviewed-on: #4
2026-07-19 17:48:46 +00:00
0d29431734 fix(device-auth): inline helpers into each hook handler (JSVM scope)
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>
2026-07-19 19:48:03 +02:00
4cc5279842 Merge pull request 'feat: device-auth backend for codeless sign-in (app #12)' (#3) from feat/device-auth into main
Reviewed-on: #3
2026-07-19 17:34:35 +00:00
b98b80b33b feat(device-auth): codeless device sign-in backend (#12)
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>
2026-07-19 14:36:15 +02:00
ed25627f5f Merge pull request 'feat(schema): encrypted addon-config collection (issue #20)' (#2) from feature/addon-config-schema into main
Reviewed-on: richiexec/myanime-backend#2
2026-07-18 12:30:14 +00:00
f3084731a6 feat(schema): encrypted addon-config collection (issue #20)
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>
2026-07-18 02:14:40 +02:00
f9300ceec5 Merge pull request 'Schema: fields the client needs to sync watch state / watchlist / prefs' (#1) from feature/sync-fields into main
Reviewed-on: richiexec/myanime-backend#1
2026-07-17 16:40:27 +00:00
ac1c983ddd feat(schema): fields the client needs to sync watch state / watchlist / prefs
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
2026-07-17 16:20:38 +02:00
cf299e06dd fix: run PocketBase as root so it can write Coolify's persistent mount
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>
2026-07-15 21:08:12 +02:00
24d59c3f91 feat: PocketBase backend + account/profile schema (epic #6, issue #9)
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>
2026-07-15 20:22:45 +02:00