From 55444ee55eab751f1276d4bd4071d64e633514c7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 30 Aug 2026 04:09:10 +0200
Subject: [PATCH] Stop choosing an update variant, and drop the 18+ controls
Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.
The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.
whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.
The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.
Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.
Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.
check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
---
docs/auto-update-contract.md | 42 +++++++-------
pb_hooks/status.pb.js | 31 ++++------
pb_hooks/update.pb.js | 24 +++++---
pb_hooks/whoami.pb.js | 20 +++----
pb_public/status.html | 42 ++------------
scripts/check-flavor.py | 107 -----------------------------------
6 files changed, 62 insertions(+), 204 deletions(-)
delete mode 100755 scripts/check-flavor.py
diff --git a/docs/auto-update-contract.md b/docs/auto-update-contract.md
index 283838d..5e90d23 100644
--- a/docs/auto-update-contract.md
+++ b/docs/auto-update-contract.md
@@ -1,7 +1,8 @@
# Auto-update — contract (issue #16)
-The app checks for a newer build and installs it, receiving the **adult** or
-**clean** variant according to its account's admin-set `nsfwEnabled` flag.
+The app checks for a newer build and installs it. One variant ships, `clean`;
+the server used to pick between that and an `adult` one from the account's
+`nsfwEnabled` flag, and no longer does.
Server pieces (this repo):
- `pb_migrations/1786500000_releases.js` — the `releases` collection.
@@ -16,7 +17,7 @@ manifest fetch), the desktop swap-installer, and the Android APK install channel
| Field | Type | Notes |
|---------------|--------|-------|
| `platform` | select | `windows` \| `linux` \| `android`. |
-| `variant` | select | `clean` \| `adult`. |
+| `variant` | select | `clean` \| `adult`. Only `clean` is published now. The field stays because every installed app compares it against its own and refuses a mismatch **silently** — removing it would strand those installs with no error to see. |
| `version` | text | Human semver shown to the user, e.g. `1.0.1`. |
| `buildNumber` | number | Monotonic int; the comparator the updater actually uses. |
| `file` | file | The artifact: windows = `.exe` installer, linux = `.zip` of the release bundle, android = `.apk`. Protected (see rules). |
@@ -30,20 +31,20 @@ Unique index on `(platform, variant, buildNumber)`.
**Access rules.** Read is gated:
`@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)`
— any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads
-**adult** rows. The `file` field is **`protected: true`**, which is what gates the
-bytes: a protected file is served only with a short-lived file token
-(`POST /api/files/token`) whose grant **re-checks the view rule above** — so a
-non-flagged account can't download an adult artifact (and without the flag the
-file URL would be public regardless of the view rule). `create`/`update`/`delete`
+**adult** rows. That rule is deliberately left in place: `adult` rows from before
+the split are still in this collection, and the rule is what keeps them out of
+reach. The `file` field is **`protected: true`**, which is what gates the bytes:
+a protected file is served only with a short-lived file token
+(`POST /api/files/token`) whose grant **re-checks the view rule above** (and
+without the flag the file URL would be public regardless of the view rule). `create`/`update`/`delete`
are **superuser-only** (null rules); publishing goes through the admin API.
## Endpoint
### `GET /api/update/manifest?platform=windows|linux|android` (auth: users)
-Returns the latest build for the caller's platform. The **variant is chosen
-server-side** from `nsfwEnabled` — the client cannot request adult.
+Returns the latest build for the caller's platform, always `variant: "clean"`.
-- No release for that platform/variant → `{ "available": false }`.
+- No release for that platform → `{ "available": false }`.
- Otherwise:
```json
{
@@ -81,16 +82,15 @@ bump `buildNumber` for each release.
the zip. One artifact serves both jobs: people download and run it, and the app's
updater runs the same file with `/SILENT`, which is why `DesktopInstaller`
dispatches on the extension. The zip `release_windows.ps1` still builds beside it
-is for the flavour check and for unpacking by hand — do not publish it.
-
-`check-flavor.py` cannot read a `.exe` (Inno compresses the payload) and refuses
-rather than passing it. The Windows flavour gate is `Assert-Flavor` in
-`release_windows.ps1`, which reads the staged folder before either artifact is
-made from it; run it there, not here.
+is for unpacking by hand — do not publish it.
## Gating summary
-`nsfwEnabled` (admin-set on the `users` record) is the single source of truth:
-the manifest hook reads it to pick the variant, and the collection rules enforce
-it independently at read/download time. A no-flag account only ever sees and
-downloads clean builds.
+There is nothing left to gate between: one variant is published, and the manifest
+hook returns it to everyone.
+
+`nsfwEnabled` still exists on the `users` record and the collection rules still
+read it. Both stay because `adult` rows from before the split are still in the
+collection, and those rules are what keeps them unreachable. Nothing writes the
+flag any more: the manifest hook ignores it, `whoami` no longer returns it, and
+the status dashboard's toggle is gone.
diff --git a/pb_hooks/status.pb.js b/pb_hooks/status.pb.js
index 1b6caf3..c2eb7a6 100644
--- a/pb_hooks/status.pb.js
+++ b/pb_hooks/status.pb.js
@@ -75,16 +75,20 @@ routerAdd("GET", "/api/status", (e) => {
// ---- what the family is being served (local DB, always available) ------
const releases = safe("releases", () => {
- // Newest build per platform+variant. Six rows in practice; a 200-row window
- // is plenty and bounds the query regardless of history size.
- const rows = $app.findRecordsByFilter("releases", "id != ''", "-buildNumber", 200, 0)
+ // Newest build per platform. Three rows in practice; a 200-row window is
+ // plenty and bounds the query regardless of history size.
+ //
+ // Filtered to variant='clean' rather than reading whatever is newest: rows
+ // from the 18+ flavour are still in this collection, and a leftover one
+ // could otherwise be reported as what the family is being served.
+ const rows = $app.findRecordsByFilter(
+ "releases", "variant = 'clean'", "-buildNumber", 200, 0)
const newest = {}
for (const r of rows) {
- const key = r.getString("platform") + ":" + r.getString("variant")
+ const key = r.getString("platform")
if (newest[key]) continue
newest[key] = {
platform: r.getString("platform"),
- variant: r.getString("variant"),
version: r.getString("version"),
build: r.getInt("buildNumber"),
size: r.getInt("size"),
@@ -129,9 +133,7 @@ routerAdd("GET", "/api/status", (e) => {
const accounts = safe("accounts", () => {
const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0)
- let adult = 0
- for (const r of rows) if (r.getBool("nsfwEnabled")) adult++
- return { total: rows.length, adult: adult, clean: rows.length - adult }
+ return { total: rows.length }
})
// ---- containers (Coolify) --------------------------------------------
@@ -243,16 +245,7 @@ routerAdd("GET", "/api/status/errors", (e) => {
const ev = r.getString("event") || ""
return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error"
}
- // Forward-compatible: works the moment the app starts sending a flavour.
- const flavourOf = (r) => {
- const top = r.getString("flavor") || r.getString("flavour")
- if (top) return top
- const meta = r.getString("meta") || ""
- const m = /"flavou?r"\s*:\s*"([a-z]+)"/.exec(meta)
- return m ? m[1] : "neznámá"
- }
-
- const days_ = {}, byVersion = {}, byPlatform = {}, byFlavour = {}, byEvent = {}
+ const days_ = {}, byVersion = {}, byPlatform = {}, byEvent = {}
// Pre-seed every day so the graph has no gaps — a missing bar and a zero bar
// mean different things and must not look alike.
for (let i = 0; i < days; i++) {
@@ -272,7 +265,6 @@ routerAdd("GET", "/api/status/errors", (e) => {
if (days_[day]) { days_[day].total++; if (err) days_[day].errors++ }
bump(byVersion, r.getString("appVersion") || "?", err)
bump(byPlatform, r.getString("platform") || "?", err)
- bump(byFlavour, flavourOf(r), err)
if (err) {
const ev = r.getString("event") || "?"
byEvent[ev] = (byEvent[ev] || 0) + 1
@@ -293,7 +285,6 @@ routerAdd("GET", "/api/status/errors", (e) => {
days: Object.keys(days_).sort().map((k) => days_[k]),
byVersion: list(byVersion),
byPlatform: list(byPlatform),
- byFlavour: list(byFlavour),
topErrorEvents: Object.keys(byEvent).sort((a, b) => byEvent[b] - byEvent[a])
.slice(0, 8).map((k) => ({ key: k, errors: byEvent[k] })),
})
diff --git a/pb_hooks/update.pb.js b/pb_hooks/update.pb.js
index 7359751..3d2e447 100644
--- a/pb_hooks/update.pb.js
+++ b/pb_hooks/update.pb.js
@@ -4,13 +4,19 @@
//
// GET /api/update/manifest?platform=windows|linux|android (auth: users)
//
-// Returns the latest published build for the caller's platform. The VARIANT is
-// chosen server-side from the account's admin-set `nsfwEnabled` (adult) vs clean
-// — the client never asks for adult. This is belt-and-suspenders on top of the
-// `releases` collection rules, which already hide adult rows from non-flagged
-// accounts. The download is PocketBase's native protected file endpoint: the app
-// mints a short-lived file token (`POST /api/files/token`) and appends it to
-// `downloadPath`, and the token grant re-checks the same gating rule.
+// Returns the latest published build for the caller's platform. The download is
+// PocketBase's native protected file endpoint: the app mints a short-lived file
+// token (`POST /api/files/token`) and appends it to `downloadPath`, and the token
+// grant re-checks the collection rule.
+//
+// `variant` used to be chosen here from the account's admin-set `nsfwEnabled`,
+// picking an 18+ build over the clean one. Amber has no 18+ build any more, so it
+// is a constant.
+//
+// It is NOT removed from the response, and must not be. Every Amber already
+// installed compares this field against its own and refuses a mismatch SILENTLY
+// — no error, no prompt, the update simply never appears. Drop it, or send
+// anything but "clean", and those installs stop updating with nothing to see.
//
// NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so the
// small helpers live inside the handler.
@@ -19,8 +25,8 @@ routerAdd("GET", "/api/update/manifest", (e) => {
const platform = (e.request.url.query().get("platform") || "").toLowerCase().trim()
if (ALLOWED.indexOf(platform) === -1) throw new BadRequestError("bad or missing platform")
- // The account flag decides the channel; a client can't opt into adult.
- const variant = e.auth.getBool("nsfwEnabled") ? "adult" : "clean"
+ // One build ships. See the note above for why this field survives at all.
+ const variant = "clean"
let rec = null
try {
diff --git a/pb_hooks/whoami.pb.js b/pb_hooks/whoami.pb.js
index 8499685..3569821 100644
--- a/pb_hooks/whoami.pb.js
+++ b/pb_hooks/whoami.pb.js
@@ -2,7 +2,7 @@
// Token introspection for amber-api (the facts service).
//
-// GET /api/amber/whoami (auth: users) → { id, nsfwEnabled }
+// GET /api/amber/whoami (auth: users) → { id }
//
// WHY THIS EXISTS: amber-api has to know whether an inbound request carries a
// valid Amber user token before it will spend an ffprobe on a caller-supplied
@@ -13,13 +13,14 @@
// which `$apis.requireAuth("users")` does before this handler ever runs.
//
// So the handler body is deliberately trivial: reaching it *is* the answer. It
-// returns the two facts amber-api needs and nothing else — no email, no
-// username, no profile list. A token-introspection endpoint is a tempting place
-// to leak account data, and amber-api has no business knowing any of it.
+// returns the one fact amber-api needs and nothing else — no email, no username,
+// no profile list. A token-introspection endpoint is a tempting place to leak
+// account data, and amber-api has no business knowing any of it.
//
-// `nsfwEnabled` is included because it's the same admin-only flag that gates
-// adult release channels, and a future amber-api surface may need to scope
-// results by it. It is read-only here.
+// It used to return `nsfwEnabled` too, on the reasoning that a future amber-api
+// surface might scope results by it. No such surface was ever built, adult
+// content is not something Amber serves, and an account flag nobody reads is
+// exactly the kind of thing this endpoint should not be handing out.
//
// amber-api caches positives for ~10 minutes and negatives for ~30 seconds, so
// this route sees roughly one request per device per 10 minutes — cheap enough
@@ -29,8 +30,5 @@
// anything a handler needs must be declared INSIDE it — file-scope helpers throw
// ReferenceError at request time. Nothing is hoisted here; keep it that way.
routerAdd("GET", "/api/amber/whoami", (e) => {
- return e.json(200, {
- id: e.auth.id,
- nsfwEnabled: e.auth.getBool("nsfwEnabled"),
- })
+ return e.json(200, { id: e.auth.id })
}, $apis.requireAuth("users"))
diff --git a/pb_public/status.html b/pb_public/status.html
index 160a036..3ddebe3 100644
--- a/pb_public/status.html
+++ b/pb_public/status.html
@@ -290,12 +290,12 @@ function panelReleases(rs){
esc(rs && rs.error || "—") + "
";
if (!rs.length) return '
Vydání
nic zveřejněno
';
var body = rs.map(function(r){
- return "
" + esc(r.platform) + "
" + esc(r.variant) +
+ return "
" + esc(r.platform) +
"
" + esc(r.version) + "+" + r.build +
"
" + esc(r.sha8) + "
" + bytes(r.size) + "
";
}).join("");
return '
Vydání — co se rozdává
' +
- "
platforma
varianta
verze
sha256
velikost
" +
+ "
platforma
verze
sha256
velikost
" +
body + "
" +
'
Buildy se nemusí shodovat mezi platformami — když se vydává jen ' +
"pro počítače, televize zůstane na starším čísle a nedostane výzvu k aktualizaci." +
@@ -345,8 +345,6 @@ function panelAccounts(a){
if (!a || a.error) return "";
return '
Historie je omezená retencí: client_logs se každou noc čistí ' +
@@ -658,13 +655,8 @@ async function setNoticeActive(id, to){
function viewAdmin(users, tpls){
var rows = (users || []).map(function(u){
return "
" + esc(u.email || u.username || u.id) + "
" +
- "
" + (u.nsfwEnabled ? '18+'
- : 'clean') + "
" +
"
" + esc(u.ratingDefault || "—") + "
" +
- "
" + esc(String(u.created).substring(0, 10)) + "
" +
- '
";
+ "
" + esc(String(u.created).substring(0, 10)) + "
";
}).join("");
return '
Nový účet
' +
@@ -674,9 +666,6 @@ function viewAdmin(users, tpls){
'
";
}
}
-async function setNsfw(id, to){
- try {
- var r = await fetch("/api/collections/users/records/" + encodeURIComponent(id), {
- method: "PATCH",
- headers: { "Content-Type": "application/json", Authorization: tok },
- body: JSON.stringify({ nsfwEnabled: to === "1" }),
- });
- if (!r.ok) throw new Error("HTTP " + r.status);
- load();
- } catch (e) {
- el("main").insertAdjacentHTML("afterbegin",
- '
' + esc(e.message) + "
");
- }
-}
function wire(){
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
});
- Array.prototype.forEach.call(el("main").querySelectorAll("button[data-nsfw]"), function(b){
- b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
- });
if (el("createBtn")) el("createBtn").onclick = createAccount;
if (el("nSave")) el("nSave").onclick = saveNotice;
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){
diff --git a/scripts/check-flavor.py b/scripts/check-flavor.py
deleted file mode 100755
index d49c0f9..0000000
--- a/scripts/check-flavor.py
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/usr/bin/env python3
-"""Verify a built artifact's flavour without installing or launching it.
-
- check-flavor.py
-
-Exits non-zero unless the artifact contains exactly one `AMBER_FLAVOR::` marker
-and it matches. Handles an `.apk`, a Linux bundle `.zip`, a Windows bundle `.zip`
-and an unpacked build **directory** — it finds the Dart AOT snapshot inside each.
-
-A Windows `.exe` installer cannot be checked here and is refused rather than
-passed: Inno LZMA-compresses the payload, so the marker only exists in a form
-nothing can grep. That is why the release script gates the staged folder before
-packaging instead of gating the artifact afterwards.
-
-**Why this exists.** The clean flavour is what anonymous downloads and every
-child profile receive, so publishing an adult build into a clean slot is the
-worst mistake this release process can make — and until now the only way to tell
-the two apart offline was to install the APK on a television and look at the tab
-row. That made a content-safety gate depend on a TV being awake and on adb still
-being authorised, which is exactly how it gets skipped "just this once".
-
-Nothing cheaper worked. Class names such as `AdultBrowse` survive in **both**
-snapshots whatever the dart-define, and the `'adult'`/`'clean'` strings the
-updater compares sit inside a function body, so both literals ship in both
-builds. The two APKs are also routinely **byte-identical in size** (zip
-alignment absorbs the difference), so size proves nothing either. `config.dart`
-therefore compiles in a deliberate const-folded marker; see [kFlavorMarker].
-"""
-import os
-import re
-import sys
-import zipfile
-
-MARKER = re.compile(rb'AMBER_FLAVOR::(adult|clean)')
-
-# Where the Dart AOT snapshot lives, per artifact kind.
-SNAPSHOTS = (
- 'lib/arm64-v8a/libapp.so', # apk
- 'lib/armeabi-v7a/libapp.so', # apk, 32-bit
- 'lib/libapp.so', # linux bundle zip
- 'data/app.so', # windows bundle zip
-)
-
-
-def markers_in(blob: bytes) -> set:
- return {m.group(1).decode() for m in MARKER.finditer(blob)}
-
-
-def main(path: str, expected: str) -> int:
- if expected not in ('adult', 'clean'):
- print(f'error: expected must be adult|clean, got {expected!r}')
- return 2
-
- if path.lower().endswith('.exe'):
- # Say why rather than throwing a BadZipFile, and fail rather than pass:
- # a checker that prints something reassuring about an artifact it never
- # read is worse than no checker.
- print(f'FAIL {path}: an Inno installer LZMA-compresses its payload, so '
- 'the marker is not readable here.\n'
- ' The gate for a .exe is Assert-Flavor in '
- 'amber-app/scripts/release_windows.ps1, which reads the staged\n'
- ' folder BEFORE packaging. Point this script at that folder, '
- 'or at the .zip built beside the installer.')
- return 1
-
- found = set()
- checked = []
- if os.path.isdir(path):
- # The staged build folder, which is where the truth actually lives.
- for candidate in SNAPSHOTS:
- f = os.path.join(path, candidate.replace('/', os.sep))
- if os.path.isfile(f):
- checked.append(candidate)
- with open(f, 'rb') as fh:
- found |= markers_in(fh.read())
- else:
- with zipfile.ZipFile(path) as z:
- names = set(z.namelist())
- for candidate in SNAPSHOTS:
- if candidate in names:
- checked.append(candidate)
- found |= markers_in(z.read(candidate))
-
- if not checked:
- # Better to fail loudly than to pass an artifact nothing was read from.
- print(f'FAIL {path}: no Dart snapshot found (looked for {SNAPSHOTS})')
- return 1
- if not found:
- print(f'FAIL {path}: no AMBER_FLAVOR marker in {checked} — '
- 'built before the marker existed?')
- return 1
- if len(found) > 1:
- print(f'FAIL {path}: ambiguous, contains {sorted(found)}')
- return 1
-
- actual = found.pop()
- if actual != expected:
- print(f'FAIL {path}: is {actual!r}, expected {expected!r}')
- return 1
- print(f'ok {path}: {actual} (from {", ".join(checked)})')
- return 0
-
-
-if __name__ == '__main__':
- if len(sys.argv) != 3:
- raise SystemExit(__doc__)
- raise SystemExit(main(sys.argv[1], sys.argv[2]))