Caught in a screenshot, invisible in the source: sharing a grid row with
the create form left the five-column table about half the page, so every
date broke as "2026-" / "08-05", the action button wrapped to two lines,
and a long address split mid-word. A wrapped date reads as two dates.
Full width, the address takes the slack, and the four columns that must
never wrap say so. Verified by counting line boxes per cell rather than
by eye — my first check compared cell HEIGHTS and flagged all five rows,
which was just the button's own height.
Two views on status.html, and one migration.
Hlášení: one incident per card rather than a table row, because a report
carries fifteen numbers and the job is to read one incident, not scan a
column. The reporter's address is resolved from the `user` relation in the
browser — the app never writes identity onto telemetry rows. The box is
named. Our own test sessions are HIDDEN by default, since the question
this view answers is what the family experienced; `tester` OR an emulator
tell OR a test address, so it stays right for rows written before the
column existed. Day-range filter, and every field says "neměří se" rather
than 0 when that player does not send it — a different claim from zero,
and the TV sends everything while mpv sends part.
Správa: create an account and flip clean/18+. Registration stays CLOSED —
`users.createRule` is still null and this is a plain POST with the
superuser token the page has held since it was written, so it grants
nothing that was not already available in the PocketBase admin UI. The
password field is deliberately visible: you are making it for someone
else and have to read it out.
client_logs.gitSha: which BUILD wrote a row, not which version. Fourth
outing for the silent-drop lesson, and written alongside the client change
rather than discovered after.
Verified against real data before deploying, by splicing production rows
into the shipped page as fixtures and driving it in a browser: seven
reports render with the right reporter and box, the day filter and the
test toggle both work, prefs persist, and the create form's guard fires.
That caught a JS syntax error (a Czech quote closed with an ASCII one
inside a double-quoted string, which would have taken the whole dashboard
down — PocketBase serves this file with no build step) and a missing space
that rendered "ďáblashlédnuto".
"Na televizi otevři aplikaci Downloader" was carried over from the old landing page
and never checked. Downloader is a third-party sideloading app people install on Fire
TV sticks; it is not standard on Android TV, and the owner has never seen it on his.
An instruction that points at something absent is worse than no instruction — the
family would simply be stuck.
The browser route is what he actually uses and has confirmed works, so it is the
instruction now: open the TV's browser, go to the site, press a button. The button is
new — telling someone to type a second URL when a link can do it was needless, and
`/tv` stays as the shortcut for anyone who would rather type than click.
Both places said it: the signed-out card and the Stáhnout tab.
The addon accepts either host on its own as of stremio_prehrajto 7ef248d, so the
page that mints the URLs stops claiming otherwise. Each host stays all-or-nothing
and at least one must be complete — the same rule /encode enforces, checked here
too so the message arrives in Czech rather than as a 400.
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.
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.
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>
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>