Every other interactive element on the account page is amber; the browser
default made this one blue and underlined, which reads as something pasted in
from another site — not the impression to give somebody who already thinks they
have lost their account.
Spotted in a screenshot of the rendered page, not in the markup.
The reset email works — confirmed by a delivered message, after every attempt on
record had failed at the lookup and the mail path had never once been exercised.
What the delivered message showed was a different problem: it was in English,
and its button pointed at `pb.petruzalekr.cz/_/#/auth/confirm-password-reset/…`,
which is PocketBase's admin console. Every other surface of this app is Czech on
purpose, because the people using it are the owner's parents and his children —
and the one moment they are most likely to be stuck is the one moment it started
speaking English and sent them to what reads like a developer tool.
Found by looking at a delivered email rather than at the settings. The templates
were simply PocketBase's defaults, and a default is invisible until somebody
receives one.
The reset link now lands on amber.petruzalekr.cz, which hosts the form: two
fields, in Czech, on the domain people already know. A reset link wins over a
stored session, because somebody arriving with one is trying to fix their
account and dropping them into a signed-in page hides the thing they came to do.
The token is stripped from the address bar once spent — a URL carrying a
credential is one that ends up in a bookmark or a screenshot.
A failed confirm names the likely cause. These links expire, and "something went
wrong" gives somebody who opened yesterday's email no way to know that.
Verification and email-change keep PocketBase's own confirm pages: nobody has
used those, they are not part of the report, and pointing them at a page that
does not exist would be worse than English.
As a migration rather than a click in the admin UI, so restoring the backend
from migrations does not quietly put English back.
Two halves of the same report. A family member could not get in, four password
resets produced nothing, and the site offered no way to ask for one anyway.
**Capitals.** The web sign-in trimmed the address but did not lower-case it, and
PocketBase looks accounts up case-sensitively — so `Vojta.Markup@seznam.cz` for
an account stored in lowercase matched nothing and read as a wrong password.
Phones capitalise the first letter of a text field by themselves, so this is the
mistake people will actually make. The same fix went into the app.
**The reset itself.** A link under the sign-in button, hidden until asked for:
the overwhelmingly common visit is somebody who remembers their password, and a
reset field on the way in invites people to reach for it first.
The message it shows says what was *done*, never whether the address matched.
The endpoint deliberately answers the same either way so that nobody can use it
to discover which addresses are registered — and pretending otherwise is exactly
what made this so hard to read from outside: four attempts, four "sent", nothing
arriving, no way to tell that the lookup had simply found nothing. It also
points at the one thing that always works when mail does not, which is asking
for it to be set directly.
Whether delivery itself works is still unverified: every reset attempt on record
failed at the lookup, so the mail path has never once been exercised. A reset
has been triggered against a real account to settle that.
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.
This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.
The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.
Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
Publishing a notice did nothing and said it had worked. `api()` took only
`path` and dropped the options object every caller passes, so
`api(path, {method: "POST", body: ...})` fetched the URL. The read
succeeded -- notices are publicly listable -- so the page printed
"zveřejněno" over a collection that had not changed. Switching a notice
on or off is a PATCH and went the same way.
It takes method and body now, and reports what PocketBase said rather
than a bare status, so a permissions problem cannot look like an outage.
Found because the owner tried to tell the family that the anime tab is
down -- AniList have disabled their API, and every call returns 403 with
"The AniList API has been temporarily disabled due to severe stability
issues" -- and the notice never reached anyone.
Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.
The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.
whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.
The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.
Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.
Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.
check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
AniList disabled their public API with no warning and the family's Anime tab
stopped working. The app had nothing to say: the cause was outside it, no retry
would have helped, and the honest message was "this is broken, it is not your
television, and it is not ours to fix today". Nothing could deliver that.
A `notices` collection the owner writes and every app reads, plus a composer in
the dashboard's Správa view, which already holds a superuser token.
Read is PUBLIC on purpose: a notice has to survive the case where signing in is
itself what is broken, which is exactly when an explanation is worth most. The
cost is that it is world-readable, so both the migration and the composer say in
so many words that nothing sensitive goes in one. Writing stays superuser-only.
`endsAt` exists because the predictable failure is not a wrong notice but a stale
one: the outage ends, the row stays, and within a week everybody has learned to
ignore the banner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A friend got "The requested resource wasn't found." downloading the adult
Windows installer. It is not a permissions problem and not missing data:
the row and the file are both there, and the same download returns 206 with
a valid token. That message is exactly what a protected file answers when
the token is missing or expired, verified against production.
releases.file is protected, so the URL needs a file token, and a file token
lives 180 seconds -- read off the JWT's own exp claim, not guessed. The
download list minted three of them while building the rows and baked them
into the hrefs, so three minutes after the page loaded every button was
permanently dead, and pressing it again could not help because the stale
token was in the markup. Nothing suggested reloading.
The owner never saw it because he presses the button as soon as the tab
renders. The setup flow I added this week makes it near-certain for anyone
else: it walks somebody through choosing services, creating accounts,
paying and fetching a TMDB key before pointing them at Stáhnout, which is
many minutes after the download tab was first rendered.
So the token is minted in the click handler now, and the link carries
download=1 so PocketBase sends an attachment rather than leaving the
browser to decide what to do with a .exe or a .zip.
The app was already correct: UpdateService mints its token immediately
before _dio.download, so auto-update on the family's devices was never
affected. This was the web page only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The child-profile checkbox read "Dětský profil (skryje anime a 18+, na
odchod chce PIN)" for everyone. Three of the four accounts are
nsfwEnabled = false, so for most of the household that sentence announced
a section they have never seen and cannot reach, and it was also untrue:
there is no 18+ content on those accounts to hide.
The adult source field on the Zdroje tab has always been gated on the same
flag. This label was the one place on the page that was not, because it was
static markup rather than something rendered from USER.
Now set in enter(): "skryje anime" alone, or "skryje anime a 18+" when the
account has it. Checked both branches against a clean and an adult test
user on a local instance.
The app is already clean here: childProfileDesc is only "K opuštění tohoto
profilu je potřeba PIN", and every 18+ string in app_cs.arb lives inside
the adult section itself, which a clean build does not contain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.
A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.
Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.
preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.
Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.
Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".
So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.
Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.
status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.
The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.
Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.
Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.
The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.
State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.
Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.
byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.
Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, and the third one I first said was impossible.
Nový účet takes the full width — a 420px column inside a full-width panel
left two thirds of the row empty. Three across on a desktop, one on a
phone.
Rodinné nastavení edits `onboarding_template`: the shared sources a new
member's first setup pulls down. The values are plaintext credentials — a
prehraj.to login and a TorBox key — so they are masked behind a reveal
toggle, and this stays a superuser-only noindex page. It PATCHes the one
record and never creates a second; a second row would make the hook's
`-updated` ordering silently decide which config the family gets.
"Použít rodinné nastavení" pre-loads that config onto the account being
created, so the new person never types a family code. I claimed this could
not work because `addon_config` is keyed by profile and a fresh account has
none. The owner pushed back, correctly: the old wizard needed a profile for
the same reason and simply could not make one, whereas this page can — and
`CloudSyncService._findOrCreateDefaultProfile` looks for an existing
profile BEFORE creating one, explicitly "so a second device signing into an
account with data doesn't make a duplicate". A profile made here is adopted
as the default on first sign-in. The other two obstacles were never real:
the template is superuser-readable, and the password is available because
this page just chose it.
The crypto is lifted unchanged from index.html and verified rather than
trusted: the page's own functions, run against
amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture
addon_config_crypto_interop_test pins the Dart side to — derive the
identical key, and a sealed blob round-trips. One wrong iteration count
would have made a new member's app fail to decrypt, which presents as a
password reset rather than as a bug.
The account is created BEFORE the pre-load, and a failed pre-load says so
in warning colour rather than reporting success or rolling back: a
half-made account you were not told about is the worst outcome of the
three.
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>