A commit that deletes a file from pb_public does not delete it from the server —
the deploy only copies in. So /settings.html and /onboarding.html kept answering
200 after the swap, the second still offering a signup form that can now only
fail. Explicit routes shadow them, and go on working as bookmarks even after
someone clears the directory by hand.
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
Registration is closed. The instance was gated by a single shared invite code in
AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record
of who used it, and known to every family member who ever onboarded, so it leaked
by design. An email whitelist was the obvious replacement and does not survive
contact with the facts: it binds to a person only if the address is verified, and
all five accounts on this instance are `verified = false`. For a household this
size the owner creating each account in the admin UI is stronger than either and
has nothing to leak. `users.createRule` is superuser-only now, and the hook
refuses with a sentence a family member can read rather than a bare 403.
The family code survives with a narrower job. It is no longer a key to the front
door; it is what unlocks the *shared family credentials* during setup. Close
friends get accounts too and bring their own (BYOC), so "has an account" must not
mean "may have the family's sources" -- the template route now wants auth AND the
code, keeping its rate limiter because the code is still a shared secret.
pb_public/account.html replaces index/settings/onboarding with one page behind a
persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced
playback preferences per profile, sources, devices, downloads, account. The token
persists in localStorage; the password never does, and anything needing it asks in
the moment -- that is the honest split between "stay signed in" and "hold the key
to your credentials in a browser tab".
Sources can now be set up from your own service credentials. The page posts them
straight to the addon's own /encode and builds TorBox's config token locally --
both addons send permissive CORS, so the credentials go from the family member's
browser to the addon and never touch our server. prehraj.to is required because
the addon's /encode returns 400 without it and decodeConfig rejects such a token,
so "webshare only" is not currently possible; the copy says so rather than
pretending. TorBox is optional and the copy is emphatic about what it is for.
Two hazards handled rather than hoped over. Changing the password re-keys the
addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were
after, a failure would leave a blob no device can read. And prefs writes carry
`updatedAt`, the clock the app's LWW actually compares -- the server's `updated`
is only the pull cursor -- and merge into the existing data rather than replacing
it, because the app writes fields this page does not show yet.
Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one
half of a parental control that does not sync. The app side ships with the next
release. The other three device-local settings stay local on purpose, and
`adult_enabled` most deliberately of all.
Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.
**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.
**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.
The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.
Inline SVG rather than a charting library -- this page is served by PocketBase with
no build step, and 14 bars do not justify a dependency. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
The owner issued a read-only Coolify token, which is the right shape: this
dashboard only ever performs GETs. Renaming the variable from COOLIFY_TOKEN to
COOLIFY_READ_TOKEN means the requirement is encoded where someone setting it will
see it, rather than living only in a doc.
A write-capable token here would mean a PocketBase vulnerability could deploy or
stop every app on the PaaS. coolify.sh keeps using the full token for deploys;
having both in secrets/amber.env is the separation, not redundancy.
Verified the read token against both endpoints the dashboard needs (applications
list, container logs) before wiring it, and validated the renamed variable on a
clean local boot: 7 services enumerated, both panels configured, no errors.
The documented URL did not work, and failed in the most misleading way possible:
PocketBase's indexFallback (on by default) serves index.html for any unmatched
static path, so /status returned the family landing page with HTTP 200. It looked
like the dashboard had silently broken rather than like a missing route.
Follows the existing /settings -> /settings.html pattern in settings.pb.js, which
is presumably why that one has always worked.
Validated on a clean boot with hooksWatch off: 302 to /status.html, the page
titled 'Amber - stav služeb', /api/status still 401, no JS errors.
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.
pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.
**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.
Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.
Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.
Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
amber-api must know whether an inbound request carries a valid Amber user
token before it spends an ffprobe on a caller-supplied URL. PocketBase
auth tokens are stateless JWTs signed with a per-user secret only
PocketBase holds, so offline verification is impossible by design — the
only correct check is to present the token and see if PocketBase accepts
it. $apis.requireAuth("users") does that before the handler runs, so
reaching the body IS the answer and the body is deliberately trivial.
Returns exactly {id, nsfwEnabled} and nothing else. A token-introspection
endpoint is a tempting place to leak account data (email, username,
profiles) and amber-api has no business knowing any of it — asserted.
Verified against a real PocketBase 0.39.6 running the repo's actual hooks
and migrations (the binary, no Docker needed): full suite green including
8 new assertions — anonymous 401, bogus token 401, valid 200, own id
returned, nsfwEnabled present, no extra keys, per-caller distinctness.
Also verified the integration this exists for: a genuine app token from
PocketBase authenticates a probe enqueue on amber-api, an invalid one
still 401s, and amber-api's 10-minute positive cache means a second
request costs no PocketBase round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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/).
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).
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>