# 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=` 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. ## 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. ## 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 one-click preload on account creation is family-only.** Other groups are maintained in the PocketBase admin UI and their members type their own code on the account page. A group picker in the dashboard is a further piece of work, listed as not done rather than half-built. ### 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.