2026-07-22 16:23:05 +00:00
# Family onboarding & distribution site
The distribution site (landing + account wizard) lives in `pb_public/` and is
served by PocketBase itself at `/` — same origin as the API, no extra service.
It exists so a family member can be fully set up **from a browser** : create an
account, receive the family addon config (encrypted client-side), and log the
TV in via the existing device sign-in flow — without ever installing Amber on a
PC or typing addon URLs.
## Pages
| Path | What |
|--------------------|-------------------------------------------------------------|
| `/` | Landing: create-account CTA, TV sign-in steps, PC downloads (login-gated) |
| `/onboarding.html` | The wizard: invite + account + profile + template config |
| `/device` | Existing device-approve page (issue #12 ), linked from both |
| `/get/tv` | Public direct download of the latest **clean** Android APK |
## Environment
| Var | Meaning |
|---------------------|-----------------------------------------------------------|
| `AMBER_INVITE_CODE` | The single family invite code. **Unset → registration and the template route are disabled (fail closed).** Set it in Coolify. |
## Routes (pb_hooks/onboarding.pb.js)
### users create gate
Creating a `users` record requires the invite code — header `X-Amber-Invite`
(what the wizard sends) or `?invite=` query fallback. Superusers bypass.
**Note:** this also closes the previously-open in-app registration; the app's
register screen will refuse without a code until it grows an invite field
(family onboarding happens through the site, so this is acceptable).
### GET /api/amber/onboarding-template?code=CODE
Returns the admin-maintained plaintext family config:
```json
{ "addonUrl": "…", "czechAddonUrl": "…", "tmdbKey": "…" }
```
- `403` bad code · `429` after 10 bad codes / 5 min / IP · `404` no template
record · `503` env unset.
- **Deliberately no adult fields** — the template is family config by
definition (mirrors the #14/#20 child stripping). Adult stays per-account.
- Plaintext travels once over TLS to the wizard, which encrypts it under the
*new user's* password (PBKDF2-HMAC-SHA256 210k → AES-256-GCM, blob =
`base64(nonce[12] ‖ ct ‖ tag[16])` , `kdf = pbkdf2-sha256-210000` ) and pushes
it as the account's `addon_config` row — the server still stores only
ciphertext per account. Interop with the app's `AddonConfigCrypto` is
verified (a wizard-produced blob decrypts in the app's Dart crypto).
### GET /get/tv
Streams the newest `releases` row with `platform=android && variant=clean` as
`amber-tv.apk` (attachment). Public by design: the TV-sideload path (the
Downloader app needs a plain URL, a fresh TV has no account, and the APK is
useless without one). Adult builds remain account-gated, unchanged.
## Template maintenance (admin)
One record in the `onboarding_template` collection, edited via the PocketBase
admin UI (`/_/`). Fields: `addonUrl` , `czechAddonUrl` , `tmdbKey` (+ free-form
`note` ). Same value shapes the app's Settings store (base URLs without
`/manifest.json` ). The hook serves the newest record if several exist — keep
just one. No client can read the collection directly (all rules null).
## Wizard flow (onboarding.html)
1. `POST /api/collections/users/records` with `X-Amber-Invite` (email +
password).
2. `auth-with-password` → token.
3. `POST /api/collections/profiles/records` — the default profile, named by
the user ("Kdo se bude dívat?"). The app adopts the earliest-created
profile on first login (`CloudSyncService._ensureProfile`), so this *is*
their profile.
4. Template fetch → client-side encrypt → `POST /api/collections/addon_config/records`
(`profile`, `blob` , `salt` (fresh 16 B), `kdf` , `updatedAt` ).
5. Success: TV instructions (`/get/tv` + "Přihlásit z jiného zařízení" →
`/device` ), plus login-token-gated Windows/Linux download buttons (existing
`/api/update/manifest` + `/api/files/token` ).
Template application is optional (checkbox, default on); every failure past
account creation degrades gracefully — the account still works, config can be
entered in-app.
## Coolify deploy
1. DNS: `amber.petruzalekr.cz` → the server.
2. The service's **Domains** field becomes a comma-separated list:
`https://pb.petruzalekr.cz,https://amber.petruzalekr.cz` (save, don't
"Generate Domain", then restart). The site is host-agnostic (relative API
paths), so both domains work; hand the family the `amber.` one.
3. Env: add `AMBER_INVITE_CODE=<code>` and redeploy.
4. Admin UI → `onboarding_template` → create the one record with the real
values.
5. Publish releases (`scripts/publish-release.sh`) so `/get/tv` and the
download buttons have something to serve.
## Local verification (2026-07-22)
Native `pocketbase serve` against the repo dirs + `AMBER_INVITE_CODE` :
signup refused without/with wrong code (Czech error) and accepted via header
and query · template route 403/200/429(10 per 5 min)/404 paths · full wizard
run in a real browser produced the user, the named profile, and an
`addon_config` blob that **decrypted with the app's Dart `AddonConfigCrypto`**
with every field intact (adult fields null/false) · `/get/tv` streamed a
published dummy APK byte-identical with the attachment header · landing's
download login handled the no-releases state.
Tell a new viewer what to buy before asking them to fill in a form
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>
2026-08-12 16:36:55 +00:00
## The setup flow for someone with their own accounts (2026-08-12)
The family path is the `onboarding_template` route above: one code, and the shared
credentials arrive already filled in. This is the other path, for someone who is
trusted with an account but brings their own subscriptions, and until now had to
work out on their own that Amber needs paid accounts somewhere before it can find
anything.
`#start` is a tab in `index.html` , not a new page, because the deploy copies files
into `pb_public` and never deletes the ones a commit removed. That is why stale
`onboarding.html` and `settings.html` still answer 200 and need shadow routes in
`web.pb.js` , and one file avoids repeating it.
### The six steps
| | Step | Finished when |
|---|---|---|
| 1 | Change the generated password | `onboarding.pwChangedAt` is set |
| 2 | Choose services | `onboarding.chosen` is non-empty |
| 3 | Create accounts and pay | an `addon_config` row exists |
| 4 | Get a TMDB key | the decrypted config has `tmdbKey` |
| 5 | Enter the credentials in Zdroje | an `addon_config` row exists |
| 6 | Download and pair | never; you can always want the app again |
Steps 1, 5 and 6 hand off to the existing Účet, Zdroje and Stáhnout tabs. This
screen owns no credentials and no crypto, deliberately: a second encryptor for one
blob is how a vault gets corrupted. Step 5 in particular is just a signpost to
`adByocBox` , which already turns usernames and keys into addon URLs by calling the
addon's `/encode` straight from the browser.
**The password is step 1 and 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. First, while the vault is still empty, there is nothing to
re-encrypt and no paired television to strand, and the password is still in memory
from the sign-in that just happened.
### State: three fields, and everything else derived
`users.onboarding` is json holding only `skipped` , `chosen` and `pwChangedAt` .
Nothing else needs storing: whether the sources exist is `CONFIGS.length` , and
whether TMDB is set is a field in the decrypted config. A second copy of a
derivable fact eventually disagrees with the first, and then the flow either nags
someone who has finished or congratulates someone who has not.
`pwChangedAt` is the exception that cannot be derived, because PocketBase records
no password-changed timestamp and `updated` moves for any edit. Absent means "as
far as we know, still on the password the owner generated", which is the honest
default for a reminder.
The flow opens itself only when there is no `addon_config` and `skipped` is not
set, so the family, who all have sources already, never see it. An explicit
`#hash` always wins so bookmarks still land where they say.
### Content lives in `providers`, not in the page
Prices and click-paths go stale on somebody else's schedule, and a wrong price on
our own page reads as a promise. `providers` is editable in the admin UI and
`priceCheckedAt` renders next to the number, so a stale figure looks stale.
`scripts/providers.seed.json` is the starting point; `scripts/seed-providers.py`
upserts by slug and leaves existing rows alone unless given `--force` , so it is
safe to run after any deploy.
`slug` is load-bearing: the page keys its wiring off `torbox` , `prehrajto` ,
`webshare` and `tmdb` , and the seeder refuses anything else. Everything else is
free text.
`kind` separates the two questions the flow asks. `source` rows are a choice to
weigh up; `metadata` (TMDB) is not optional and is not offered as one, because
without a key there are no posters and no descriptions.
### Verified locally (2026-08-12)
Against PocketBase 0.39.6, the pinned version, run from a throwaway data dir with
this repo's real `pb_migrations` , `pb_hooks` and `pb_public` . Docker was not used:
its daemon is disabled on the dev box and the binary alone is enough.
Both migrations applied clean on a fresh database. Then, as a locally created test
user with no sources: the flow opened itself; choosing prehraj.to and TorBox
persisted as `{"chosen":["prehrajto","torbox"]}` and nothing else; step 3 rendered
both guides numbered with working links; skipping moved to Profily and raised the
banner; a reload kept it skipped rather than reopening; resuming came back in.
Not yet verified: the whole thing against the live instance, and a real TorBox or
webshare signup followed end to end by someone who does not already know the
answer.
The code has to choose the template, not the clock
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>
2026-08-12 19:35:46 +00:00
## Several shared-credential groups, one code each (2026-08-12)
The family shares one set of paid accounts. Friends now get a second set that is
deliberately not the family's, so `onboarding_template` holds one row per group.
`name` labels it, `code` unlocks it, `isDefault` marks the family. The unique index
on `code` is partial (`WHERE code != ''`) because PocketBase text fields default to
`''` and SQLite treats two empty strings as equal, which would otherwise stop a
second row from existing until both had codes.
### The code chooses the row, which it did not before
The route used to check the code and then take the most recently updated row:
```js
findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
```
With one row that was invisible. With two it would have handed the family's
credentials to friends, or the reverse, depending only on which row was edited
last, silently and with no error. The lookup is now `code = {:code}` bound as a
parameter, and `AMBER_INVITE_CODE` is a fallback that opens the `isDefault` row so
nobody mid-setup broke and Coolify needed no edit. Once the family row carries its
own code the env var is redundant.
A wrong code and a code with no row behind it both return 403. Distinguishing them
would confirm which codes exist. The per-IP failure limit is unchanged and stays
per IP rather than per code, so guessing at one group cannot earn a fresh budget by
switching to another.
### status.html is the FAMILY editor, explicitly
The dashboard read the template in three places, all `sort=-updated` , and its own
comment warned "never create a second, or `-updated` ordering in the hook would
start deciding which config the family gets". All three now go through
`familyTemplate()` , which selects on `isDefault` and falls back to the single row
only on an instance that never had a template. So adding a friends row cannot make
the editor wander onto it, and cannot make account creation preload the wrong
group.
The dashboard edits any group, not whichever one it found
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>
2026-08-12 19:54:12 +00:00
### The dashboard edits any group (added same day)
A `skupina` dropdown above the fields, listing every row plus ** + nová skupina**.
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.
Switching groups drops unsaved edits, which is the honest behaviour: they belonged
to the group you just left.
`name` and `code` are editable here too, and `code` is masked with the other
secrets behind *zobrazit hodnoty* , 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 put us back to the original bug: which credentials the
old code hands out would depend on edit order. Saving with the box ticked clears
the flag on every other row and says so in the confirmation.
Account creation takes a **sdílené nastavení** dropdown instead of the old
family-only checkbox: every group, plus *žádné* for someone bringing their own
accounts. It defaults to the `isDefault` row, so the previous behaviour is what you
get by not touching it. `preloadFamilyConfig` became `preloadSharedConfig(userId,
name, password, tplId)` and fetches the chosen row by id.
Fixed in passing: the save confirmation was written and then destroyed by `load()`
re-rendering the section, so it had always flashed and vanished. It matters now
because the message reports that the default group moved.
The code has to choose the template, not the clock
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>
2026-08-12 19:35:46 +00:00
### Verified locally (2026-08-12)
PocketBase 0.39.6 from a throwaway data dir with this repo's real migrations, hooks
and pages, `AMBER_INVITE_CODE=legacy-family-code` , and two rows carrying
deliberately different credentials.
With **friends** as the most recently updated row, which is the exact state that
used to break:
| given | resolves to |
|---|---|
| the friends code | friends credentials |
| the family's own code | family credentials |
| `AMBER_INVITE_CODE` | family credentials, via `isDefault` |
| a wrong code | 403 |
| an empty code | 403 |
| a second row reusing a code | rejected, 400, by the unique index |
`familyTemplate()` returned `family` while `-updated` returned `friends` , which is
the divergence the change exists to create. Rate limiting still allows ten failures
and answers 429 on the eleventh.
The dashboard edits any group, not whichever one it found
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>
2026-08-12 19:54:12 +00:00
### Dashboard verified locally (2026-08-12)
Driven through the page's own handlers against three rows with deliberately
different credentials. Screenshots were unavailable in this session (the browser
pane was not displayed), so this was checked at the DOM level rather than by eye.
- the picker listed `family (výchozí)` , `friends` , `kolegove` , `+ nová skupina` ,
and the create form listed all three plus *žádné* , with the default preselected;
- switching to `friends` loaded the friends' code and TorBox URL, not the family's;
- `+ nová skupina` cleared every field rather than inheriting the previous group's;
- a nameless save was refused; reusing another group's code reported
`code: Value must be unique.` rather than a bare 400;
- saving a new group with `výchozí` ticked moved the marker off `family` , and
putting it back reported "Výchozí skupina je teď „family“, ne „kolegove“";
- afterwards all four codes resolved to their own credentials (three group codes
plus `AMBER_INVITE_CODE` to family) and exactly one row held `isDefault` .