amber-backend/docs/onboarding-contract.md
Claude 57585d8ad2 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 18:36:55 +02:00

9.8 KiB

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:

{ "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.

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.