amber-backend/pb_hooks/onboarding.pb.js
Claude 00b20550c2 Accounts are owner-created, and one page manages the whole account
Registration is closed. The instance was gated by a single shared invite code in
AMBER_INVITE_CODE -- 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 does not survive
contact with the facts: it binds to a person only if the address is verified, and
all five accounts on this instance are `verified = false`. For a household this
size the owner creating each account in the admin UI is stronger than either and
has nothing to leak. `users.createRule` is superuser-only now, and the hook
refuses with a sentence a family member can read rather than a bare 403.

The family code survives with a narrower job. It is no longer a key to the front
door; it is what unlocks the *shared family credentials* during setup. Close
friends get accounts too and bring their own (BYOC), so "has an account" must not
mean "may have the family's sources" -- the template route now wants auth AND the
code, keeping its rate limiter because the code is still a shared secret.

pb_public/account.html replaces index/settings/onboarding with one page behind a
persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced
playback preferences per profile, sources, devices, downloads, account. The token
persists in localStorage; the password never does, and anything needing it asks in
the moment -- that is the honest split between "stay signed in" and "hold the key
to your credentials in a browser tab".

Sources can now be set up from your own service credentials. The page posts them
straight to the addon's own /encode and builds TorBox's config token locally --
both addons send permissive CORS, so the credentials go from the family member's
browser to the addon and never touch our server. prehraj.to is required because
the addon's /encode returns 400 without it and decodeConfig rejects such a token,
so "webshare only" is not currently possible; the copy says so rather than
pretending. TorBox is optional and the copy is emphatic about what it is for.

Two hazards handled rather than hoped over. Changing the password re-keys the
addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were
after, a failure would leave a blob no device can read. And prefs writes carry
`updatedAt`, the clock the app's LWW actually compares -- the server's `updated`
is only the pull cursor -- and merge into the existing data rather than replacing
it, because the app writes fields this page does not show yet.

Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one
half of a parental control that does not sync. The app side ships with the next
release. The other three device-local settings stay local on purpose, and
`adult_enabled` most deliberately of all.

Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-07 01:54:25 +02:00

118 lines
5.8 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// 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 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).
//
// **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()
throw new BadRequestError(
"Účty zakládá správce — registrace je uzavřená. Napiš Richardovi.")
}, "users")
// ── GET /api/amber/onboarding-template ────────────────────────────────────────
// Query: ?code=<family 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
const code = $os.getenv("AMBER_INVITE_CODE")
if (!code) return e.json(503, { error: "onboarding disabled" })
// 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)
let st = store.get(key)
if (st && st.resetAt <= now) st = null
if (st && st.count >= MAX_FAILS) {
return e.json(429, { error: "too many attempts", retryAfter: st.resetAt - now })
}
const given = (e.request.url.query().get("code") || "").trim()
if (given !== code) {
store.set(key, {
count: (st ? st.count : 0) + 1,
resetAt: st ? st.resetAt : now + WINDOW_SECONDS,
})
return e.json(403, { error: "invalid family code" })
}
store.set(key, null)
let rec = null
try {
const rows = $app.findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
if (rows && rows.length) rec = rows[0]
} catch (_) { /* collection empty */ }
if (!rec) return e.json(404, { error: "no template configured" })
return e.json(200, {
addonUrl: rec.getString("addonUrl"),
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
// (the Downloader app needs a plain URL; a fresh TV has no account yet, and the
// APK alone is useless without one, so serving the clean build openly is fine).
// Adult builds stay account-gated (manifest + protected file), unchanged.
routerAdd("GET", "/get/tv", (e) => {
let rec = null
try {
const rows = $app.findRecordsByFilter(
"releases",
"platform = 'android' && variant = 'clean'",
"-buildNumber",
1, 0
)
if (rows && rows.length) rec = rows[0]
} catch (_) { /* none published yet */ }
if (!rec) return e.json(404, { error: "no release published yet" })
const filename = rec.getString("file")
// Local storage layout: <dataDir>/storage/<collectionId>/<recordId>/<file>.
const path = $app.dataDir() + "/storage/" + rec.collection().id + "/" + rec.id + "/" + filename
e.response.header().set("Content-Disposition", 'attachment; filename="amber-tv.apk"')
return e.fileFS($os.dirFS($filepath.dir(path)), $filepath.base(path))
})