/// // 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=. → { 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: /storage///. 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)) })