Drop the 18+ account flag, its rule clause and the web page's 18+ field

Nothing read `users.nsfwEnabled` any more. `/api/amber/whoami` stopped returning
it, `GET /api/update/manifest` always answers `clean`, and the `releases` read
rule stopped mentioning it in migration 1793000000, when the nine 18+ rows (437
MB) were deleted. What was left was a per-account flag nobody writes and nobody
reads, sitting on the family's accounts and showing up in every schema dump and
access-rule review as if it still gated something. A flag that looks like a
protection but protects nothing costs the next reader the work of proving it dead.

One live reader did remain: pb_public/index.html showed a "Zdroj 18+" source
field and wrote `adultAddonUrl` into the encrypted addon config for a flagged
account, and picked a different child-profile label for one. Both are gone, so
the page offers the same three sources to everyone.

Migration 1797000000 drops the field, rewrites users.updateRule to
`id = @request.auth.id` (the clause it loses guarded a field that no longer
exists), and removes `adult` from releases.variant's allowed values. Checked
before writing it: 15 release rows, every one `clean`; three accounts carried the
flag (the owner's, one family member's, the retired test account); the separate
18+ app has no accounts and no PocketBase, so nothing outside Amber reads it.

`variant` itself stays and stays required: every installed Amber compares it
against its own build and refuses a mismatch SILENTLY, so dropping the field
would strand those installs with no error to see. publish-release.sh now accepts
only `clean`.

The status dashboard stops listing `adult-stremio-addon` and `stripchat-proxy`.
Both still run on the VPS; they are the owner's, not Amber's, and Amber never
reached them.

scripts/verify.py asserts the flag stays gone (field absent, no rule mentions it,
updateRule own-record-only). Run against a local PocketBase built from origin/main
it failed on exactly those three checks; with this migration applied, ALL CHECKS
PASSED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-17 20:32:02 +02:00
parent 580f43d19d
commit fb9abaae17
10 changed files with 123 additions and 94 deletions

View file

@ -27,12 +27,11 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro
│ ├─1 prefs (playback/audio/subtitle blob)
│ └─1 addon_config (client-encrypted addon blob; ciphertext only)
├─* device_auth (codeless sign-in requests / approved devices, issue #12)
└ nsfwEnabled (admin-only bool, legacy: see below)
```
| Collection | Type | Key fields |
|----------------|------|------------|
| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only, legacy) |
| `users` | auth | `username` (unique when set), `email`, `password`, `ratingDefault` (account-wide content ceiling) |
| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) |
| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) |
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) |
@ -41,34 +40,22 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro
| `device_auth` | base | `code` (unique), `deviceName`, `status`, `user`→users, `expiresAt`, + hidden `authToken`/`keyCiphertext`/`pollSecret`/`devicePubKey` — codeless device sign-in (#12) |
**Access rules.** A user only ever reads/writes their own `users` row, their own
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
accepted from a client (`@request.body.nsfwEnabled:isset = false` on create and
update) — only a **superuser** sets it, from the admin UI. Relations
`profiles`, and rows whose `profile.user` is them. Accounts are created by a
superuser only (registration is closed, migration 1789000000). Relations
`cascadeDelete`, so deleting a user removes their profiles and all child rows.
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
`prefs`/`addon_config` row per profile.
### `nsfwEnabled` is legacy
### There is no 18+ anything here
There is **one Amber build**. The flag used to choose between an 18+ build and the
clean one; the 18+ section left Amber to become a separate app, and none of that
gating is left in this repo:
One Amber build ships. An account flag `nsfwEnabled` used to choose between an
18+ build and the clean one; the 18+ section left Amber to become a separate app,
and the flag was dropped in `pb_migrations/1797000000_drop_nsfw_enabled.js` along
with `adult` as a publishable variant. `scripts/verify.py` asserts it stays gone.
- `GET /api/update/manifest` (`pb_hooks/update.pb.js`) always serves variant
`clean`, whatever the flag says. The `variant` field stays in the response
because every installed Amber silently refuses a mismatch.
- The `releases` read rules no longer mention it
(`pb_migrations/1793000000_releases_drop_adult_rule.js`): any signed-in account
reads releases.
- `/api/amber/whoami` no longer returns it, and the status dashboard is gated on
superuser credentials, never on this flag.
- The Amber app neither reads nor sends it.
What remains: the field is still on `users`, still admin-only by rule, and has
exactly one reader, the family web page (`pb_public/index.html`). For an account
with the flag set, that page shows an adult addon URL field on the sources tab
(saved inside the encrypted addon config) and mentions 18+ in the child-profile
label. Nothing else reads it.
`releases.variant` itself is **not** going anywhere: it is always `clean`, and
every installed Amber compares it against its own and refuses a mismatch
silently, so dropping the field would strand those installs with no error to see.
### Encrypted addon config (issue #20)
@ -140,8 +127,8 @@ docker compose exec pocketbase pocketbase superuser upsert you@example.com 'a-st
# admin UI: http://localhost:8090/_/
```
The end-to-end access-rule check (two users can't see each other's data; a user
can't set their own `nsfwEnabled`; a superuser can) lives in
The end-to-end access-rule check (two users can't see each other's data; the
retired 18+ flag is gone from the schema and the rules) lives in
[`scripts/verify.py`](scripts/verify.py):
```bash
@ -226,7 +213,7 @@ Only needed to rebuild the Coolify app. Secrets never live in this repo.
Account/session tokens and the addon plaintext-credential "tokens" are **never
logged or echoed** (see the app's `CLAUDE.md`). `pinHash` is a hidden field and
never leaves the API. `nsfwEnabled` is admin-only by rule, not just by UI.
never leaves the API.
## Upgrading PocketBase

View file

@ -1,8 +1,8 @@
# Auto-update — contract (issue #16)
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.
The app checks for a newer build and installs it. One variant ships, `clean`.
The server used to pick between that and an 18+ one from an account flag; that
flag and that build are both gone (`pb_migrations/1797000000_drop_nsfw_enabled.js`).
Server pieces (this repo):
- `pb_migrations/1786500000_releases.js` — the `releases` collection.
@ -17,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`. 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. |
| `variant` | select | `clean`, the only value. 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). |
@ -28,12 +28,9 @@ manifest fetch), the desktop swap-installer, and the Android APK install channel
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. 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:
**Access rules.** Read needs a signed-in account (`@request.auth.id != ''`) and
nothing more: one variant is published and there is nothing left to gate between.
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`
@ -89,8 +86,6 @@ is for unpacking by hand — do not publish it.
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.
The 18+ flag is gone from `users` and `adult` is gone from `variant`'s allowed
values (`pb_migrations/1797000000_drop_nsfw_enabled.js`). The manifest hook
serves `clean` to every signed-in account.

View file

@ -40,8 +40,8 @@ Returns the admin-maintained plaintext family config:
- `403` bad code · `429` after 10 bad codes / 5 min / IP · `404` no template
record · `503` env unset.
- **Deliberately no adult fields** — the template is family config by
definition (mirrors the #14/#20 child stripping). Adult stays per-account.
- **Deliberately no 18+ fields** — the template is family config by
definition (mirrors the #14/#20 child stripping), and Amber has no 18+ content.
- Plaintext travels once over TLS to the wizard, which encrypts it under the
*new user's* password (PBKDF2-HMAC-SHA256 210k → AES-256-GCM, blob =
`base64(nonce[12] ‖ ct ‖ tag[16])`, `kdf = pbkdf2-sha256-210000`) and pushes
@ -53,7 +53,7 @@ Returns the admin-maintained plaintext family config:
Streams the newest `releases` row with `platform=android && variant=clean` as
`amber-tv.apk` (attachment). Public by design: the TV-sideload path (the
Downloader app needs a plain URL, a fresh TV has no account, and the APK is
useless without one). Adult builds remain account-gated, unchanged.
useless without one). One build ships, so there is nothing else to serve.
## Template maintenance (admin)
@ -102,7 +102,7 @@ signup refused without/with wrong code (Czech error) and accepted via header
and query · template route 403/200/429(10 per 5 min)/404 paths · full wizard
run in a real browser produced the user, the named profile, and an
`addon_config` blob that **decrypted with the app's Dart `AddonConfigCrypto`**
with every field intact (adult fields null/false) · `/get/tv` streamed a
with every field intact · `/get/tv` streamed a
published dummy APK byte-identical with the attachment header · landing's
download login handled the no-releases state.

View file

@ -133,7 +133,7 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => {
// 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.
// One variant is published, so this is the whole set of Android artifacts.
routerAdd("GET", "/get/tv", (e) => {
let rec = null
try {

View file

@ -14,9 +14,9 @@
//
// **Superuser-gated, not user-gated.** There is no "operator" flag on `users` and
// inventing one would need a migration; the owner already has superuser
// credentials for /_/, so that is the honest gate. `nsfwEnabled` was considered
// and rejected — it is a content flag, and using it as an admin flag would mean
// any adult-enabled family member could read infrastructure logs.
// credentials for /_/, so that is the honest gate. A field on `users` was
// considered and rejected: a content or convenience flag pressed into service as
// an admin flag is how family members end up reading infrastructure logs.
//
// **This is the first hook in the project to make an outbound HTTP request.**
// Everything before it read only PocketBase's own database (see the note in
@ -154,7 +154,7 @@ routerAdd("GET", "/api/status", (e) => {
// Only Amber's own services; the same instance also hosts unrelated apps.
const mine = [
"amber-backend", "amber-api", "stremio_prehrajto", "Stremio_Torbox",
"adult-stremio-addon", "myanime-pair-server", "stripchat-proxy",
"myanime-pair-server",
]
const out = []
for (const a of apps) {
@ -220,9 +220,10 @@ routerAdd("GET", "/api/status", (e) => {
// worst. Every version row therefore carries total, errors and errors/total.
//
// **Flavour is not recorded yet.** The app's TelemetryService sends appVersion,
// platform and device, but nothing carries clean-vs-adult. This reads a top-level
// `flavor` field first and then `meta.flavor`, so the day the app starts sending
// it this endpoint needs no change; until then the column honestly reads unknown.
// platform and device, but nothing carries the build's variant. This reads a
// top-level `flavor` field first and then `meta.flavor`, so the day the app
// starts sending it this endpoint needs no change; until then the column reads
// unknown.
//
// The window is capped by retention, not by choice: client_logs.pb.js trims to 14
// days on a nightly cron, so nothing older exists to graph.

View file

@ -9,9 +9,9 @@
// 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.
// `variant` used to be chosen here from an account flag, picking an 18+ build
// over the clean one. Amber has no 18+ build any more and the flag is gone
// (migration 1797000000), 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

View file

@ -0,0 +1,57 @@
/// <reference path="../pb_data/types.d.ts" />
// Remove the last of the 18+ scaffolding: the `nsfwEnabled` flag on `users`, the
// rule clause that guarded it, and `adult` as a publishable release variant.
//
// WHY NOW: nothing reads the flag any more. `/api/amber/whoami` stopped
// returning it, `GET /api/update/manifest` always serves `clean`, the `releases`
// read rule stopped mentioning it in migration 1793000000 (the nine 18+ rows,
// 437 MB, were deleted then), and the web settings page stopped showing an 18+
// source field in this same change. What was left was a per-account flag nobody
// writes and nobody reads, sitting on the family's accounts and appearing in
// every schema dump, README table and access-rule review as if it still gated
// something. A flag that looks like a protection but protects nothing is worse
// than no flag: the next person to read the schema has to prove it is dead.
//
// `users.updateRule` loses `@request.body.nsfwEnabled:isset = false`. The clause
// was the structural guarantee that a signed-in person could not flip their own
// flag. With the field gone there is nothing to flip, and PocketBase rejects an
// unknown field on write anyway. `createRule` is already superuser-only (null,
// migration 1789000000), so it needs no change.
//
// `releases.variant` KEEPS EXISTING and keeps being required -- only `adult`
// leaves its allowed values. Every installed Amber compares the manifest's
// variant against its own and refuses a mismatch **silently**, so removing the
// field would strand every install with no error to see. Publishing stays
// exactly as it is, minus a choice nobody can make any more.
//
// The down migration restores all three, so an instance can be rolled back. It
// cannot restore the per-account VALUES of the flag: dropping a field drops its
// data. That is intended -- the owner asked for the 18+ leftovers to be gone,
// and the only account that had it set was a test account being retired with it.
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users")
users.updateRule = "id = @request.auth.id"
users.fields.removeByName("nsfwEnabled")
app.save(users)
const releases = app.findCollectionByNameOrId("releases")
const variant = releases.fields.getByName("variant")
variant.values = ["clean"]
app.save(releases)
},
(app) => {
const users = app.findCollectionByNameOrId("users")
users.fields.add(new BoolField({ name: "nsfwEnabled" }))
users.updateRule =
"id = @request.auth.id && @request.body.nsfwEnabled:isset = false"
app.save(users)
const releases = app.findCollectionByNameOrId("releases")
const variant = releases.fields.getByName("variant")
variant.values = ["clean", "adult"]
app.save(releases)
},
)

View file

@ -393,11 +393,6 @@
<input id="adCzech" type="text" spellcheck="false" placeholder="https://…">
<label for="adTmdb">TMDB klíč</label>
<input id="adTmdb" type="text" spellcheck="false">
<div id="adAdultBox" class="hidden">
<hr class="sep">
<label for="adAdult">Zdroj 18+</label>
<input id="adAdult" type="text" spellcheck="false" placeholder="https://…">
</div>
<p class="hint">Prázdné pole zdroj z účtu odebere.</p>
<button id="adSave" class="btn primary">Uložit zdroje</button>
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
@ -632,16 +627,11 @@ async function enter(){
loadProviders(), loadConfigList()]);
$("acEmail").value=(USER&&USER.email)||"";
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
// **Do not mention 18+ to an account that does not have it.** This label used to
// read "skryje anime a 18+" for everyone, which told most of the household that a
// section they have never seen and cannot reach exists at all. Three of the four
// accounts are `nsfwEnabled = false`, and for them the sentence was also simply
// untrue: there is no 18+ content to hide. The adult source field on the Zdroje
// tab has always been gated on the same flag; this label was the one place that
// was not.
$("pfChildLabel").textContent = (USER && USER.nsfwEnabled)
? "Dětský profil (skryje anime a 18+, na odchod chce PIN)"
: "Dětský profil (skryje anime, na odchod chce PIN)";
// Amber has no 18+ content, so this label does not mention any. It used to read
// "skryje anime a 18+" behind an account flag, which told the household that a
// section they have never seen and cannot reach exists at all.
$("pfChildLabel").textContent =
"Dětský profil (skryje anime, na odchod chce PIN)";
renderAddonGate();
renderStart();
// An explicit #hash always wins — a bookmark or a link must land where it says.
@ -1157,10 +1147,6 @@ async function loadAddons(){
$("adTorbox").value=cfg.addonUrl||"";
$("adCzech").value=cfg.czechAddonUrl||"";
$("adTmdb").value=cfg.tmdbKey||"";
if (USER && USER.nsfwEnabled){
$("adAdultBox").className="";
$("adAdult").value=cfg.adultAddonUrl||"";
}
}
$("acChange").disabled = !PASSWORD;
// Unlocking or saving here is what finishes steps 4 and 5, so the flow has to
@ -1288,7 +1274,6 @@ $("adSave").onclick=async function(){
var next={ addonUrl:$("adTorbox").value.trim(),
czechAddonUrl:$("adCzech").value.trim(),
tmdbKey:$("adTmdb").value.trim() };
if (USER && USER.nsfwEnabled) next.adultAddonUrl=$("adAdult").value.trim();
await saveConfig(next, $("adSave"));
};

View file

@ -38,7 +38,7 @@ die() { echo "error: $*" >&2; exit 1; }
[[ -n "$PLATFORM" && -n "$VARIANT" && -n "$VERSION" && -n "$BUILD" && -n "$FILE" ]] \
|| die "need --platform --variant --version --build --file"
[[ "$PLATFORM" =~ ^(windows|linux|android)$ ]] || die "platform must be windows|linux|android"
[[ "$VARIANT" =~ ^(clean|adult)$ ]] || die "variant must be clean|adult"
[[ "$VARIANT" =~ ^clean$ ]] || die "variant must be clean (18+ builds are gone)"
[[ "$BUILD" =~ ^[0-9]+$ ]] || die "build must be an integer"
[[ -f "$FILE" ]] || die "file not found: $FILE"

View file

@ -8,7 +8,8 @@ Overridable via env: PB_BASE, PB_ADMIN_EMAIL, PB_ADMIN_PASS.
python scripts/verify.py
Creates test users/profiles/rows, then asserts users can't see or touch each
other's data and can't set their own admin-only `nsfwEnabled`. Exits non-zero
other's data. Also asserts the retired 18+ flag `nsfwEnabled` is gone from the
schema and from every rule, so it cannot come back unnoticed. Exits non-zero
if any check fails.
"""
import json, os, sys, urllib.request, urllib.error
@ -47,11 +48,16 @@ admin = r["token"]
# --- schema assertions ---
st, users = req("GET", "/api/collections/users", admin)
fnames = {f["name"] for f in users["fields"]}
check("users has username + nsfwEnabled", st, {"username", "nsfwEnabled"} <= fnames)
check("users.createRule blocks nsfwEnabled",
st, "nsfwEnabled:isset = false" in (users["createRule"] or ""))
check("users.updateRule blocks nsfwEnabled",
st, "nsfwEnabled:isset = false" in (users["updateRule"] or ""))
check("users has username", st, "username" in fnames)
# The 18+ flag is retired (migration 1797000000). A field nobody reads but that
# still sits on every account reads like a protection that is still doing
# something, so its absence is asserted rather than assumed.
check("users has NO nsfwEnabled field", st, "nsfwEnabled" not in fnames)
check("no users rule mentions nsfwEnabled", st, not any(
"nsfw" in (users[k] or "").lower()
for k in ("listRule", "viewRule", "createRule", "updateRule", "deleteRule")))
check("users.updateRule is own-record-only",
st, (users["updateRule"] or "") == "id = @request.auth.id")
for name in ("profiles", "watch_state", "watchlist", "prefs"):
st, c = req("GET", f"/api/collections/{name}", admin)
@ -133,16 +139,16 @@ check("POST /api/device-auth/request is served (hook loaded)", st, st == 200)
st, _ = req("GET", "/api/device-auth/poll?id=nope&secret=nope")
check("GET /api/device-auth/poll is served", st, st in (200, 400, 403, 404))
# --- create two users (superuser create; nsfwEnabled set by admin) ---
# --- create two users (superuser create; registration is closed) ---
def make_user(email, uname):
st, r = req("POST", "/api/collections/users/records", admin, {
"email": email, "password": "Passw0rd!23", "passwordConfirm": "Passw0rd!23",
"username": uname, "nsfwEnabled": True,
"username": uname,
})
return st, r
st, ua = make_user("alice@example.com", "alice")
check("superuser CAN create user + set nsfwEnabled", st, st == 200 and ua.get("nsfwEnabled") is True)
check("superuser CAN create a user", st, st == 200 and ua.get("id"))
st, ub = make_user("bob@example.com", "bob")
check("second user created", st, st == 200)
@ -230,12 +236,12 @@ st, r = req("POST", "/api/collections/profiles/records", bob,
{"user": ua["id"], "name": "sneaky"})
check("bob CANNOT create a profile owned by alice", st, st in (400, 403, 404))
# --- alice cannot flip her own nsfwEnabled ---
# --- the retired 18+ flag cannot be reintroduced from a client ---
st, r = req("PATCH", f"/api/collections/users/records/{ua['id']}", alice,
{"nsfwEnabled": False})
check("alice CANNOT change her nsfwEnabled (rule blocks)", st, st in (400, 403, 404))
{"nsfwEnabled": True})
st, me = req("GET", f"/api/collections/users/records/{ua['id']}", alice)
check("alice's nsfwEnabled still True after attempt", st, me.get("nsfwEnabled") is True)
check("alice's record carries no nsfwEnabled after trying to set it",
st, "nsfwEnabled" not in me)
# --- alice cannot create watch_state under bob's (nonexistent-to-her) profile ---
st, pb = req("POST", "/api/collections/profiles/records", bob, {"user": ub["id"], "name": "Bob Main"})
@ -258,11 +264,9 @@ check("whoami: bogus token → 401", st, st == 401)
st, r = req("GET", "/api/amber/whoami", alice)
check("whoami: valid token → 200", st, st == 200)
check("whoami returns the caller's own id", st, r.get("id") == ua["id"])
check("whoami returns nsfwEnabled", st, r.get("nsfwEnabled") is True)
# A token-introspection endpoint is a tempting place to leak account data;
# amber-api has no business knowing the email, username or profile list.
check("whoami leaks nothing else", st,
set(r.keys()) == {"id", "nsfwEnabled"})
check("whoami leaks nothing else", st, set(r.keys()) == {"id"})
# Per-caller, not "some authenticated user": a shared/cached answer here would
# let amber-api attribute one device's requests to another account.