A Member account could open a terminal. Demonstrated, not inferred: on 2026-08-06
a token for dingoshf@gmail.com (role Member) was refused GET /api/tasks with 403
and, in the same minute, opened /api/tasks/pipeline/ws with 101 Switching
Protocols.
The account backstop lives in originScopeMiddleware, which is a Hono middleware.
Websocket upgrades never reach Hono: Bun's route table in server.tsx matches
'/api/terminal/ws' and its siblings before the '/api/*' entry that hands off, so
NON_OWNER_PATHS was enforced on HTTP and nowhere else. upgradeWs verified the
token and the blacklist, then upgraded — it proved WHO was calling and never
asked what they could reach. None of the handlers behind it checked either;
terminal, chat and desktop have no authorisation code at all.
What was reachable with any valid token: a shell as the owner in the owner's
home, the agent with --dangerously-skip-permissions, arbitrary script execution
through task-runner and pipeline, and the owner's physical screen and keyboard
over the VNC mirror.
upgradeWs now refuses any provider a non-owner has no business on. cliamp and
cliamp-audio stay open to them — those are the socket half of /api/music, which
is what a music account exists for. An unrecognised provider denies.
NON_OWNER_WS_PROVIDERS is declared beside NON_OWNER_PATHS on purpose. They are
one rule at two doors, and the whole failure was that only one door had it;
splitting them across files is how that happens again.
The vault socket was already gated — it verifies isSuperAdmin in `open` and
closes 4001 — so upgradeWs was the only gap.
Not yet verified against the running server: platform TS does not hot-reload, so
the 101 above still reproduces until `pm2 restart officer`. The provider table
was checked in isolation: the five execution providers and an unknown name all
deny, cliamp and cliamp-audio allow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The policy editor now opens read-only behind an Edit button, and edit mode brings up an assistant:
describe the change, get a complete revised document back, read a line diff of it, and only then press
Save. Nobody writes an ACL from memory — it is HuJSON in Tailscale's grammar keyed to names only the
server knows — and that, not typing speed, is what made this screen unusable.
The model never touches Headscale. It proposes text, the text lands in the editor's draft, and the
existing Save button is still the only thing that leaves the browser. A model that could write the ACL
directly is one that can partition the network the owner is connected through.
The sidecar calls officer-anthropic-proxy on loopback for one request with a timeout — no session, no
agent, nothing persisted. It sends the draft on screen plus the tailnet's vocabulary (user, node and tag
names) and no credentials of any kind. It does not validate the reply either: Headscale owns the only
parser that counts, same argument as policy.ts.
editing one server then another without closing in between reused the same
mounted form, which seeds its fields at mount — so the second server showed
the first one's name, url and ssh host. submit diffs that stale state
against the new server prop, so saving would have written them to it.
policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.
nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
Reads the per-server Officer Companion (COMMS/HEADSCALE_COMPANION_API.md): container health with its
own diagnosis, a log snapshot, a live SSE tail, and restart/stop/start.
The companion sits at ${server.url}/officer-api behind the same admin key we already store, so there
is nothing new to register — but only the sidecar can decrypt that key, so the sidecar proxies it and
the browser never talks to the companion directly. That also rules out EventSource for the stream
(no Authorization header), which is why the tail is fetch() + a hand-rolled SSE reader.
Two inversions the code is built around: /health is always HTTP 200 and must be read by verdict, and
an absent companion is a state to render rather than an error — an HTML 502 is nginx, a JSON 502 is
the companion reporting a failed docker op, and the admin API on the same domain is independent
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.
Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.
The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).
The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.
Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while reading the app, not while fixing anything. The policy editor is the
only one that costs an ssh session today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chrome came back for a couple of seconds every few minutes during a film with
nobody touching anything. Only three things wake it, and none of them is periodic —
so the culprit is a stray pointermove. A mouse sitting on a desk still emits the odd
one-pixel event, a bumped table emits a few, and the browser synthesises a zero-delta
move of its own when the cursor style changes, which this player does every single
time it hides the cursor.
A move now has to travel eight manhattan pixels from wherever the pointer last
genuinely woke it. The anchor only advances on a real wake, so a slow deliberate
drift still accumulates past the threshold — it is jitter around a fixed point that
stops counting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The auto-hide timer was there and correct; `position` was in its dependency list.
That advances about four times a second while a video plays, so the effect re-ran
and cleared the pending timeout every ~250ms and the 2.5s never elapsed. The chrome
could only hide while playback was stopped — which is exactly when the code
deliberately keeps it up — so in practice it never hid at all.
Not a fullscreen bug, though fullscreen is where a permanent scrubber is
impossible to ignore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things about the background-task strip, all the same complaint: the chip you
want is the one you cannot see.
Newest first, because the strip scrolls horizontally and the far end is off-screen —
chronological order put every task you just started exactly where you had to scroll to
reach it. Finished chips are capped at five, so a long session stops turning the tray
into a history; running ones are never counted or dropped, since watching them is the
whole point. And the default horizontal scrollbar is ~15px tall and laid out inside the
row, which is what made the chips read as squeezed — it is thin now, and the row and
chips have the padding back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the gap limit was 1000, hardcoded, with the comment noting that raising it costs
node CPU and not correctness — and no way to raise it. it is the one thing about
a scan only the owner can know: how many addresses their old wallet handed out
and never had paid. RescanOptions threads from the POST body through the backend
and the source to utxos/scan, capped at 100k because past that the scan takes
longer than anyone waits. the card gets a "search depth" field beside the button,
blank meaning the default.
deleting a wallet with no seed took one unconfirmed request. the dialog asked for
the wallet's name and then threw the answer away, so the check existed only for
whoever went through the dialog — a node wallet still holds the credential, the
labels and the freezes. confirmName now travels with the request and the route
enforces it. a bodyless DELETE is told which field is missing rather than that
its JSON did not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
four wallet defects, none urgent, all cheap:
selectCoins let an explicit coin-control pick override a freeze. naming an
outpoint now overrides only the confirmed-only default; frozen is absolute.
balances counted frozen coins in onchainConfirmed, so Send showed a figure a
max-value spend could not reach. Balances gains onchainFrozen — a component of
confirmed, not a deduction — filled at the route layer, because freezing is
Officer policy in Postgres and no backend can see it. The route only reads utxos
when something is actually frozen. Send subtracts it under "Spendable";
Overview lists it beside unconfirmed.
a rescan in flight upstream was invisible after a sidecar restart, and a second
POST would have queued behind it (scantxoutset is single-threaded node-wide).
adoptRescan polls an existing NBXplorer scan instead of starting one, and the
GET route falls back to it when local state is gone.
the per-variant scan deadline counted queue time, so a variant that sat behind
another wallet's scan timed out without ever having run. the deadline now
refreshes while the status reads Queued.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
two holes found auditing the wallet sidecar after the frozen-utxo fix.
the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".
nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
freezing a coin promised it would not be spent, and the promise only
held when the caller named outpoints explicitly. an ordinary send picked
its own inputs from a snapshot in which every utxo said frozen: false —
the chain has no idea what officer froze — so selectCoins, which has
always filtered on that flag, never saw one set. sendAll was the worst
case: "send everything" swept the frozen coin too.
the frozen list now travels with the request, set by the route and
overwritten if a caller supplies one. it can only ever restrict what is
spendable, so smuggling a value in gains nothing. the backend still
reads no officer table.
three tests pin it, including a control that sends successfully once the
coin is unfrozen — without it the other two would pass on a wallet that
could not spend at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the route typed its body as {name, defaultBip, config} and passed the
parsed object straight to updateWallet, which also accepted sealedSeed
for the passphrase change. a TypeScript annotation strips nothing at
runtime, so any authenticated caller could send a sealedSeed key and
overwrite the encrypted seed — no passphrase, no unlock. encryptSecret
encrypts nonsense happily, so the damage would have surfaced at the next
unlock, not at the write.
updateWallet can no longer touch the seed at all; resealing moves to
replaceSealedSeed, whose only caller has already proved knowledge of the
old passphrase. the route rebuilds its patch field by field as well, so
the next field added there cannot re-open it.
verified against the test wallet: the envelope is byte-identical after
the same request that previously would have replaced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the card keyed off `sync.rescan` being null, which is what esplora
reports — and equally what nbxplorer reports before its first scan. so
the guard could not tell "unsupported" from "not yet run" and resolved
it the wrong way: every esplora wallet got a button that 501s.
canRescan carries the distinction explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
registering an xpub only indexes it from that moment on, so an imported
seed with history read as a confident zero: every call succeeded, the
coins were simply absent. scantxoutset walks the node's current utxo set
directly and finds them regardless of when the account was registered.
runs all four script variants sequentially — the funds could be on any
one — and surfaces progress through the existing SyncState channel so
the balance says "scanning" rather than nothing. auto-fires on an
imported mnemonic only; a generated seed has no history to look for.
recovers spendable coins, not spent history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Background tasks already had a row in the transcript, but a row scrolls away — and a
task started ten minutes ago is precisely the one you want to keep an eye on. The same
`role: 'task'` rows now also drive a tray docked above the chat input: a chip per task,
running ones pulsing, finished ones dismissable.
Clicking a chip opens what the task is actually doing right now. Nothing about that
crosses the wire between `task:started` and the notification, so it is read from the
file Claude Code streams the task into:
$TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output
For a backgrounded shell that file IS the log; for an agent it is a symlink to the
subagent's own transcript, which is ordinary session JSONL and so parses with the
reader we already had. Both kinds are therefore reachable from one directory.
Resolution is by task id alone, deliberately: the client learns a task id from
`task:started` and nothing else — officer's per-connection session key is not Claude's
session uuid, and the uuid only arrives with the turn result, long after the tray needs
to show the task. A task that has not written anything yet answers 200 `{kind:'pending'}`
rather than 404, because that is the ordinary first second of a task's life.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
it said fourteen; there are nineteen. photos, notify, caldav, memos and jellyfin have landed
since it was last written. also names the two mybiblepal entries that share this pm2 daemon
and belong to a different project, and points at `pm2 jlist` as the source of truth so the
next reader does not trust a list that goes stale every time a sidecar ships.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nothing the player did ever reached jellyfin, so nothing ever appeared in continue watching
or in the currently-playing list. the proxy was fine — a progress POST through the sidecar
moves the resume point upstream and answers 204. the client was the problem.
useClient() rebuilds its verbs on every render, so `report` had a new identity every render,
so `sendReport` did, so the effect whose cleanup reports the final position re-ran on every
render — and that cleanup nulls planRef. planRef went null a few milliseconds after the
stream opened and every report after that returned early. the ten-second heartbeat never
fired either: its interval was cleared and restarted on every render, and timeupdate renders
about four times a second.
both are now held in refs, and the two effects have honest dependency lists. same treatment
for the negotiate mutation, which react query also rebuilds per render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sidecar now describes every audio and subtitle stream on the chosen source. a subtitle
jellyfin can deliver as an external file gets a /_jf webvtt path with the embedded ApiKey
stripped; one it cannot gets the reason instead, so the picker can show it disabled rather
than pretend it does not exist. colliding labels get their stream index — two tracks called
"English - ASS" are a coin flip otherwise.
quality is a named rung rather than a number. a bitrate the owner picks maps onto the rung
at or below it, which is what stops jellyfin's ResolutionNormalizer from inventing a
resolution from an unrounded number. auto stays absent so a copyable source still copies.
the control bar is ours in all three transports. progressive has no timeline the browser can
render, and an audio-track or quality change is a re-negotiation with the server rather than
something a <video> knows how to do — one bar for all three is what keeps those from being
three different players. the menus are hand-rolled because a radix dropdown portals to
document.body, which is outside the fullscreen element and would be invisible exactly when
the player is most likely to be used.
no scrubber thumbnails: they come from trickplay tiles and this server generates none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a live transcode has no length and no byte ranges, so the progressive mp4 the player
used could not be seeked — a scrub restarted ffmpeg at a new offset. jellyfin's own
HLS playlist is VOD and spans the whole runtime, so seeking it is a segment request.
hls.js is checked in rather than installed. installs are frozen so that adding a
package is a reviewed act, and a committed file also has no install-time hook, which
is the vector the 2026-08-04 npm worm used. provenance, hashes and the update recipe
are in vendor/README.md; the tarball sha512 matches the registry's published integrity.
the sidecar now overrides VideoBitrate and MaxWidth on the TranscodingUrl jellyfin
hands back, for the same reason progressivePath computes them: jellyfin resolves that
bitrate from MaxStreamingBitrate (~119 Mbit, the ceiling that exists to let a stream
copy through) and sets no width, which asks a CPU-only container to encode 4K.
safari is deliberately not given the m3u8 — segment URIs are relative and would not
carry the ?token=, and it cannot set an Authorization header the way hls.js can.
progressive stays as the fallback for any browser without MSE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a transcode url without `videoBitrate` is not "let the server choose".
jellyfin resolves the missing value to 0 and runs ResolutionNormalizer
over it, which maps a bitrate onto a resolution — 0 lands on the bottom
rung. verified here: a 3840x1600 hdr source came back through
scale=...min(max(iw,ih*a),416)... with -maxrate 0.
the number has to be picked per source, because the two cases pull
opposite ways: a cap is a reason for jellyfin to REFUSE a stream copy,
and the absence of one is what makes a cpu-only 4k encode hopeless. so
copyable h264 asks for a ceiling nothing hits and no width, and anything
being genuinely re-encoded asks for 1080p at 12 mbit.
verified both branches against the ffmpeg command jellyfin logs: h264 mkv
→ -codec:v:0 copy, hevc 10-bit hdr → libx264 at 1920 wide, tonemapped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.
the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-jellyfin owns the whole Jellyfin contract: the instance URL, the access
token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed
by. The platform side is a 17-line proxy holding no credentials.
Servers are a registry, not a single row — this machine runs four instances and
the owner switches between them. The password is never stored: it is traded once
for an access token through AuthenticateByName, and only that token is persisted,
encrypted.
Two doors. /_officer/* is a hand-written JSON façade for the things the browser
should not have to know — the user id in the path, the Fields lists that decide
whether a grid has posters, the PlaybackInfo negotiation. /_jf/* is a GET-only,
allow-listed byte pass-through for images, video, HLS and subtitles; it keeps
Jellyfin's own paths because a master playlist references its segments
relatively, so any renaming would mean rewriting m3u8 bodies.
TranscodingUrl arrives with api_key=<access token> in its query string and would
otherwise be handed straight to a video element. It is stripped before anything
is returned; the pass-through re-adds the credential as a header.
Video only — Officer's own player owns audio, so music collections are filtered
out of the library list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
bun.lock and nothing else — it fails rather than quietly picking up a newer
version, including a transitive one nobody chose.
Config rather than a documented habit, because a supply-chain compromise does not
wait for the one time somebody forgets a flag. On 2026-08-04 eleven cache
packages — keyv, flat-cache, file-entry-cache, cacheable-request, cache-manager,
the @cacheable/* scope and ecto — were published with a preinstall dropper that
harvested npm and GitHub tokens, AWS and Kubernetes credentials, SSH and PEM
keys, .env files and .claude/settings.json, then republished itself through any
npm token it found, reaching 434 further packages across 1381 versions. This
machine was unaffected only because nothing had installed since 2026-08-02.
Verified on bun 1.3.10 rather than assumed, and the first result was wrong: with
NO lockfile present, neither the flag nor the config refuses — bun simply creates
one, so an initial test made the setting look ignored. Against a lockfile that no
longer satisfies package.json, both exit 1 with "lockfile had changes, but
lockfile is frozen". The config is honoured; the earlier reading was a bad test.
`bun install` in this repo still reports 1058 installs, no changes.
Applied to monorepo-mobile too, where flat-cache, file-entry-cache and keyv are
present as eslint/got transitive deps — at versions old enough to be unaffected,
which was luck rather than design.
Documented in platform/CLAUDE.md and the workspace root, including the part that
matters most: do NOT add --no-frozen-lockfile to a script, a Dockerfile or CI to
make the error go away. The error means the lockfile and package.json disagree,
and an unexplained lockfile change in a diff is precisely the signal this exists
to produce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
99.97% printed as "100.0%" beside a still-blue bar, which reads as a stuck
torrent. only a genuinely complete fraction shows a hundred now, as "100%".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the album view already carries the transport, so the full-width dock was a
second bar costing the workspace a row. the host stays mounted (it owns the
engine) and only withholds its bar on /music; MusicMiniBar draws the scrubber
at the foot of the library panel, with play/pause and the lyrics toggle for
when you browse away from the album that's playing.
player-time now publishes duration alongside position so a scrubber outside
the host's tree can render without a 60hz state channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
they are indexed like any other folder — all six carry cover:true and serve a
jpeg — so the root view uses the same card as the folder grid instead of flat
gradient tiles. a root that holds tracks directly gets the hover play button
for free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
same useGlobal MUSIC_PLAYER state, so on the album that is already loaded it
shows pause while it plays and resumes rather than restarting from track one.
any other album still starts from the top.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sheet forces the dark theme tokens on its own subtree — they are CSS
variables scoped to a .dark ancestor, so a light theme would otherwise paint
near-black text on black. inactive synced lines drop the /50 and use plain
muted-foreground, the same grade as the artist under each track title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the microphone is the same switch in two places — the play dock and the album
header — so its state moves to a channel seeded from localStorage. turning it
on splits the /music detail panel in two with a nested WorkspaceLayout: a fixed
layout, components keyed by panel id, no persistence and no registry entries.
the album view is handed to the left panel through context, so the split moves
the same element instead of remounting it and refetching the album.
playback position now reaches the pane through a module-level publisher rather
than props — it lives outside the player's subtree, and the feed ticks every
animation frame. the pane subscribes and re-renders only when the active line
changes, about once a line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the mobile app's lyric parser (packages/core/src/services/lyrics.ts) to the web player and
expands a sheet above the play dock. Synced .lrc lines highlight, auto-scroll and seek on click;
plain .txt scrolls by hand. The server side already served all of this — /api/music/lyrics and the
indexer's embedded-USLT extraction — so nothing changed behind the proxy.
The sheet lives in the dock rather than a /music panel because the dock is mounted app-wide: lyrics
follow the music onto every screen, and no saved workspace layout has to be migrated to see it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.
API — all owner-only, mounted on the existing users router:
GET /api/users list, plus the role enum so the UI never
hand-writes the names
PATCH /api/users/:id/role change a role
DELETE /api/users/:id remove an account
ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.
The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.
The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.
The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.
Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.
Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.
Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guards on user 1, the bootstrap account, so ownership survives whatever
happens to the rows.
ck_users_owner_is_super_admin — CHECK ((id <> 1) OR (role = 'Super Admin')).
In the database rather than in application code because the point is that it
holds against a stray UPDATE, a migration script or someone at a psql prompt,
not just against the API. A row-level CHECK can say "if this row is user 1 then
its role is Super Admin"; it cannot say "some row must be Super Admin", which
would need to see other rows. So it pins the bootstrap account and nothing else —
promoting and demoting everyone else stays free.
deleteUser() refuses id 1, because a CHECK cannot stop a DELETE and removing the
owner reaches the same end by another route: nobody who can open the vault, no
identity for the agent sidecar to run as, a web origin restricted to a Super
Admin that no longer exists, and the passkeys cascaded away so there is no
signing back in. It throws rather than returning false — its eventual caller is a
manage-users flow, where a silent false reads as "already gone".
OWNER_USER_ID is exported from the schema and used by both, so the number appears
once.
Tested on a scratch database, and the first harness was wrong — a shell variable
holding a command did not expand, every statement failed with "command not
found", and the check reported them all as allowed. Re-run directly:
insert user 1 as Super Admin allowed
demote user 1 -> Member REJECTED by CHECK
demote user 1 -> Admin REJECTED by CHECK
insert user 1 as Member REJECTED by CHECK
demote/promote users 2 and 3 allowed
deleteUser(1) refused with the message above
deleteUser(3) deleted
Pushing twice showed the CHECK adds no diff churn — still only the two known
pk_music_now_playing statements. Every row in officer_dev already satisfies it,
so it will apply without touching data.
NOT covered: nothing stops a second account also being Super Admin. The rule
asked for was "user 1 is always Super Admin", not "only user 1 is", and the two
are different constraints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There were three answers to "who is the owner" and nothing kept them agreeing:
SUPER_ADMIN_EMAIL in .env, the lowest user id, and now the role column. Any two
of them part company the moment one is edited, and the failure is silent — an
account quietly gains or loses the vault, the platform origin, and the identity
the agent sidecar runs as. The column wins; the other two are gone.
- isSuperAdmin() reads users.role. SUPER_ADMIN_EMAIL is deleted from the code and
from .env.
- getOwnerUser() selects on the role instead of `order by id limit 1`. It returns
undefined when no row holds it rather than falling back: the agent sidecar
refusing to start beats it silently running as the wrong person.
- bootstrap creates the first account with role 'Super Admin'. Without this a
fresh install would take the column's 'Member' default and come up with NO
owner at all — no vault, no agent identity, and the web origin locked to a
Super Admin that does not exist. That bug was live the moment the column
landed; SUPER_ADMIN_EMAIL was masking it here.
Deliberately not cached. The old resolver cached an owner id, justified by "the
owner never changes at runtime (bootstrap is closed after user #1)" — which stops
being true as soon as roles are editable, and a cache with no invalidation
contract is a staleness bug waiting for whoever builds the role UI. It is one
primary-key lookup on a request that has already verified a JWT.
Verified against the live database: getOwnerUser resolves to id 1, isSuperAdmin
is true for it and false for the three Members, for a null payload and for an id
that does not exist. Then temporarily set id 13 to 'Admin' (false) and to
'Super Admin' (true) with no restart in between, which is what proves the column
is doing the deciding rather than a cache or the old lowest-id path. Reverted.
Not solved here: nothing stops the last Super Admin being demoted, which would
leave the platform ownerless. The schema cannot express it; whatever eventually
edits roles has to. Noted in the column's comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
begin() only sets `started` after a fetch and a full decode, so anything that
called it during that window saw started === false and started a second decode
of the same track. both finished, both called startBuffer, and only one of the
two sources ended up in `cur`.
starting a queue from a paused player did this every time: the host commits a
new queue and playing: true in one render, its queue effect calls
load(autoplay) -> begin, and its playing effect then calls play() -> begin
again, same generation.
it compounds, which is why it sounded like three songs and not two. the twin
keeps its own onended, so at the boundary advance() ran twice: the index jumped
two tracks and a second source was promoted while the first was still sounding.
three changes. begin() refuses re-entry for a generation it is already running.
onended only advances the queue if the source that ended is the one in `cur`.
and every source is registered in a `live` set, because cur/nxt is what the
engine reasons about while `live` is what it is responsible for silencing — an
untracked web audio node cannot be stopped by anything except closing the
context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>