Nothing read `users.nsfwEnabled` any more. `/api/amber/whoami` stopped returning it, `GET /api/update/manifest` always answers `clean`, and the `releases` read rule stopped mentioning it in migration 1793000000, when the nine 18+ rows (437 MB) were deleted. What was left was a per-account flag nobody writes and nobody reads, sitting on the family's accounts and showing up in every schema dump and access-rule review as if it still gated something. A flag that looks like a protection but protects nothing costs the next reader the work of proving it dead. One live reader did remain: pb_public/index.html showed a "Zdroj 18+" source field and wrote `adultAddonUrl` into the encrypted addon config for a flagged account, and picked a different child-profile label for one. Both are gone, so the page offers the same three sources to everyone. Migration 1797000000 drops the field, rewrites users.updateRule to `id = @request.auth.id` (the clause it loses guarded a field that no longer exists), and removes `adult` from releases.variant's allowed values. Checked before writing it: 15 release rows, every one `clean`; three accounts carried the flag (the owner's, one family member's, the retired test account); the separate 18+ app has no accounts and no PocketBase, so nothing outside Amber reads it. `variant` itself stays and stays required: every installed Amber compares it against its own build and refuses a mismatch SILENTLY, so dropping the field would strand those installs with no error to see. publish-release.sh now accepts only `clean`. The status dashboard stops listing `adult-stremio-addon` and `stripchat-proxy`. Both still run on the VPS; they are the owner's, not Amber's, and Amber never reached them. scripts/verify.py asserts the flag stays gone (field absent, no rule mentions it, updateRule own-record-only). Run against a local PocketBase built from origin/main it failed on exactly those three checks; with this migration applied, ALL CHECKS PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
15 KiB
Markdown
299 lines
15 KiB
Markdown
# 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 18+ fields** — the template is family config by
|
|
definition (mirrors the #14/#20 child stripping), and Amber has no 18+ content.
|
|
- 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). One build ships, so there is nothing else to serve.
|
|
|
|
## 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 · `/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 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.
|
|
|
|
### 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.
|
|
|
|
### 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`.
|