diff --git a/pb_hooks/onboarding.pb.js b/pb_hooks/onboarding.pb.js
index a6242fc..48a781c 100644
--- a/pb_hooks/onboarding.pb.js
+++ b/pb_hooks/onboarding.pb.js
@@ -1,44 +1,55 @@
///
-// Family onboarding: invite-gated registration + template config (distribution
-// site). The instance is private — family only — so BOTH doors are keyed by a
-// single invite code from the AMBER_INVITE_CODE env var (set in Coolify):
+// Family onboarding: **accounts are created by the owner**, and the template
+// config is served to whoever is already signed in.
//
-// 1. Creating a `users` record requires the code (header `X-Amber-Invite`,
-// or `?invite=` as a fallback). Without it, registration is refused — this
-// also closes the previously-open in-app register endpoint. Superusers
-// (admin UI) bypass the gate.
-// 2. GET /api/amber/onboarding-template?code=… returns the admin-maintained
-// plaintext family config (onboarding_template collection) so the
-// onboarding page can encrypt it client-side under the new user's
-// password. Rate-limited per IP so the code can't be brute-forced.
+// 1. Creating a `users` record is refused for everyone except a superuser. The
+// owner makes accounts in the PocketBase admin UI and hands over a temporary
+// password. There is no public signup and no invite string.
+// 2. GET /api/amber/onboarding-template returns the admin-maintained plaintext
+// family config, gated by auth AND the family code — family members use it,
+// close friends bring their own credentials instead (BYOC).
//
-// Fail-closed: with AMBER_INVITE_CODE unset, registration and the template
-// route are both disabled (a private instance must not silently fall open).
+// **This replaced a single shared invite code** (AMBER_INVITE_CODE) that gated
+// both doors. That code was a bearer secret: one string, the same for everyone,
+// forever, with no record of who used it — and known to every family member who
+// ever onboarded, so it leaked by design. An email whitelist was considered and
+// rejected in the same breath: it only binds to a person if the address is
+// verified, and no account on this instance is (`verified = false` on all of
+// them). For a household this size, "the owner makes the account" is stronger
+// than either and has nothing to leak. See docs/plan-accounts-and-web.md.
+//
+// AMBER_INVITE_CODE survives with a narrower job: it is the *family* code that
+// unlocks the shared template at setup, not a key to the front door.
// ── users create gate ─────────────────────────────────────────────────────────
+// Superuser only. The collection's createRule says the same thing (see the
+// migration), and both exist on purpose: the rule is the structural guarantee,
+// this hook is what returns a sentence a human can read instead of a bare 403.
onRecordCreateRequest((e) => {
if (e.hasSuperuserAuth()) return e.next()
- const code = $os.getenv("AMBER_INVITE_CODE")
- const info = e.requestInfo()
- // requestInfo() normalizes header keys to snake_case and exposes query params
- // as a plain map (e.request.url.query() is NOT available on record-request
- // events — it throws, which read as a generic 400).
- const given =
- ((info.headers["x_amber_invite"] || info.query["invite"] || "") + "").trim()
- if (!code) {
- throw new BadRequestError("Registrace je uzavřená (server nemá nastavený AMBER_INVITE_CODE).")
- }
- if (given !== code) {
- throw new BadRequestError("Registrace vyžaduje platný kód pozvánky.")
- }
- e.next()
+ throw new BadRequestError(
+ "Účty zakládá správce — registrace je uzavřená. Napiš Richardovi.")
}, "users")
// ── GET /api/amber/onboarding-template ────────────────────────────────────────
-// Query: ?code=. → { addonUrl, czechAddonUrl, tmdbKey } (values may be
-// empty strings when the template record isn't filled yet). 403 on a bad code,
-// 429 when rate-limited, 404 when no template record exists.
+// Query: ?code=. → { addonUrl, czechAddonUrl, tmdbKey }. 403 on a
+// bad code, 429 when rate-limited, 404 when no template record exists.
+//
+// **Auth AND the family code.** Two gates, because they answer different
+// questions. Auth is now guaranteed — accounts are owner-created, so anyone
+// reaching setup is signed in — but "has an account" must not mean "may have the
+// family's shared credentials": close friends get accounts too, and they bring
+// their own (BYOC). The code is what distinguishes *family* from *someone the
+// owner also trusts with an account*.
+//
+// So AMBER_INVITE_CODE keeps its job and loses its old one. It is no longer a
+// registration key — registration is owner-only — it is the family credential
+// selector during setup. The env var keeps its name so no Coolify change is
+// needed; the name is now a misnomer and can be renamed at leisure.
+//
+// Rate-limited per IP as before: the code is still a shared secret, and being
+// behind auth narrows who can guess at it but does not make guessing free.
routerAdd("GET", "/api/amber/onboarding-template", (e) => {
const MAX_FAILS = 10
const WINDOW_SECONDS = 300
@@ -46,8 +57,8 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => {
const code = $os.getenv("AMBER_INVITE_CODE")
if (!code) return e.json(503, { error: "onboarding disabled" })
- // Fixed-window per-IP limit, same shape as verify-pin's (app store — file
- // scope state does not survive the isolated handler JSVMs).
+ // Fixed-window per-IP limit (app store — file scope state does not survive the
+ // isolated handler JSVMs).
const store = $app.store()
const key = "amber.onboardFails." + e.realIP()
const now = Math.floor(Date.now() / 1000)
@@ -63,7 +74,7 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => {
count: (st ? st.count : 0) + 1,
resetAt: st ? st.resetAt : now + WINDOW_SECONDS,
})
- return e.json(403, { error: "invalid invite code" })
+ return e.json(403, { error: "invalid family code" })
}
store.set(key, null)
@@ -79,7 +90,7 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => {
czechAddonUrl: rec.getString("czechAddonUrl"),
tmdbKey: rec.getString("tmdbKey"),
})
-})
+}, $apis.requireAuth("users"))
// ── GET /get/tv ───────────────────────────────────────────────────────────────
// Public direct download of the latest CLEAN Android APK — the TV-sideload path
diff --git a/pb_hooks/settings.pb.js b/pb_hooks/settings.pb.js
deleted file mode 100644
index 3ee295f..0000000
--- a/pb_hooks/settings.pb.js
+++ /dev/null
@@ -1,6 +0,0 @@
-/// Pretty URL for the account settings editor: /settings → the static page.
-/// The page itself (pb_public/settings.html) is a login → decrypt → edit →
-/// re-encrypt flow for the client-encrypted addon_config; everything happens in
-/// the browser against the normal collection API, so there's no server logic
-/// here beyond this redirect.
-routerAdd("GET", "/settings", (e) => e.redirect(302, "/settings.html"))
diff --git a/pb_hooks/web.pb.js b/pb_hooks/web.pb.js
new file mode 100644
index 0000000..179b8f5
--- /dev/null
+++ b/pb_hooks/web.pb.js
@@ -0,0 +1,19 @@
+///
+
+// Pretty URLs for the family-facing web surfaces.
+//
+// One page now does what three did: `account.html` is sign-in, profiles,
+// playback preferences, sources, devices and downloads behind a persisted
+// session. `/settings` keeps working because it is in people's muscle memory and
+// in older messages — it lands on the sources section of the new page.
+//
+// `/tv` is the short form of `/get/tv`, because that URL gets typed on a
+// television remote inside the Downloader app, one character at a time.
+
+routerAdd("GET", "/account", (e) => e.redirect(302, "/account.html"))
+
+// Deep-link straight to sources; the page reads the hash to pick its tab.
+routerAdd("GET", "/settings", (e) => e.redirect(302, "/account.html#addons"))
+
+// Typing this on a remote is the actual cost being optimised.
+routerAdd("GET", "/tv", (e) => e.redirect(302, "/get/tv"))
diff --git a/pb_migrations/1789000000_owner_created_accounts.js b/pb_migrations/1789000000_owner_created_accounts.js
new file mode 100644
index 0000000..e7d601b
--- /dev/null
+++ b/pb_migrations/1789000000_owner_created_accounts.js
@@ -0,0 +1,38 @@
+///
+
+// Registration is closed: `users` can only be created by a superuser.
+//
+// WHY: the instance was gated by a single shared invite code in
+// AMBER_INVITE_CODE, checked in a hook. That is a bearer secret — one string for
+// everyone, forever, no record of who used it, and known to every family member
+// who ever onboarded, so it leaked by design. An email whitelist was the obvious
+// replacement and was rejected: it binds to a person only if the address is
+// verified, and every account on this instance is `verified = false`. For a
+// household of five, the owner creating each account in the admin UI is stronger
+// than either and has nothing to leak at all.
+//
+// The hook in pb_hooks/onboarding.pb.js refuses the same thing with a readable
+// Czech sentence. **Both exist deliberately**: a hook can be edited or fail to
+// load, so the rule is the structural guarantee; the rule alone would return a
+// bare 403 that tells a family member nothing.
+//
+// `nsfwEnabled` stays out of createRule's reach exactly as before — it was
+// already `@request.body.nsfwEnabled:isset = false`, and with creation limited to
+// superusers that condition is now redundant but harmless. Keeping it means the
+// down migration restores the previous rule verbatim.
+//
+// Existing accounts are untouched: this changes creation only. Sign-in, sync and
+// every other rule are unaffected.
+
+migrate(
+ (app) => {
+ const users = app.findCollectionByNameOrId("users")
+ users.createRule = null // superuser only
+ app.save(users)
+ },
+ (app) => {
+ const users = app.findCollectionByNameOrId("users")
+ users.createRule = "@request.body.nsfwEnabled:isset = false"
+ app.save(users)
+ },
+)
diff --git a/pb_migrations/1789000001_users_rating_default.js b/pb_migrations/1789000001_users_rating_default.js
new file mode 100644
index 0000000..1814e33
--- /dev/null
+++ b/pb_migrations/1789000001_users_rating_default.js
@@ -0,0 +1,39 @@
+///
+
+// Add `ratingDefault` to users: the account-wide content ceiling.
+//
+// WHY IT MOVES TO THE SERVER: the per-profile override (`profiles.maxRating`)
+// has lived here since migration 1787000000, while the *account default* it falls
+// back to stayed in each device's secure storage. So the same parental control was
+// half synced and half not — set the default on the PC and the television still
+// used whatever it had. It is also the one of the four device-local settings the
+// owner asked to sync, precisely because it is a parental control rather than a
+// per-device convenience (`adult_enabled` deliberately does NOT follow: a toggle
+// propagating to the children's television is a safety change, not a nicety).
+//
+// Stores the RatingTier enum name — `kids` | `twelve` | `fifteen` |
+// `unrestricted` — matching `RatingTier.fromName`/`.name` in the app, so the
+// existing device-local value migrates by being pushed as-is.
+//
+// Nullable: every account predates this, and an absent value must read as "not
+// set" so `RatingTier.unrestricted` remains the fallback rather than a null crash.
+// The app's two-clock LWW decides who wins when a device and the server disagree.
+
+migrate(
+ (app) => {
+ const users = app.findCollectionByNameOrId("users")
+ users.fields.add(
+ new TextField({
+ name: "ratingDefault",
+ required: false,
+ max: 20,
+ }),
+ )
+ app.save(users)
+ },
+ (app) => {
+ const users = app.findCollectionByNameOrId("users")
+ users.fields.removeByName("ratingDefault")
+ app.save(users)
+ },
+)
diff --git a/pb_public/account.html b/pb_public/account.html
new file mode 100644
index 0000000..c22af77
--- /dev/null
+++ b/pb_public/account.html
@@ -0,0 +1,879 @@
+
+
+
+
+
+
+Amber — můj účet
+
+
+
+
+
+
+
+
+
Amber
+
Přihlas se a spravuj svůj účet, profily a nastavení.
+ Přihlášení zůstane uložené, takže příště to bude rovnou tady.
+
+
+
+
+
+
+
Účty zakládá Richard — registrace tu není. Když se nemůžeš
+ dostat dovnitř, napiš mu.
+
+
+
+
Nainstalovat na televizi
+
+
Na televizi otevři aplikaci Downloader.
+
Zadej amber.petruzalekr.cz/tv —
+ stáhne se instalace.
+
Spusť Amber → Přihlásit z jiného zařízení a načti QR kód
+ telefonem. Na televizi nic nepíšeš.
+
+
+
+
+
+
+
+ Účet:
+
+
+
+
+
+
+
+
Profily
+
Každý profil má vlastní seznamy, rozkoukané a nastavení
+ přehrávání.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Přehrávání
+
Nastavení jazyků platí pro vybraný profil a projeví se na
+ všech zařízeních.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Čeština jako hlavní zvuk znamená, že Amber vždycky hledá
+ ověřený český dabing. Záložní jazyk se použije jen tehdy, když žádný
+ český zdroj neexistuje.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Zdroje
+
Odkud Amber bere filmy a seriály. Uloženo zašifrovaně —
+ klíč se počítá z tvého hesla a nikdy neopustí prohlížeč.
+
+
+
+
+
Bez hesla nelze zdroje rozšifrovat ani uložit.
+
+
+
+
+
+
Ještě tu nic není. Vyber, jak to nastavit:
+
+
+
+
+
+
+
+
+
Kód dostaneš od Richarda. Nastaví zdroje rodiny za tebe.
+
+
+
+
+
+
+
Amber si z tvých přihlašovacích údajů poskládá adresy
+ zdrojů sám. Údaje jdou přímo do doplňku, přes náš server neprojdou.
+
+
prehraj.to
+
+
+
+
+
+
+
Povinné — český doplněk stojí na prehraj.to a bez něj
+ se nespustí. webshare se přidává k němu, samostatně zatím nefunguje.
+
+
webshare.cz — volitelné
+
+
+
+
+
+
+
Druhý český host. Buď obojí, nebo nic — půlka údajů
+ selže při každém hledání.
+
+
TorBox — volitelné, ale doporučené
+
+
+
Tohle je zdroj s největším výběrem. Pro cokoli,
+ co není česky dabované — původní znění, novinky, seriály, 4K — je
+ TorBox zdaleka nejlepší a najde toho nejvíc. Bez něj zůstaneš jen
+ u českých zdrojů.
+
+
+
+
Bez něj se nenačtou plakáty ani popisy. Zdarma na
+ themoviedb.org.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Prázdné pole zdroj z účtu odebere.
+
+
+
+
+
+
+
+
+
+
Zařízení
+
Přihlášené televize a počítače. Odebrání zabrání dalšímu
+ přihlašování z toho zařízení.
+
+
+
Už přihlášené zařízení může dobíhat, dokud mu nevyprší
+ token — odebrání zastaví nová přihlášení, ne běžící relaci.
+
+
+
+
+
Stáhnout Amber
+
Verze podle tvého účtu.
+
+
+
Televize
+
+
Na televizi v aplikaci Downloader zadej
+ amber.petruzalekr.cz/tv.
+
V Amberu zvol Přihlásit z jiného zařízení a načti QR telefonem.
+
+
+
+
+
+
+
Účet
+
+
+
E-mail mění správce.
+
+
+
+
+
Použije se pro profily, které nemají vlastní strop.
+
+
+
+
Změna hesla
+
Heslo šifruje tvoje zdroje. Změna je proto přešifruje —
+ musíš mít v této relaci odemčené Zdroje, jinak o ně přijdeš.