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