amber-backend/pb_hooks/onboarding.pb.js
Claude 8f2cb994ec The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:

  findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)

With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".

So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.

Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.

status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.

The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.

Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:35:46 +02:00

155 lines
7.9 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 an admin-maintained plaintext
// shared config, gated by auth AND a group code. There is one row per group
// that the owner shares accounts with (family, friends), each with its own
// credentials and its own code; anyone without a code brings their own
// accounts instead, through the setup flow on the account page.
//
// **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 again: it is no longer a key to
// the front door, and no longer the only group code either. Codes now live on the
// template rows, and the env var is a fallback that opens the row flagged
// `isDefault` so the move to several groups broke nobody mid-setup.
// ── 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=<group code>. → { addonUrl, czechAddonUrl, tmdbKey }. 403 for a code
// that matches nothing, 429 when rate-limited. There is no 404: "no such code" and
// "no row behind that code" are the same answer, because distinguishing them would
// confirm which codes exist.
//
// **One row per group, each with its own code.** The family shares one set of paid
// accounts; friends now get a second set that is deliberately not the family's. So
// the code does not just gate the route, it *selects the row* — which is the part
// that used to be missing (see the migration, and the comment at the lookup below).
//
// **Auth AND a code.** Two gates, because they answer different questions. Auth is
// guaranteed — accounts are owner-created, so anyone reaching setup is signed in —
// but "has an account" must not mean "may have somebody's shared credentials". The
// code is what says which group's credentials, if any, this person is owed. A friend
// with no code brings their own accounts instead, through the setup flow on the
// account page.
//
// AMBER_INVITE_CODE is now only a fallback, opening the row flagged `isDefault`, so
// that the change broke nobody mid-setup and needed no Coolify edit. Give the family
// row its own `code` and the env var stops mattering.
//
// Rate-limited per IP as before, and per IP rather than per code on purpose: guessing
// at one group's code must not earn a fresh budget by switching to another's.
routerAdd("GET", "/api/amber/onboarding-template", (e) => {
const MAX_FAILS = 10
const WINDOW_SECONDS = 300
// Fixed-window per-IP limit (app store — file scope state does not survive the
// isolated handler JSVMs). Counted per IP rather than per code, so guessing at
// one group's code does not get a fresh budget by switching to another's.
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 fail = () => {
store.set(key, {
count: (st ? st.count : 0) + 1,
resetAt: st ? st.resetAt : now + WINDOW_SECONDS,
})
return e.json(403, { error: "invalid code" })
}
const given = (e.request.url.query().get("code") || "").trim()
// An empty code can never match: the filter below requires a non-empty stored
// code, and this returns before it. That is what keeps the route fail-closed on
// an instance where nothing has been configured yet.
if (!given) return fail()
// **The code chooses the record.** It used to check the code and then take the
// most recently updated row regardless, which was invisible with one row and
// would have handed the family's credentials to friends with two. Bound as a
// filter parameter, never concatenated, so a code cannot smuggle in filter
// syntax of its own.
let rec = null
try {
const rows = $app.findRecordsByFilter(
"onboarding_template", "code != '' && code = {:code}", "-updated", 1, 0,
{ code: given })
if (rows && rows.length) rec = rows[0]
} catch (_) { /* collection empty */ }
// Fallback for the original single code, which lives in the environment and not
// on any row. It opens the row flagged `isDefault` (the family), so nobody
// halfway through setup is broken by this change and Coolify needs no edit. Once
// that row carries its own `code`, the env var is redundant and can go.
if (!rec) {
const legacy = ($os.getenv("AMBER_INVITE_CODE") || "").trim()
if (legacy && given === legacy) {
try {
const rows = $app.findRecordsByFilter(
"onboarding_template", "isDefault = true", "-updated", 1, 0)
if (rows && rows.length) rec = rows[0]
} catch (_) { /* no default row */ }
}
}
// A wrong code and a code with no row behind it are the same answer on purpose:
// telling the difference would confirm which codes exist.
if (!rec) return fail()
store.set(key, null)
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))
})