Nothing read `users.nsfwEnabled` any more. `/api/amber/whoami` stopped returning
it, `GET /api/update/manifest` always answers `clean`, and the `releases` read
rule stopped mentioning it in migration 1793000000, when the nine 18+ rows (437
MB) were deleted. What was left was a per-account flag nobody writes and nobody
reads, sitting on the family's accounts and showing up in every schema dump and
access-rule review as if it still gated something. A flag that looks like a
protection but protects nothing costs the next reader the work of proving it dead.
One live reader did remain: pb_public/index.html showed a "Zdroj 18+" source
field and wrote `adultAddonUrl` into the encrypted addon config for a flagged
account, and picked a different child-profile label for one. Both are gone, so
the page offers the same three sources to everyone.
Migration 1797000000 drops the field, rewrites users.updateRule to
`id = @request.auth.id` (the clause it loses guarded a field that no longer
exists), and removes `adult` from releases.variant's allowed values. Checked
before writing it: 15 release rows, every one `clean`; three accounts carried the
flag (the owner's, one family member's, the retired test account); the separate
18+ app has no accounts and no PocketBase, so nothing outside Amber reads it.
`variant` itself stays and stays required: every installed Amber compares it
against its own build and refuses a mismatch SILENTLY, so dropping the field
would strand those installs with no error to see. publish-release.sh now accepts
only `clean`.
The status dashboard stops listing `adult-stremio-addon` and `stripchat-proxy`.
Both still run on the VPS; they are the owner's, not Amber's, and Amber never
reached them.
scripts/verify.py asserts the flag stays gone (field absent, no rule mentions it,
updateRule own-record-only). Run against a local PocketBase built from origin/main
it failed on exactly those three checks; with this migration applied, ALL CHECKS
PASSED.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deploy docs written earlier today said a push to amber-api or amber-backend
deploys nothing, from a 2026-09-13 reading of Coolify records where every deploy
of both is is_api=true. Measured against commit times, every one of those
deploys started 2 to 4 s after its commit, including two README-only pushes on
2026-09-15 that nobody deployed by hand (21:02:14 -> 21:02:18, 21:02:17 ->
21:02:20). A push reaches Coolify through its deploy API, so the record says
is_api. The hook config itself was not inspected (no Forgejo API token).
Corrected the README deploy section to say a push deploys within seconds, and
that a manual deploy is only for a push no deployment followed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Retitled from myanime-backend. The Forgejo repo is amber-backend now; the
old name redirects, and Coolify's source URL still uses it.
- The deploy section said to push the repo to GitHub. It now describes the
real Coolify app (uuid, domains, volume), says a push does not deploy it
(every recorded deploy is an explicit call), gives the Deploy button and
the API call, and the live checks (/api/health 200, /status 302). The
first-time setup steps stay, with the git source step fixed for Forgejo.
- nsfwEnabled no longer gates an adult versus clean build. There is one
build: the manifest hook always serves clean, the releases rules dropped
the flag (1793000000_releases_drop_adult_rule.js), whoami no longer
returns it. The README now says so, and names its one remaining reader,
the family web page's adult addon field and child-profile label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nightly trimClientLogs cron fetched one page of 500 records older than
14 days and deleted those, while about 1275 rows arrive a day. Measured on
production (read-only) on 2026-09-13: 30213 rows, 14420 of them past the
cutoff, the oldest from 2026-08-19, so the backlog grew by roughly 775 rows
a day and the documented 14-day retention never held.
The job now deletes in batches of 2000 with plain SQL until a batch comes
back short, so one run clears everything past the cutoff. Plain SQL instead
of $app.delete() per record is safe here: client_logs has no file fields, no
collection has a relation pointing at it and no hook watches its deletes, so
the record path would only add a transaction and a cascade lookup per row.
Batches keep each write lock short. The run logs "trimClientLogs" with the
deleted count and the cutoff to PocketBase's logs, and a failure is logged
instead of swallowed silently as before.
scripts/test_client_logs_trim.py starts a throwaway PocketBase 0.39.6 with
this repo's migrations and hooks, seeds stale and fresh rows, fires the job
through POST /api/crons/trimClientLogs and checks what is left. Against the
old hook it failed: 800 of 1300 stale rows left, no log line. With the fix:
0 of 1300 left, all 40 in-window rows kept (one just inside the cutoff), and
0 of 15000 stale rows left in 0.4 s at production scale.
Co-Authored-By: Claude Opus 5 <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
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>