amber-backend/pb_migrations/1791000000_onboarding_template_codes.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

75 lines
3.7 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// More than one shared-credentials template, each behind its own code.
//
// The family shares one set of paid accounts. A few friends are now getting the
// same treatment, but must NOT land on the family's subscriptions, so there has to
// be a second template with its own credentials and its own code.
//
// **The bug this migration exists to make safe.** Before it, the route checked the
// code and then chose a record with `findRecordsByFilter(..., "-updated", 1, 0)` —
// newest wins, with no reference to the code that was given. One record made that
// invisible. A second record would have made the family code hand out whichever row
// was edited last, and the friends code do the same, silently and with no error.
// The hook is rewritten in the same commit; adding a row before that would have been
// the whole failure.
//
// **Why the code lives on the record and not in a new env var.** A second
// `AMBER_FRIENDS_CODE` would mean a Coolify env change and a redeploy for every
// group, and the codes would sit somewhere other than the credentials they unlock.
// Here a new group is one row in the admin UI: no deploy, each code rotatable on its
// own, and the code is visible next to what it hands out.
//
// **The unique index is partial.** PocketBase text fields default to `''`, not NULL,
// and SQLite treats two empty strings as equal — so a plain unique index would stop
// a second row from existing until both had codes. `WHERE code != ''` is the same
// shape PocketBase uses for its own optional-unique columns (see the `email` index on
// `users`).
//
// **No secret is written here.** `AMBER_INVITE_CODE` is deliberately not copied into
// the family row: a migration that bakes in a credential is one that leaks it into
// every future database dump for no gain. The row is flagged `isDefault` instead, and
// the hook keeps honouring the env var for it until the owner pastes a real code in.
// That also means anyone halfway through setup right now is unaffected.
migrate(
(app) => {
const tpl = app.findCollectionByNameOrId("onboarding_template")
// Which group this is, for the admin list. Never served to clients.
tpl.fields.add(new Field({ type: "text", name: "name", max: 80 }))
// The shared secret that unlocks this row. Still a bearer secret, still
// rate-limited per IP in the hook; being per row is what makes it revocable
// without touching the others.
tpl.fields.add(new Field({ type: "text", name: "code", max: 200 }))
// Which row the legacy AMBER_INVITE_CODE still opens.
tpl.fields.add(new Field({ type: "bool", name: "isDefault" }))
tpl.indexes = (tpl.indexes || []).concat([
"CREATE UNIQUE INDEX idx_onboarding_template_code " +
"ON onboarding_template (code) WHERE code != ''",
])
app.save(tpl)
// Preserve today's behaviour exactly: the row the route is currently handing
// out is the most recently updated one, so that is the one the old code must
// keep opening. Marking every row would make the fallback ambiguous again.
try {
const rows = app.findRecordsByFilter(
"onboarding_template", "id != ''", "-updated", 1, 0)
if (rows && rows.length) {
const r = rows[0]
r.set("isDefault", true)
if (!r.getString("name")) r.set("name", "family")
app.save(r)
}
} catch (_) { /* no template configured yet: nothing to preserve */ }
},
(app) => {
const tpl = app.findCollectionByNameOrId("onboarding_template")
tpl.indexes = (tpl.indexes || []).filter(
(i) => !i.includes("idx_onboarding_template_code"))
tpl.fields.removeByName("name")
tpl.fields.removeByName("code")
tpl.fields.removeByName("isDefault")
app.save(tpl)
},
)