amber-backend/pb_hooks/onboarding.pb.js
Claude 0b65fd7f49 Family onboarding site: invite-gated signup, template config, /get/tv
Distribution site served by PocketBase itself (pb_public/, same origin):
- Landing (/): create-account CTA, TV sign-in steps, login-gated PC
  downloads via the existing update manifest + file tokens
- Wizard (/onboarding.html): invite code -> account -> named default
  profile -> family template fetched and encrypted IN THE BROWSER under
  the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with
  the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified)
  -> pushed as their addon_config ciphertext. Adult fields never included.
- /get/tv: public direct download of the latest clean Android APK
  (Downloader-friendly; adult builds stay account-gated)

Backend:
- onboarding_template collection (admin-only, maintained via admin UI)
- AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header
  or ?invite=) and the template route (per-IP rate limit 10/5min);
  fail-closed when unset. Note: this closes the previously-open in-app
  registration too.
- Dockerfile ships pb_public + --publicDir; compose passes the env var

Contract + Coolify steps (second domain amber.petruzalekr.cz) in
docs/onboarding-contract.md. Verified locally end-to-end against
pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:23:05 +02:00

107 lines
4.9 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// 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):
//
// 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.
//
// Fail-closed: with AMBER_INVITE_CODE unset, registration and the template
// route are both disabled (a private instance must not silently fall open).
// ── users create gate ─────────────────────────────────────────────────────────
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()
}, "users")
// ── GET /api/amber/onboarding-template ────────────────────────────────────────
// Query: ?code=<invite>. → { 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.
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, same shape as verify-pin's (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 invite 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"),
})
})
// ── 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))
})