amber-backend/docs/onboarding-contract.md
Claude 0b65fd7f49 Family onboarding site: invite-gated signup, template config, /get/tv
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>
2026-07-22 18:23:05 +02:00

5.4 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.