amber-backend/pb_hooks/onboarding.pb.js

156 lines
7.9 KiB
JavaScript
Raw Normal View History

/// <reference path="../pb_data/types.d.ts" />
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
// Family onboarding: **accounts are created by the owner**, and the template
// config is served to whoever is already signed in.
//
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
// 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.
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 19:35:46 +00:00
// 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.
//
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
// **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.
//
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 19:35:46 +00:00
// 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 ─────────────────────────────────────────────────────────
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
// 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()
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
throw new BadRequestError(
"Účty zakládá správce — registrace je uzavřená. Napiš Richardovi.")
}, "users")
// ── GET /api/amber/onboarding-template ────────────────────────────────────────
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 19:35:46 +00:00
// 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.
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
//
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 19:35:46 +00:00
// **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).
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
//
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 19:35:46 +00:00
// **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.
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
//
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 19:35:46 +00:00
// 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
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
// Fixed-window per-IP limit (app store — file scope state does not survive the
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 19:35:46 +00:00
// 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 })
}
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 19:35:46 +00:00
const fail = () => {
store.set(key, {
count: (st ? st.count : 0) + 1,
resetAt: st ? st.resetAt : now + WINDOW_SECONDS,
})
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 19:35:46 +00:00
return e.json(403, { error: "invalid code" })
}
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 19:35:46 +00:00
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 {
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 19:35:46 +00:00
const rows = $app.findRecordsByFilter(
"onboarding_template", "code != '' && code = {:code}", "-updated", 1, 0,
{ code: given })
if (rows && rows.length) rec = rows[0]
} catch (_) { /* collection empty */ }
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 19:35:46 +00:00
// 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"),
})
Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app.
2026-08-06 23:54:25 +00:00
}, $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))
})