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> |
||
|---|---|---|
| docs | ||
| pb_hooks | ||
| pb_migrations | ||
| pb_public | ||
| scripts | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
amber-backend
PocketBase backend for Amber: auth, database, realtime, file storage and admin UI in a single Go binary. This is the foundation the account features build on: cloud sync, codeless device sign-in, profiles, child profiles, recommendations, and gated auto-update (epic #6).
The repo used to be called myanime-backend. On Forgejo it is now
richiexec/amber-backend; the old name still redirects to it, and Coolify's git
source still uses the old URL.
- No E2E. TLS in transit + at-rest on disk. The server is deliberately readable so recommendations and painless password reset work.
- Schema as code. The collections live in
pb_migrations/and auto-apply on boot — never hand-click the schema in the admin UI, or the next deploy will drift. - Version-pinned. The
Dockerfiledownloads the official PocketBase v0.39.6 static binary from GitHub releases (no third-party image). BumpPB_VERSIONthere and indocker-compose.ymlto upgrade.
Data model
users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per profile+item)
(auth)│ ├─* watchlist (1 row per profile+item)
│ ├─1 prefs (playback/audio/subtitle blob)
│ └─1 addon_config (client-encrypted addon blob; ciphertext only)
├─* device_auth (codeless sign-in requests / approved devices, issue #12)
| Collection | Type | Key fields |
|---|---|---|
users |
auth | username (unique when set), email, password, ratingDefault (account-wide content ceiling) |
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) |
watchlist |
base | profile→profiles, itemId, itemType, meta (json), addedAt (client), updated (server), deletedAt (tombstone) |
prefs |
base | profile→profiles, data (json), updatedAt (client), updated (server) |
addon_config |
base | profile→profiles, blob (ciphertext), salt, kdf, updatedAt (client), updated (server) |
device_auth |
base | code (unique), deviceName, status, user→users, expiresAt, + hidden authToken/keyCiphertext/pollSecret/devicePubKey — codeless device sign-in (#12) |
Access rules. A user only ever reads/writes their own users row, their own
profiles, and rows whose profile.user is them. Accounts are created by a
superuser only (registration is closed, migration 1789000000). Relations
cascadeDelete, so deleting a user removes their profiles and all child rows.
Unique indexes keep one resume/watchlist row per (profile, itemId) and one
prefs/addon_config row per profile.
There is no 18+ anything here
One Amber build ships. An account flag nsfwEnabled used to choose between an
18+ build and the clean one; the 18+ section left Amber to become a separate app,
and the flag was dropped in pb_migrations/1797000000_drop_nsfw_enabled.js along
with adult as a publishable variant. scripts/verify.py asserts it stays gone.
releases.variant itself is not going anywhere: it is always clean, and
every installed Amber compares it against its own and refuses a mismatch
silently, so dropping the field would strand those installs with no error to see.
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.
Codeless device sign-in (issue #12)
A new device (a TV) signs into an account with no password typing and no code
transcription: it shows a QR + short code, and an already-logged-in phone/PC
approves it from GET /device. On approval the TV gets a minted session token and
— end-to-end, the server never sees it — the addon-config vault key, so it can
decrypt the addon_config blob it then pulls. This lives in the device_auth
collection (pb_migrations/1786000000_device_auth.js) plus two JS hooks
(pb_hooks/device_auth.pb.js routes + cron, pb_hooks/device_page.pb.js approve
page). All create/poll/approve logic is in the hooks (the TV is unauthenticated
when it requests and polls); the collection exposes only the owner-scoped device
list + revoke. JWT revocation caveat: deleting a device row removes it from
the list but can't invalidate a token already minted (stateless JWTs) — a
documented v1 limitation.
Full contract (endpoints, record shape, the exact crypto — PBKDF2 params +
the X25519/HKDF/AES-GCM key transfer): docs/device-auth-contract.md.
The two clocks (issue #11)
Sync rows carry two timestamps, and they are not interchangeable:
updatedAt/addedAt— set by the client. When the user actually acted. This is what resolves conflicts (last-write-wins). It has to be the client's, because a device that edits offline on Monday and pushes on Friday must not beat a device that edited on Tuesday and synced immediately.updated— a server autodate. When we heard about it. This is the pull cursor (filter=updated > {lastCursor}). It has to be the server's: a device with a skewed clock would otherwise stamp rows behind another device's cursor and stay invisible to it forever.
itemId encodes the client's local key — anime:<anilistId>:<episode>,
movie:<tmdbId>:0, tv:<tmdbId>:<seasonEpisodeKey> — so the unique
(profile, itemId) index is what makes a push idempotent. meta is an opaque
JSON blob of the client's display fields (title, cover art, episode, flags); the
server never reads into it.
Deletes are soft. A watchlist removal sets deletedAt rather than deleting
the row, because an absence is not something another device can pull — it would
just re-push the title and resurrect it. Re-adding clears deletedAt on the same
row, so a delete/re-add round-trip can never leave a duplicate.
Local run / verify
docker compose up --build # http://localhost:8090
# create the first superuser (one-time):
docker compose exec pocketbase pocketbase superuser upsert you@example.com 'a-strong-pass' --dir=/pb_data
# admin UI: http://localhost:8090/_/
The end-to-end access-rule check (two users can't see each other's data; the
retired 18+ flag is gone from the schema and the rules) lives in
scripts/verify.py:
python scripts/verify.py # expects the server on :8090 + the superuser above
The client_logs retention check starts its own throwaway PocketBase (the
binary pinned in the Dockerfile), seeds rows past the 14-day cutoff, fires the
nightly trimClientLogs cron through the superuser API and asserts none is left:
PB_BIN=/path/to/pocketbase python scripts/test_client_logs_trim.py
Deploy
Production is a Coolify "Dockerfile application" on https://coolify.petruzalekr.cz:
app amber-backend, uuid eu3wk4j0zf0uczhbew0m5c9p, serving
pb.petruzalekr.cz and amber.petruzalekr.cz on port 8090, with a persistent
volume at /pb_data.
A push to main deploys it to production within seconds. Coolify's git source
is https://projects.petruzalekr.cz/richiexec/myanime-backend.git (the old name,
redirected to this repo), branch main.
- Its deployment records say
is_apirather than webhook, but every one since 2026-09-13 started 2 to 4 seconds after its commit. - If no deployment follows a push, deploy by hand: the app's Deploy button in
Coolify, or
GET /api/v1/deploy?uuid=eu3wk4j0zf0uczhbew0m5c9pwith the Coolify API token.
Migrations and hooks are baked into the image and apply when the new container boots, so a schema or hook change reaches production only with a deploy.
A deploy is done when live requests show it, not when Coolify says finished:
curl -s https://pb.petruzalekr.cz/api/health # 200: the binary is up
curl -s -o /dev/null -w '%{http_code}\n' https://amber.petruzalekr.cz/status # 302: the hooks loaded
and then a request that shows the change itself. There is no commit endpoint, so
compare the commit on Coolify's deployment record with origin/main. How to wait
for a deploy, recover a wedged deployment queue and verify every service:
docs/runbooks/deploy.md in amber-app.
Setting it up from scratch
Only needed to rebuild the Coolify app. Secrets never live in this repo.
- Git source: Coolify's "GitHub App" source cannot talk to Forgejo (it forces
api.github.com), so add the repo as a plain git source: its public HTTPS URL, or SSH on Forgejo's port 22222. - New Resource → Dockerfile application, point it at this repo. It listens
on
8090. - Persistent storage: add a volume mounted at
/pb_data(DB + uploaded files). Without this you lose data on redeploy. - Domain + TLS: set the domains to
pb.petruzalekr.czandamber.petruzalekr.cz; Coolify/Traefik provisions the Let's Encrypt cert and terminates TLS in front of the plain:8090. Force-HTTPS on. - Bootstrap the superuser once, from the container terminal:
pocketbase superuser upsert you@example.com 'a-strong-pass' --dir=/pb_data(or open the printed/_/#/pbinstall/...link on first boot). Lock down the admin UI — strong password; the_superuserscollection is the only way in. - SMTP (admin UI → Settings → Mail settings): point at your SMTP host so verification / password-reset emails send. Send the test email to confirm.
- Automated backups (admin UI → Settings → Backups): enable the schedule and, ideally, S3 off-site upload. Coolify volume backups are a second layer.
- App wiring: the Amber app's PocketBase base URL defaults to
https://pb.petruzalekr.cz(lib/core/config.dartin amber-app).
Verify a fresh setup
- Admin UI reachable over HTTPS only; HTTP redirects up.
- Create a test user + profile + a couple of rows via admin or REST; confirm a
second user can't read them (run
scripts/verify.pyagainst the live URL by editingBASE). - Trigger a password-reset email and confirm delivery.
- Confirm a backup ran and can be downloaded/restored.
Security
Account/session tokens and the addon plaintext-credential "tokens" are never
logged or echoed (see the app's CLAUDE.md). pinHash is a hidden field and
never leaves the API.
Upgrading PocketBase
Bump PB_VERSION in the Dockerfile (and the compose arg), then deploy
explicitly as described above (a push alone does not). Check the PocketBase
release notes for migration-API or schema changes first, and test locally with
docker compose up --build before deploying.