Commit Graph
985 Commits
Author SHA1 Message Date
pastilhas b6b9b01aa6 keep the headscale server form on screen when the list query fails 2026-08-06 03:09:38 +00:00
pastilhas 217d5ed29a headscale: describe an acl change in english, read the diff, then save
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.
2026-08-06 01:23:54 +00:00
pastilhas 08948bc4aa headscale: give the server picker its own panel 2026-08-06 01:11:17 +00:00
pastilhas 5f6377ac33 headscale: share the invite's https link, drop the deep link 2026-08-06 00:48:45 +00:00
pastilhas 91ed90893d todo: headscale invites are live on all four servers 2026-08-05 19:02:32 +00:00
pastilhas 9dd3f76e56 headscale: read the invite list out of whatever envelope the companion sends 2026-08-05 19:02:20 +00:00
pastilhas 864140998f remove stray companion probe script 2026-08-05 18:49:52 +00:00
pastilhas 3ca5968605 headscale: invites live under the companion's /api/v1 mount 2026-08-05 18:49:41 +00:00
pastilhas 4cee0335c2 headscale: device invites — admin surface for offscale enrollment 2026-08-05 18:10:44 +00:00
pastilhas 55abaa4042 headscale: probe every server when the list opens 2026-08-05 17:07:18 +00:00
pastilhas 2e2eebc9fd headscale: compare node tags as a set, not a sequence 2026-08-05 17:03:40 +00:00
pastilhas 75d6b65119 headscale: key the server form to the server it edits
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.
2026-08-05 17:02:41 +00:00
pastilhas e8b19229cb headscale: acl policy editor, node owner and tags
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.
2026-08-05 16:48:27 +00:00
pastilhasandClaude Opus 5 d6d405100c headscale: diagnostics from the officer companion
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>
2026-08-05 16:17:01 +00:00
pastilhasandClaude Opus 5 208f26ad89 headscale: an ssh console for when the api cannot answer
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>
2026-08-05 14:10:33 +00:00
pastilhasandClaude Opus 5 2f92f9b15c note the headscale gaps in the todo
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>
2026-08-05 13:48:38 +00:00
pastilhasandClaude Opus 5 acff072f5c a resting mouse is not the user
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>
2026-08-05 02:01:04 +00:00
pastilhasandClaude Opus 5 541b2a509b the player chrome can actually hide now
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>
2026-08-04 23:41:57 +00:00
pastilhasandClaude Opus 5 e1cc3a2cd5 the task tray shows the newest first, and forgets the oldest
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>
2026-08-04 22:18:37 +00:00
pastilhasandClaude Opus 5 ddc982ce5b a rescan can be told how deep to look, and a node wallet cannot be deleted blind
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>
2026-08-04 21:00:24 +00:00
pastilhasandClaude Opus 5 a61d5c3a91 frozen coins are never spendable, and a rescan already running is adopted
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>
2026-08-04 20:56:37 +00:00
pastilhasandClaude Opus 5 e0f6a469aa rate-limit every passphrase check, and stop address issuance outrunning the scan
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>
2026-08-04 20:41:46 +00:00
pastilhasandClaude Opus 5 bc06fbb2a5 honour frozen coins in automatic selection, not just coin control
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>
2026-08-04 20:25:49 +00:00
pastilhasandClaude Opus 5 1d9a648ff7 stop the generic wallet PATCH from being able to replace the seed
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>
2026-08-04 20:20:12 +00:00
pastilhasandClaude Opus 5 d2290baa56 hide the rescan control on a source that cannot rescan
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>
2026-08-04 20:08:43 +00:00
pastilhasandClaude Opus 5 22c0e83b49 recover an imported wallet's coins with an nbxplorer utxo scan
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>
2026-08-04 20:06:10 +00:00
pastilhasandClaude Opus 5 82aa39a05f chat: pin background tasks above the input and let you look inside them
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>
2026-08-04 19:40:26 +00:00
pastilhasandClaude Opus 5 6fde7afd7f docs: refresh the pm2 process list
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>
2026-08-04 19:27:33 +00:00
pastilhasandClaude Opus 5 4b094aa3a5 jellyfin: actually report playback progress to the server
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>
2026-08-04 19:16:37 +00:00
pastilhasandClaude Opus 5 7884bd3290 jellyfin: subtitle tracks, audio picker and real player controls
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>
2026-08-04 19:10:44 +00:00
pastilhasandClaude Opus 5 7732ca1da5 vendor hls.js and play transcodes over hls
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>
2026-08-04 18:52:15 +00:00
pastilhasandClaude Opus 5 9990c04008 jellyfin: ask for a bitrate, or get 416 pixels
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>
2026-08-04 18:34:27 +00:00
pastilhasandClaude Opus 5 a894d64cb3 jellyfin panel app: browse, detail and player
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>
2026-08-04 18:16:45 +00:00
pastilhasandClaude Opus 5 904edefd62 jellyfin sidecar: server registry, video façade and byte pass-through
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>
2026-08-04 17:59:50 +00:00
pastilhasandClaude Opus 5 f2052fbdaa freeze installs: bun resolves from the lockfile or fails
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>
2026-08-04 17:58:07 +00:00
pastilhasandClaude Opus 5 5995cd3ab7 transmission: floor progress percentages instead of rounding
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>
2026-08-04 17:31:31 +00:00
pastilhasandClaude Opus 5 ce5bac3cf5 music: album art on the /music mini bar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:59:47 +00:00
pastilhasandClaude Opus 5 81209ae66d music: replace the dock with an in-panel scrubber on /music
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>
2026-08-04 16:47:06 +00:00
pastilhasandClaude Opus 5 1be43df75b library roots get cover art in the music grid
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>
2026-08-04 16:39:23 +00:00
pastilhasandClaude Opus 5 1712a8b4d5 the album play button is the dock's play button
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>
2026-08-04 15:44:42 +00:00
pastilhasandClaude Opus 5 c3279280de black lyrics sheet, and lift the inactive lines out of the murk
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>
2026-08-04 15:41:45 +00:00
pastilhasandClaude Opus 5 2c136c4b93 lyrics render in a split of the music detail panel, not a dock sheet
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>
2026-08-04 15:22:13 +00:00
pastilhasandClaude Opus 5 5be85db043 lyrics sheet in the music dock
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>
2026-08-04 14:59:28 +00:00
pastilhasandClaude Opus 5 a328451250 add /settings/user-management
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>
2026-08-04 14:57:48 +00:00
pastilhasandClaude Opus 5 4ccdb8a1fa db: the owner account cannot be demoted or deleted
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>
2026-08-04 14:48:04 +00:00
pastilhasandClaude Opus 5 8de3b1908c auth: the role column decides who owns the server
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>
2026-08-04 14:42:35 +00:00
pastilhasandClaude Opus 5 c0e7364a90 stop the music player playing two tracks at once
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>
2026-08-04 14:37:45 +00:00
pastilhasandClaude Opus 5 a9b6494f56 db: stop push re-creating every composite key on every run
`bun db:push` planned 32 statements against a database that already matched the
schema, and stopped on a "do you want to truncate screens?" prompt that answering
could not resolve — the same question came back next run. Root cause found and
fixed rather than worked around.

drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads one back,
compares it against a schema declaring the identical name, columns and order,
decides they differ, and emits DROP + ADD. Fifteen of those, forever. Reproduced
on a database drizzle had itself created seconds earlier, so it is not drift.
Single-column .unique() is diffed correctly; only unique('name').on(a, b) is
affected. Unique indexes go through a different code path and are stable, so all
fifteen are now uniqueIndex.

A unique index enforces exactly what the constraint did — verified, a duplicate
insert still fails on uq_screens_user_name — and onConflictDoUpdate accepts it as
an arbiter. It cannot be a foreign-key target, but nothing here targets a
composite key; checked before converting.

Separately, user_integrations_server_integration_id_server_integrations_id_fk is
65 characters and Postgres truncates identifiers at 63, so drizzle compared its
generated name against the stored, truncated one and re-created the FK every run.
Declared explicitly as fk_user_integrations_server_integration.

Measured on a scratch database, pushing twice each time:
  before        32 statements, interactive prompt
  after uniqueIndex   4
  after FK fix        2

The two that remain are a composite primaryKey with the same bug and no index
form to escape to — music_now_playing re-creates pk_music_now_playing every push.
Silent, no prompt even with rows, data unaffected, and naming it explicitly does
not help. Documented as expected.

The conversion itself was tested against populated tables, since that is what the
real database will do: no prompt, and all rows survived.

Docs rewritten in src/databases/CLAUDE.md — the rules committed an hour ago
described the broken behaviour and would have been wrong the moment this landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:35:26 +00:00
pastilhasandClaude Opus 5 0799ec7325 commit the mobile dav correspondence
the letter that asked for the provisioning endpoint and the reply that answers
it. both lived only in an untracked COMMS/ directory on the dev box, which is
where the reasoning behind an api contract goes to be lost.

the feedback comes with it: a reply that answers a letter nobody can read is
half a record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:30:25 +00:00
pastilhasandClaude Opus 5 6af80a20b0 db: add the user role column, and write down how push actually behaves
Adds `role` to users as a text column with a TS enum, matching how `status` is
done — no pgEnum, so adding a role stays a one-line schema change rather than an
ALTER TYPE while the set is still settling. USER_ROLES and UserRole are exported
from officerdb so the API and UI enumerate them from the column definition rather
than a second hand-written list. Defaults to 'Member', the least privileged, so a
row created by a path that does not think about authorisation cannot mint an
admin. NOT YET PUSHED — the column is in the schema, not in the database.

The larger half of this commit is a rule for anyone running `bun db:push`,
because the first time you run it, it looks like your change broke something.

It plans 16 statements against a database that already matches the schema, and
plans them again on the next run. Two separate causes, both diagnosed here:

- it drops and re-adds every NAMED COMPOSITE unique constraint — all 14, from
  uq_screens_user_name to uq_wallet_labels_wallet_kind_ref. Single-column
  .unique() diffs correctly and is untouched; only unique('name').on(a, b) is
  affected. Names, columns and order in the database are identical to what the
  schema declares. drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
- it drops and re-adds one foreign key because
  user_integrations_server_integration_id_server_integrations_id_fk is 65
  characters and Postgres truncates identifiers at 63, so drizzle compares its
  generated name against the stored, truncated one and always differs.

The rules, ranked by how much damage getting them wrong does: never answer "Yes,
truncate the table" — it destroys rows and does not help, since the constraint is
re-added next push either way; never delete a constraint from the schema to
silence the prompt, because upserts depend on it existing; do not reach for
--force until someone has established on a scratch database which branch it takes.
Run with --verbose first and read what it is actually planning.

Pointers added to platform/CLAUDE.md and the workspace root CLAUDE.md, since
those are what a session reads before it ever opens the database directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:22:50 +00:00