///
// 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))
})