Commit graph

59 commits

Author SHA1 Message Date
eaff4abbca Make the reset link look like it belongs
Every other interactive element on the account page is amber; the browser
default made this one blue and underlined, which reads as something pasted in
from another site — not the impression to give somebody who already thinks they
have lost their account.

Spotted in a screenshot of the rendered page, not in the markup.
2026-09-05 19:08:37 +02:00
fb59f504fe Czech account emails, and a reset that finishes on our own domain
The reset email works — confirmed by a delivered message, after every attempt on
record had failed at the lookup and the mail path had never once been exercised.
What the delivered message showed was a different problem: it was in English,
and its button pointed at `pb.petruzalekr.cz/_/#/auth/confirm-password-reset/…`,
which is PocketBase's admin console. Every other surface of this app is Czech on
purpose, because the people using it are the owner's parents and his children —
and the one moment they are most likely to be stuck is the one moment it started
speaking English and sent them to what reads like a developer tool.

Found by looking at a delivered email rather than at the settings. The templates
were simply PocketBase's defaults, and a default is invisible until somebody
receives one.

The reset link now lands on amber.petruzalekr.cz, which hosts the form: two
fields, in Czech, on the domain people already know. A reset link wins over a
stored session, because somebody arriving with one is trying to fix their
account and dropping them into a signed-in page hides the thing they came to do.
The token is stripped from the address bar once spent — a URL carrying a
credential is one that ends up in a bookmark or a screenshot.

A failed confirm names the likely cause. These links expire, and "something went
wrong" gives somebody who opened yesterday's email no way to know that.

Verification and email-change keep PocketBase's own confirm pages: nobody has
used those, they are not part of the report, and pointing them at a page that
does not exist would be worse than English.

As a migration rather than a click in the admin UI, so restoring the backend
from migrations does not quietly put English back.
2026-09-05 19:04:50 +02:00
2b458fb9f6 Let people ask for a new password on the website, and stop capitals breaking it
Two halves of the same report. A family member could not get in, four password
resets produced nothing, and the site offered no way to ask for one anyway.

**Capitals.** The web sign-in trimmed the address but did not lower-case it, and
PocketBase looks accounts up case-sensitively — so `Vojta.Markup@seznam.cz` for
an account stored in lowercase matched nothing and read as a wrong password.
Phones capitalise the first letter of a text field by themselves, so this is the
mistake people will actually make. The same fix went into the app.

**The reset itself.** A link under the sign-in button, hidden until asked for:
the overwhelmingly common visit is somebody who remembers their password, and a
reset field on the way in invites people to reach for it first.

The message it shows says what was *done*, never whether the address matched.
The endpoint deliberately answers the same either way so that nobody can use it
to discover which addresses are registered — and pretending otherwise is exactly
what made this so hard to read from outside: four attempts, four "sent", nothing
arriving, no way to tell that the lookup had simply found nothing. It also
points at the one thing that always works when mail does not, which is asking
for it to be set directly.

Whether delivery itself works is still unverified: every reset attempt on record
failed at the lookup, so the mail path has never once been exercised. A reset
has been triggered against a real account to settle that.
2026-09-05 19:01:02 +02:00
5b69b9ec6a Set a password from the dashboard
Asked for because a family member could not get a reset email. The cause turned
out not to be mail at all — PocketBase looks accounts up case-sensitively, he
was typing a capitalised address, and the endpoint answers 204 either way so
that nobody can use it to discover which addresses exist. The app now
lower-cases addresses, which should stop it recurring.

This stays for the case that fixes: somebody who has genuinely forgotten a
password and cannot receive mail. One field and one button per row of a table
only a superuser can load, so it grants no capability the page did not already
have — the same token already creates accounts.

The field is cleared the moment it succeeds. It exists to be typed and read
aloud once, and a password left sitting in an input is one that ends up in a
screenshot.

Verified against the live backend on a throwaway account: created it, signed in
with the first password, set a second from this path, signed in with the second,
was refused the first, deleted it.
2026-09-05 18:34:57 +02:00
ad38989f00 notices: add the created/updated fields everything sorts by
The collection was declared with exactly the fields it needs -- message,
active, endsAt -- and PocketBase adds no created/updated unless asked.
Both readers sort by -created, so both got 400 for the life of the
feature: the app's NoticeService caught it and swallowed it, and the
dashboard's own listing showed 'zatím žádné' however many notices
existed. Writing worked throughout; nothing could read what was written.

Additive and reversible. created is onCreate only; updated moves on every
save, which costs nothing and is what a pull cursor wants.
2026-09-02 22:10:31 +02:00
5a4112f41f Dashboard writes were silently GETs
Publishing a notice did nothing and said it had worked. `api()` took only
`path` and dropped the options object every caller passes, so
`api(path, {method: "POST", body: ...})` fetched the URL. The read
succeeded -- notices are publicly listable -- so the page printed
"zveřejněno" over a collection that had not changed. Switching a notice
on or off is a PATCH and went the same way.

It takes method and body now, and reports what PocketBase said rather
than a bare status, so a permissions problem cannot look like an outage.

Found because the owner tried to tell the family that the anime tab is
down -- AniList have disabled their API, and every call returns 403 with
"The AniList API has been temporarily disabled due to severe stability
issues" -- and the notice never reached anyone.
2026-09-02 21:50:21 +02:00
271c423918 Drop the nsfwEnabled clause from the releases read rule
The nine 18+ rows it protected (three versions across three platforms,
437 MB) are deleted. A condition on a flag nothing writes, guarding rows
that no longer exist, reads like a protection that is still doing
something.

variant stays on the collection and in the manifest: every installed
Amber compares it against its own and refuses a mismatch silently.
2026-08-30 04:15:50 +02:00
55444ee55e 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.
2026-08-30 04:09:10 +02:00
1b38ce9d73 Keep a television signed in across a holiday
The auth token lasted five days. The app refreshes it on every launch,
so a device used regularly never noticed, but a device that is simply
not switched on has nothing to refresh with. A week away was enough: the
television came back to a login screen, to be answered on a remote with
no keyboard.

Ninety days is chosen against the machines this runs on rather than
against a threat model that would prefer shorter. Tokens live in the OS
secure store, and a password change still rotates tokenKey and
invalidates every outstanding one. The cost of the old value was being
paid by the least technical people in the house.

Applied to the live database by hand first; recorded here so restoring
the backend from migrations does not quietly put it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 02:09:55 +02:00
7f6a72dd43 Let the owner tell the family what is broken
AniList disabled their public API with no warning and the family's Anime tab
stopped working. The app had nothing to say: the cause was outside it, no retry
would have helped, and the honest message was "this is broken, it is not your
television, and it is not ours to fix today". Nothing could deliver that.

A `notices` collection the owner writes and every app reads, plus a composer in
the dashboard's Správa view, which already holds a superuser token.

Read is PUBLIC on purpose: a notice has to survive the case where signing in is
itself what is broken, which is exactly when an explanation is worth most. The
cost is that it is world-readable, so both the migration and the composer say in
so many words that nothing sensitive goes in one. Writing stays superuser-only.

`endsAt` exists because the predictable failure is not a wrong notice but a stale
one: the outage ends, the row stays, and within a week everybody has learned to
ignore the banner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:12:57 +02:00
dd37b36f6e Mint the download token when the button is pressed
A friend got "The requested resource wasn't found." downloading the adult
Windows installer. It is not a permissions problem and not missing data:
the row and the file are both there, and the same download returns 206 with
a valid token. That message is exactly what a protected file answers when
the token is missing or expired, verified against production.

releases.file is protected, so the URL needs a file token, and a file token
lives 180 seconds -- read off the JWT's own exp claim, not guessed. The
download list minted three of them while building the rows and baked them
into the hrefs, so three minutes after the page loaded every button was
permanently dead, and pressing it again could not help because the stale
token was in the markup. Nothing suggested reloading.

The owner never saw it because he presses the button as soon as the tab
renders. The setup flow I added this week makes it near-certain for anyone
else: it walks somebody through choosing services, creating accounts,
paying and fetching a TMDB key before pointing them at Stáhnout, which is
many minutes after the download tab was first rendered.

So the token is minted in the click handler now, and the link carries
download=1 so PocketBase sends an attachment rather than leaving the
browser to decide what to do with a .exe or a .zip.

The app was already correct: UpdateService mints its token immediately
before _dio.download, so auto-update on the family's devices was never
affected. This was the web page only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:18:33 +02:00
874eab21dc Don't tell an account about 18+ when it hasn't got any
The child-profile checkbox read "Dětský profil (skryje anime a 18+, na
odchod chce PIN)" for everyone. Three of the four accounts are
nsfwEnabled = false, so for most of the household that sentence announced
a section they have never seen and cannot reach, and it was also untrue:
there is no 18+ content on those accounts to hide.

The adult source field on the Zdroje tab has always been gated on the same
flag. This label was the one place on the page that was not, because it was
static markup rather than something rendered from USER.

Now set in enter(): "skryje anime" alone, or "skryje anime a 18+" when the
account has it. Checked both branches against a clean and an adult test
user on a local instance.

The app is already clean here: childProfileDesc is only "K opuštění tohoto
profilu je potřeba PIN", and every 18+ string in app_cs.arb lives inside
the adult section itself, which a clean build does not contain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:04:36 +02:00
79b615a02c The dashboard edits any group, not whichever one it found
The template editor was pointed at a single row and the account-creation
checkbox preloaded that same row, so with several groups the dashboard
could only maintain the family and a friend created there had to type their
own code. Both now ask which group.

A dropdown above the fields lists every row plus "+ nová skupina", and name
and code are editable here too. The selection lives in tplSel rather than
being read off the select, because load() re-renders the section from
scratch and the choice has to survive that. The code is masked with the
other secrets, because it is one.

Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but
the route's fallback for the old AMBER_INVITE_CODE still takes "the"
isDefault row, so two of them would restore the original bug: which
credentials the old code hands out would depend on edit order. Saving with
the box ticked clears the flag on the others and says so.

preloadFamilyConfig becomes preloadSharedConfig(userId, name, password,
tplId) and fetches the chosen row by id. The create form's checkbox becomes
a dropdown of every group plus "žádné", defaulting to the isDefault row, so
not touching it gives the old behaviour.

Fixed while here: the save confirmation was written and then destroyed by
load() re-rendering the section, so it had always flashed and vanished.
That matters now, because the message is what tells you the default group
moved.

Driven through the page's own handlers against three rows with different
credentials: switching loaded each group's own values, "+ nová skupina"
cleared the fields, a nameless save was refused, a reused code reported
"code: Value must be unique." rather than a bare 400, ticking výchozí moved
the marker and reported it, and afterwards all four codes resolved to their
own credentials with exactly one isDefault row. Screenshots were
unavailable this session, so this was checked at the DOM level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:54:12 +02:00
8f2cb994ec The code has to choose the template, not the clock
Sharing a second set of paid accounts with friends means a second template,
and the route was not ready for one. It checked the code and then took the
most recently updated row:

  findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)

With a single row that is invisible. With two it hands the family's
credentials to friends, or the reverse, decided purely by which row was
edited last, silently and with no error. status.html carried a comment
warning about exactly this: "never create a second".

So the code now selects the row, bound as a filter parameter rather than
concatenated. AMBER_INVITE_CODE becomes a fallback that opens the row
flagged isDefault, which means nobody halfway through setup broke and
Coolify needed no edit; once the family row has its own code the env var
stops mattering. A wrong code and a code with no row behind it give the
same 403, since telling them apart would confirm which codes exist.

Codes live on the rows rather than in more env vars, so a new group is one
row in the admin UI instead of a redeploy, and each code is revocable on its
own. The unique index is partial because PocketBase text fields default to
'' and SQLite calls two empty strings equal.

status.html read the template in three places, all by recency. They now go
through familyTemplate(), which selects on isDefault, so adding a friends
row cannot make the editor wander onto it or make account creation preload
the wrong group. Its one-click preload stays family-only; a group picker
there is left undone rather than half-built.

The website says "rodinný kód" in four places and friends are not family, so
that copy widens. The setup flow also offers "Mám kód" up front now: someone
Richard shares accounts with has nothing to buy, and walking them through
three price lists first would be actively misleading.

Verified against PocketBase 0.39.6 with two rows and friends as the most
recently updated, the state that used to break: each code resolved to its own
credentials, the legacy env code resolved to family via isDefault, wrong and
empty codes gave 403, a duplicate code was refused by the index, and
familyTemplate() returned family while -updated returned friends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:35:46 +02:00
9dd677b442 Seed with the token production is actually reachable with
secrets/amber.env holds PB_ADMIN_TOKEN and no superuser password, so the
email/password-only seeder could not be pointed at the live instance
without inventing a credential to satisfy it.

The token is also verified up front against a superuser-only endpoint,
because listing records cannot answer whether it is valid: PocketBase
applies a listRule as a FILTER, so a stale token returns 200 with zero
rows, indistinguishable from an empty collection. The seeder then decided
all four providers were missing and the first visible error was
"[FAIL] prehrajto: HTTP 403 Only superusers can perform this action",
blaming the row instead of the token. Found by trying it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:24:25 +02:00
57585d8ad2 Tell a new viewer what to buy before asking them to fill in a form
Someone with their own accounts had to work out on their own that Amber
needs a paid subscription somewhere before it can find anything, and
which of three services that means. The Zdroje box already turns their
usernames into addon URLs; nothing anywhere told them which usernames to
go and get, or what it would cost.

Six steps as a new #start tab: change the generated password, choose
services, create the accounts and pay, take a free TMDB key, enter it all
in Zdroje, download. Steps 1, 5 and 6 hand off to the tabs that already do
that work. This screen owns no credentials and no crypto on purpose, since
a second encryptor for one blob is how a vault gets corrupted.

The password is step 1, not step 6. Changing it re-keys the vault with a
fresh salt, so every device already signed in holds a stale key until it
authenticates again. Done first, while the vault is empty, there is
nothing to re-encrypt and no paired television to strand.

State is three fields on the user record and nothing more: skipped, chosen
and pwChangedAt. Whether the sources exist is CONFIGS.length and whether
TMDB is set is a field in the decrypted config, so storing those again
would let two answers disagree. pwChangedAt is the one thing that cannot be
derived, because PocketBase records no password-changed timestamp.

Prices and click-paths live in a providers collection, editable in the
admin UI, with priceCheckedAt rendered beside the number so a stale figure
looks stale rather than reading as a promise. Owner supplied the three
signup guides; TorBox Free is called out as unusable because it has no API
access, which is the only way Amber talks to it.

byGo also stops demanding a Czech host. Nothing downstream needed one, and
someone who only wants anime was being told to buy a service they had no
use for.

Verified against PocketBase 0.39.6 from a throwaway data dir with this
repo's real migrations, hooks and page: both migrations applied clean, the
flow opened itself for a user with no sources, a choice persisted as
{"chosen":["prehrajto","torbox"]} and nothing else, guides rendered
numbered with working links, skipping raised the banner and survived a
reload, resuming came back in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:36:55 +02:00
27ce0f5b60 Dashboard: the family config, set once and handed to new accounts
Three things, and the third one I first said was impossible.

Nový účet takes the full width — a 420px column inside a full-width panel
left two thirds of the row empty. Three across on a desktop, one on a
phone.

Rodinné nastavení edits `onboarding_template`: the shared sources a new
member's first setup pulls down. The values are plaintext credentials — a
prehraj.to login and a TorBox key — so they are masked behind a reveal
toggle, and this stays a superuser-only noindex page. It PATCHes the one
record and never creates a second; a second row would make the hook's
`-updated` ordering silently decide which config the family gets.

"Použít rodinné nastavení" pre-loads that config onto the account being
created, so the new person never types a family code. I claimed this could
not work because `addon_config` is keyed by profile and a fresh account has
none. The owner pushed back, correctly: the old wizard needed a profile for
the same reason and simply could not make one, whereas this page can — and
`CloudSyncService._findOrCreateDefaultProfile` looks for an existing
profile BEFORE creating one, explicitly "so a second device signing into an
account with data doesn't make a duplicate". A profile made here is adopted
as the default on first sign-in. The other two obstacles were never real:
the template is superuser-readable, and the password is available because
this page just chose it.

The crypto is lifted unchanged from index.html and verified rather than
trusted: the page's own functions, run against
amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture
addon_config_crypto_interop_test pins the Dart side to — derive the
identical key, and a sealed blob round-trips. One wrong iteration count
would have made a new member's app fail to decrypt, which presents as a
password reset rather than as a bug.

The account is created BEFORE the pre-load, and a failed pre-load says so
in warning colour rather than reporting success or rolling back: a
half-made account you were not told about is the worst outcome of the
three.
2026-08-09 01:23:53 +02:00
80c4bd498c The accounts table needed the whole width
Caught in a screenshot, invisible in the source: sharing a grid row with
the create form left the five-column table about half the page, so every
date broke as "2026-" / "08-05", the action button wrapped to two lines,
and a long address split mid-word. A wrapped date reads as two dates.

Full width, the address takes the slack, and the four columns that must
never wrap say so. Verified by counting line boxes per cell rather than
by eye — my first check compared cell HEIGHTS and flagged all five rows,
which was just the button's own height.
2026-08-08 23:33:22 +02:00
449b1e75e0 Dashboard: read the reports, and create accounts without the admin UI
Two views on status.html, and one migration.

Hlášení: one incident per card rather than a table row, because a report
carries fifteen numbers and the job is to read one incident, not scan a
column. The reporter's address is resolved from the `user` relation in the
browser — the app never writes identity onto telemetry rows. The box is
named. Our own test sessions are HIDDEN by default, since the question
this view answers is what the family experienced; `tester` OR an emulator
tell OR a test address, so it stays right for rows written before the
column existed. Day-range filter, and every field says "neměří se" rather
than 0 when that player does not send it — a different claim from zero,
and the TV sends everything while mpv sends part.

Správa: create an account and flip clean/18+. Registration stays CLOSED —
`users.createRule` is still null and this is a plain POST with the
superuser token the page has held since it was written, so it grants
nothing that was not already available in the PocketBase admin UI. The
password field is deliberately visible: you are making it for someone
else and have to read it out.

client_logs.gitSha: which BUILD wrote a row, not which version. Fourth
outing for the silent-drop lesson, and written alongside the client change
rather than discovered after.

Verified against real data before deploying, by splicing production rows
into the shipped page as fixtures and driving it in a browser: seven
reports render with the right reporter and box, the day filter and the
test toggle both work, prefs persist, and the create form's guard fires.
That caught a JS syntax error (a Czech quote closed with an ASCII one
inside a double-quoted string, which would have taken the whole dashboard
down — PocketBase serves this file with no build step) and a missing space
that rendered "ďáblashlédnuto".
2026-08-08 23:29:51 +02:00
447cb85346 client_logs.tester: mark our own sessions, not the family's
Telling a real report from one of our test runs was impossible. Three
no_first_frame rows from a development emulator sat in a production log
dump and were only spotted because an emulator decoder name happened to
be visible in a field logged for something unrelated.

Third time this silent drop has cost us — hidden profiles.pinHash, then
client_logs.flavor, now this. A field the client sends does not exist
until a migration declares it, and PocketBase says nothing. Caught only
because the added field was read back off a measured session.

NOT deployed. The reader (amber-app/tools/reports.py) works without this
column by recognising an emulator in `device` and a test address from the
resolved account, so nothing is blocked on the redeploy.
2026-08-08 20:54:12 +02:00
8d3cefa3f5 Telemetry contract: the real session_summary shape, and the duplicate warning
The documented meta was the THIN row -- the leftover second writer that
amber-app just removed. It also never said that watchedS (elapsed watch
clock) and positionS (where playback reached) are different numbers, which
is exactly the distinction that made a never-started session readable.

Records that aggregates over rows before 2026-08-07 double-count Android
sessions, and gives the filter that excludes them.
2026-08-07 12:40:49 +02:00
dc4f85fd4f The TV instructions stop naming an app that may not be installed
"Na televizi otevři aplikaci Downloader" was carried over from the old landing page
and never checked. Downloader is a third-party sideloading app people install on Fire
TV sticks; it is not standard on Android TV, and the owner has never seen it on his.
An instruction that points at something absent is worse than no instruction — the
family would simply be stuck.

The browser route is what he actually uses and has confirmed works, so it is the
instruction now: open the TV's browser, go to the site, press a button. The button is
new — telling someone to type a second URL when a link can do it was needless, and
`/tv` stays as the shortcut for anyone who would rather type than click.

Both places said it: the signed-out card and the Stáhnout tab.
2026-08-07 09:33:28 +02:00
5bfc45cd23 The sources form no longer insists on prehraj.to
The addon accepts either host on its own as of stremio_prehrajto 7ef248d, so the
page that mints the URLs stops claiming otherwise. Each host stays all-or-nothing
and at least one must be complete — the same rule /encode enforces, checked here
too so the message arrives in Czech rather than as a 400.
2026-08-07 09:09:56 +02:00
36d7bb11b0 Shadow the stale pb_public pages the deploy leaves behind
A commit that deletes a file from pb_public does not delete it from the server —
the deploy only copies in. So /settings.html and /onboarding.html kept answering
200 after the swap, the second still offering a signup form that can now only
fail. Explicit routes shadow them, and go on working as bookmarks even after
someone clears the directory by hand.
2026-08-07 02:13:31 +02:00
12e7f4103f The unified page becomes the site; retire the two it replaced
index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.

Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):

  registration    403 "Only superusers can perform this action", with and without
                  the old invite header
  template route  401 anonymously
  session         survives a full page reload; the password does NOT, so the
                  sources section re-asks for it — the intended split
  prefs           one row, updatedAt distinct from the server cursor, and all 13
                  fields present including the three the page does not render, so
                  the merge did not clobber what the app writes
  sources (BYOC)  the browser reached the addon's /encode cross-origin, minted the
                  prehraj.to token, built TorBox's config locally, encrypted and
                  saved; the stored blob contains no plaintext and carries
                  kdf=pbkdf2-sha256-210000
  downloads       all three platforms resolve to 1.1.6 · clean with a file token

Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
2026-08-07 02:10:48 +02:00
00b20550c2 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-07 01:54:25 +02:00
06a87824bf client_logs: add flavor, which PocketBase was silently discarding
The app has been sending `flavor` since yesterday's change and PocketBase was
throwing it away. An unknown key in a create payload is dropped with no error, no
warning and HTTP 200 -- the same silent-drop behaviour already documented for hidden
fields in amber-app/CLAUDE.md. So the dashboard's "Podle varianty" row read
`neznámá` for every record while the client believed it was reporting the variant,
and the only way to notice was to query the stored rows and find the field absent.

Found by verifying instead of assuming: a real play/exit on the TV produced two
session_summary rows, both with flavor empty, on a build that definitely sends it.

Not folded into `meta`, though that needs no migration and the dashboard already
falls back to reading it there. The variant is a first-class dimension of every
record, exactly like platform and appVersion beside it, and burying it in json would
make the one query it exists for -- group errors by variant -- awkward for nothing.

Additive and nullable so existing rows stay valid. Validated on a clean local boot
of the pinned PocketBase against the real migrations directory: health 200, no
errors, and client_logs now reports the field.
2026-08-06 06:45:48 +02:00
48a88e6936 Dashboard: error history, and which publish owns it
GET /api/status/errors plus a panel: 14 daily bars (records vs errors), then
breakdowns by version, platform and flavour.

**Rate, not raw count, is what attributes errors to a publish.** Versions have
wildly different exposure -- right now 1.1.4+13 has 177 records to 1.1.5+14's 20 --
so raw counts would make whichever build the family used most look worst. Every
row carries total, errors and errors/total, coloured on the rate.

**Flavour is not recorded yet and the panel says so.** TelemetryService sends
appVersion, platform and device, but nothing carries clean-vs-adult. The endpoint
reads a top-level  field and then meta.flavor, so the day the app starts
sending it this fills in with no backend change; until then the row reads
'neznámá' with a note explaining why. No app change was made here -- that is left
noted, per the owner.

The window is 14 days because that is the retention ceiling: client_logs.pb.js
trims nightly, so nothing older exists to graph. Every day is pre-seeded so a
missing bar and a zero bar cannot look alike.

Inline SVG rather than a charting library -- this page is served by PocketBase with
no build step, and 14 bars do not justify a dependency. Validated on a clean local
boot: 14 bars, correct axis labels, no crash on empty data.
2026-08-05 21:34:42 +02:00
cd5d7138d9 Dashboard reads COOLIFY_READ_TOKEN, and the name is the point
The owner issued a read-only Coolify token, which is the right shape: this
dashboard only ever performs GETs. Renaming the variable from COOLIFY_TOKEN to
COOLIFY_READ_TOKEN means the requirement is encoded where someone setting it will
see it, rather than living only in a doc.

A write-capable token here would mean a PocketBase vulnerability could deploy or
stop every app on the PaaS. coolify.sh keeps using the full token for deploys;
having both in secrets/amber.env is the separation, not redundancy.

Verified the read token against both endpoints the dashboard needs (applications
list, container logs) before wiring it, and validated the renamed variable on a
clean local boot: 7 services enumerated, both panels configured, no errors.
2026-08-05 21:28:28 +02:00
7a4f0d91f3 Pretty URL for the dashboard: /status -> /status.html
The documented URL did not work, and failed in the most misleading way possible:
PocketBase's indexFallback (on by default) serves index.html for any unmatched
static path, so /status returned the family landing page with HTTP 200. It looked
like the dashboard had silently broken rather than like a missing route.

Follows the existing /settings -> /settings.html pattern in settings.pb.js, which
is presumably why that one has always worked.

Validated on a clean boot with hooksWatch off: 302 to /status.html, the page
titled 'Amber - stav služeb', /api/status still 401, no JS errors.
2026-08-05 21:23:54 +02:00
cb3f1407ff Operator status dashboard at /status.html
Answering "is Amber healthy?" meant opening Coolify, clicking per-app pages for
status, clicking again for logs, then querying PocketBase separately for what the
family is actually being served. This puts the five questions asked when something
breaks on one screen: is it up, did the last deploy succeed, what did it say, are
clients erroring, and is the newest release the one I think it is.

pb_hooks/status.pb.js serves GET /api/status and GET /api/status/logs, both
superuser-gated. Superuser rather than a user flag because there is no operator
field on `users` and inventing one needs a migration; nsfwEnabled was considered
and rejected, since using a content flag as an admin flag would let any
adult-enabled family member read infrastructure logs.

**This is the first hook in the project to make an outbound HTTP request** -- every
prior one read only PocketBase's own database. So each external call is wrapped to
degrade one panel instead of failing the response, and carries a short timeout so a
hung upstream cannot pin a worker. The Coolify token never reaches the browser;
that is the entire reason the endpoint exists rather than the page calling Coolify
directly.

Runtime container logs come from the Coolify API, not SSH. An earlier design
assumed otherwise and asked for VPS root; it is not needed.

Validated against the project's own pinned PocketBase 0.39.6 before committing,
because a broken pb_hooks file crashes PB at boot and would take family logins
with it: hooks load with no JS errors, both routes 401 unauthenticated, /api/status
executes every panel, the degradation path reports actionable "not configured"
messages, and with real credentials it enumerates all seven Amber services and
returns live amber-api vitals. The page was driven in a browser -- login, panels,
and a log fetch returning 1812 chars of real container output.

Two harness bugs found on the way, both mine: `--publicDir` does not exist in
0.39.6 (pb_public is resolved beside the executable), and `pkill -f "pocketbase
serve"` matches the pattern inside its own command line and killed the shell.
2026-08-05 21:17:05 +02:00
9e6901505d Contract: Windows publishes the installer, and where its flavour gate lives 2026-08-01 15:58:13 +02:00
4faac4c280 check-flavor: accept a build directory, refuse a .exe explicitly 2026-08-01 15:57:37 +02:00
4bf6523ab3 Add check-flavor.py: verify an artifact's flavour without installing it
Reads the Dart AOT snapshot out of an .apk or a Linux/Windows bundle .zip and
asserts exactly one AMBER_FLAVOR:: marker matching the expected variant. See the
amber-app commit for why nothing cheaper distinguishes the two builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:12:33 +02:00
1d50e5a680 Phase 2: /api/amber/whoami token introspection for amber-api
amber-api must know whether an inbound request carries a valid Amber user
token before it spends an ffprobe on a caller-supplied URL. PocketBase
auth tokens are stateless JWTs signed with a per-user secret only
PocketBase holds, so offline verification is impossible by design — the
only correct check is to present the token and see if PocketBase accepts
it. $apis.requireAuth("users") does that before the handler runs, so
reaching the body IS the answer and the body is deliberately trivial.

Returns exactly {id, nsfwEnabled} and nothing else. A token-introspection
endpoint is a tempting place to leak account data (email, username,
profiles) and amber-api has no business knowing any of it — asserted.

Verified against a real PocketBase 0.39.6 running the repo's actual hooks
and migrations (the binary, no Docker needed): full suite green including
8 new assertions — anonymous 401, bogus token 401, valid 200, own id
returned, nsfwEnabled present, no extra keys, per-caller distinctness.

Also verified the integration this exists for: a genuine app token from
PocketBase authenticates a probe enqueue on amber-api, an invalid one
still 401s, and amber-api's 10-minute positive cache means a second
request costs no PocketBase round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:52:43 +02:00
a98f52e3bc Merge pull request 'Add /settings web editor for the encrypted addon config' (#9) from feature/web-settings-editor into main
Reviewed-on: #9
2026-07-23 16:21:15 +00:00
e781557694 Add /settings web editor for the encrypted addon config
A login → decrypt → edit → re-encrypt page so account settings (the
addon URLs + TMDB key) can be changed from any browser instead of typing
them on a TV. Doubles as the per-user config-update tool (editing a live
account's config, which the one-time onboarding template can't do).

- pb_public/settings.html: auth-with-password, load the owner's
  addon_config (rules already scope to profile.user), decrypt the blob
  in-browser with the password (PBKDF2→AES-GCM, byte-identical to the app
  + onboarding page), edit fields, re-encrypt and PATCH.
- Crucial: re-encrypt reusing the record's EXISTING salt, so the derived
  key is unchanged and an already-logged-in device (which cached that key
  and has no password on a restored session) decrypts the edited blob
  with no re-entry. A fresh salt would force a TV re-entry — the opposite
  of the point.
- 18+ fields (adult addon URL + enable toggle) render only for an account
  with nsfwEnabled; a clean account never sees them and their stored
  values are carried through untouched on save.
- pb_hooks/settings.pb.js: /settings → /settings.html redirect.

No server logic beyond the redirect — all crypto/IO is client-side against
the existing collection API. Crypto round-trip + salt-reuse + wrong-key
rejection verified against the app's format (WebCrypto, Node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:20:24 +02:00
2c1c4dde0b Merge pull request 'client_logs: background device diagnostics collection' (#8) from feature/client-logs-telemetry into main
Reviewed-on: #8
2026-07-23 11:19:42 +00:00
418f566603 Add client_logs collection for background device diagnostics (feedback 2 §4)
The family runs Amber on hardware I can't debug directly, so the app batches
a low-volume diagnostic stream here: uncaught errors, player error codes,
per-session playback summaries, and the audio-delay a user dials in to fix
desync (the "a track falls behind" signal).

- Migration 1788000000_client_logs.js: owner-create-only, superuser-read-only
  (list/view/update/delete = null); fields kind/event/message/meta/appVersion/
  platform/device/ts + created.
- client_logs.pb.js: nightly cron trims rows older than 14 days.
- docs/telemetry-contract.md: event catalogue, how I query it, and the
  privacy/redaction guarantees (host-only, never a token or full URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:49:04 +02:00
0e33a83d90 Merge pull request 'Family onboarding site: invite-gated signup, template config, /get/tv' (#7) from feature/onboarding-site into main
Reviewed-on: #7
2026-07-22 16:24:49 +00:00
0b65fd7f49 Family onboarding site: invite-gated signup, template config, /get/tv
Distribution site served by PocketBase itself (pb_public/, same origin):
- Landing (/): create-account CTA, TV sign-in steps, login-gated PC
  downloads via the existing update manifest + file tokens
- Wizard (/onboarding.html): invite code -> account -> named default
  profile -> family template fetched and encrypted IN THE BROWSER under
  the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with
  the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified)
  -> pushed as their addon_config ciphertext. Adult fields never included.
- /get/tv: public direct download of the latest clean Android APK
  (Downloader-friendly; adult builds stay account-gated)

Backend:
- onboarding_template collection (admin-only, maintained via admin UI)
- AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header
  or ?invite=) and the template route (per-IP rate limit 10/5min);
  fail-closed when unset. Note: this closes the previously-open in-app
  registration too.
- Dockerfile ships pb_public + --publicDir; compose passes the env var

Contract + Coolify steps (second domain amber.petruzalekr.cz) in
docs/onboarding-contract.md. Verified locally end-to-end against
pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:23:05 +02:00
a957cc59f8 Add /api/amber/set-pin: hidden pinHash rejects client writes (#13/#14)
Testing verify-pin against the live DB showed the correct PIN failing:
the server never held a hash at all. PocketBase silently drops client
PATCHes to hidden fields, so the app's best-effort `PATCH {pinHash}`
had never landed — the device-local verifier masked it on the PC. The
new owner-scoped route writes the hash from superuser context (which
can set hidden fields); pin "" clears it. Hash shape matches the app
and verify-pin: sha256("<profileId>:<pin>") hex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:29:02 +02:00
e96183bba3 Add /api/amber/verify-pin: server-side child-PIN check (#13/#14)
profiles.pinHash is hidden by design (a readable hash = offline brute
force of a 4-digit PIN), which left a device that never saw the PIN set
— the TV signing in after the parent set it on the PC — with nothing to
verify against: the gate always answered "wrong PIN" there. This route
compares the candidate PIN server-side and answers yes/no; the hash
never leaves the server, and failures are rate-limited (5 per 5 minutes
per account+profile, tracked in the shared app store — file-scope state
doesn't survive the per-handler JSVM isolation).

The app mirrors a successful verify into local secure storage, so each
device pays the round-trip once per profile and works offline after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:26:34 +02:00
cabb6233a3 Add profiles.maxRating for per-profile content tiers (#14)
Each profile may carry a maxRating token ("kids" | "twelve" | "fifteen" |
"unrestricted") capping which movies/TV it can browse and play. Empty means
"no explicit tier" — the client falls back to the account default (a child
profile resolves to "kids"). Plain text, opaque to the server: all filtering
is client-side like the kids filter, and a string column lets the app add
tiers without another migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:42:40 +02:00
9d442ee853 Merge pull request 'Auto-update backend (H1): releases collection + gated manifest' (#6) from feature/auto-update into main
Reviewed-on: #6
2026-07-20 11:50:31 +00:00
6be486b275 Fix auto-update file gating: mark releases.file protected (#16)
The E2E caught a real leak: a non-nsfw (even anonymous) account could download
an adult artifact. In PocketBase, file protection is a per-FIELD flag, not
derived from the collection view rule — the original migration left releases.file
unprotected, so its URL was public despite the gated read rules.

- Add protected:true to the file field (correct for fresh installs).
- 1786500001_releases_protect_file.js: alter the field on the already-deployed
  instance (applied migrations don't re-run, so the fix needs its own migration).
- Doc: correct the gating explanation (protection is the field flag; the file
  token grant then re-checks the view rule).

With this, a protected file needs a file token whose grant re-checks the view
rule, so a clean account is denied the adult artifact.
2026-07-20 11:48:07 +02:00
785a621c49 Auto-update backend: releases collection + gated manifest (#16)
Adds the server side of auto-update (issue #16, was epic #6's [H]).

- pb_migrations/1786500000_releases.js: a superuser-only 'releases' collection
  (platform, variant, version, buildNumber, file, sha256, size, notes). Read is
  gated in the rules — any signed-in account sees clean builds; only an
  nsfwEnabled account sees adult ones — so PocketBase's native protected-file
  serving hands adult bytes only to flagged accounts (no custom streaming).

- pb_hooks/update.pb.js: GET /api/update/manifest?platform=… (auth). Picks the
  variant server-side from the caller's nsfwEnabled (adult) vs clean — the client
  can't request adult — and returns the latest build's version/buildNumber/notes/
  sha256/size + the protected downloadPath.

- scripts/publish-release.sh: uploads a built artifact as a superuser (computes
  sha256 + size, multipart POST). Token or email+password via env.

- docs/auto-update-contract.md: the collection, endpoint, download flow, gating.

Migration + hook + script syntax-checked. Live verification pends deploying this
to the PB (collection auto-applies on boot, hook loads from pb_hooks/).
2026-07-20 11:36:03 +02:00
a1982fd760 Merge pull request 'Device approve page: manual code-entry for the QR-less path' (#5) from fix/device-approve-code-entry into main
Reviewed-on: #5
2026-07-20 09:08:52 +00:00
15be3bd5e5 device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.

Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).

Docs: note the optional ?code= + normalization in device-auth-contract.md.

Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 11:06:51 +02:00
3c8d60e614 Merge pull request 'fix(device-auth): inline helpers into each hook handler (JSVM scope)' (#4) from fix/device-auth-jsvm-scope into main
Reviewed-on: #4
2026-07-19 17:48:46 +00:00