hover-revealed means most people never find it, and touch has no hover at all.
70% white on the dark block, brightening on hover; the pre reserves right
padding so a long first line scrolls up to the button instead of under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the bubble's copy button copies the whole reply, which is the wrong unit when
the reply is prose ending in one command to run. fenced blocks get their own
button; inline code doesn't. text read from textContent at click time rather
than the markdown ast, trailing newline stripped so a pasted command doesn't
run itself. the positioned wrapper takes the vertical margin, or the pre's own
margin collapses through it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pressing stop ended the turn with "Claude Code returned an error" — the agent
sdk reports interrupt() as an ordinary failed result, indistinguishable from a
real fault downstream. the sidecar now flags the session it interrupted and
rewrites that event to the existing durable 'stopped', which opencode already
emitted. escape stops the turn (bound to the chat subtree, not the document),
and the prompt comes back to the composer verbatim unless you've started typing
something else. history parity: claude files [Request interrupted by user] as a
user message, so the transcript reader maps those exact strings to the same
role instead of replaying them as something you typed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the server half of the previous commit, which belonged with it. the frontend
guard needs both lists: absence from `routes` cannot tell a route this
account lacks from a route no capability claims, so without this the guard
permits everything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
useCapabilities is the frontend's view of the model and explicitly NOT its
enforcement — hiding a dock icon is a courtesy, the 403 in origin-validation
is the lock. so it fails OPEN: if the request errors the full dock renders.
a member clicking through to a 403 is a bad minute; an owner locked out of
their own platform by a transient network error is an incident, and the
server refuses what it should refuse either way.
the endpoint returns held routes AND denied routes, because absence from the
held list cannot distinguish a route this account lacks from one no
capability claims at all — `/`, the settings shell — and a guard that cannot
tell those apart either blanks the app or guards nothing. i wrote the first
version without the second list and it silently permitted everything.
`can` and `canVisit` are memoised on the query data. a verb rebuilt every
render gets a new identity every render, which is how every playback report
in the jellyfin player was disabled for days; the dock filter puts one in a
useMemo dependency list, so it would have been the same bug.
the permissions screen is one role at a time, with an explicit save and a
dirty state, rather than a roles-by-capabilities grid — a grid invites
reading across rows, which is not a question anyone has, and makes revoking
gitea for every member one click among fifty. it also states plainly why
terminal, chat, files and the rest are absent, so their absence reads as a
decision rather than as a missing feature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sessionStorage is the per-tab store — separate per tab, survives reload and
navigation, dies with the tab. The route title is derived rather than assigned,
so navigating no longer wipes a name you typed.
Duplicating a tab clones sessionStorage, so a `navigate` that arrives already
holding a name is treated as a clone and drops it; fails soft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GET /api/user/capabilities is what the caller may reach, and every account
may ask — it is mounted on a core capability so an account granted almost
nothing can still find out what it has. the dock and route guards read it.
it is a courtesy, never enforcement: hiding an icon is not access control
and the 403 in origin-validation stays the lock.
GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path
is where the registry's authority over capability keys is applied, which is
why the column has no CHECK: unknown keys and non-app kinds are refused
rather than stored for the resolver to drop on read.
the exception is `selfService`. useAuth calls PUT /api/users to change your
own name and avatar, and that route has always lived on the same router as
the owner-only account administration around it — so declaring /users an
admin capability locked every member out of their own profile. moving it to
/api/user would be tidier and would break every shipped mobile client, so
instead the registry says out loud that this one route is not what the
capability around it is. exact method and exact path, so it cannot widen:
verified that PUT /api/users passes while GET /api/users, PATCH
/api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all
still refused.
23 unit tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
replaces the account backstop. it was two hand-written lists — NON_OWNER_PATHS
confining every non-owner to /api/auth + /api/music, and NON_OWNER_WS_PROVIDERS
doing the same for sockets. they were not wrong, they were unscalable in one
specific way: an allow-list answers "which paths" but never "why", so onboarding
anyone who needed anything other than music meant editing an array in a
middleware file and hoping the socket half got edited too.
now both doors resolve against the registry, so they cannot disagree about what
a role holds. terminal, chat, task-runner, pipeline and desktop are refused by
being `execution` capabilities rather than by being absent from a list somebody
maintains.
fail-closed everywhere: an unknown capability key, a missing row, a database
error or a deleted user all deny. the grant cache is keyed on role and has an
explicit invalidation contract — unlike the one super-admin.ts refuses to have,
this one has exactly one writer and it lives beside the reader.
seeded Member → music at WRITE, which is precisely what the old path-based
backstop allowed. granting `read` would have been a silent downgrade that broke
playlists for the three live member accounts overnight.
verified against the live database and real accounts: 27 http/socket cases, the
read/write split (personal sub-paths writable at read, /music/scan not), cache
invalidation after a revoke, and the borrowed test account's role restored.
20 new unit tests; full suite 362 pass 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
role_capabilities (role, capability, level). the subject of a grant is a
role and never a user — the owner's call, and it keeps "what can a Member
do" a question with an answer, which per-user rows would not. when one
person genuinely needs something different, that person needs a role.
a missing row means no access. nothing denies; absence denies. an empty
table is a freshly installed server where members reach nothing but their
own account, which is the right starting state.
the capability key is deliberately unconstrained: a CHECK listing the keys
would put the registry in two places and turn adding one into a schema
change. the api validates against the registry instead. what the database
does enforce is shape — a legal role, a legal level, one grant per pair, and
no rows for Super Admin, since the owner bypasses this table entirely and an
inert row that looks meaningful is worse than no row.
uniqueIndex not unique().on() per databases/CLAUDE.md. verified: pushed to a
scratch db twice, second push planned only the two known-harmless
pk_music_now_playing lines, so the declaration is stable. applied to
officer_dev without --force and without a prompt. all six constraint cases
behave — bogus role, bogus level, a Super Admin row and a duplicate pair are
each rejected; a legal grant and the same capability on another role are
accepted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
permissions are capabilities, not routes. a capability is a feature — the
unit the owner grants, the dock filters on and the acl enforces — declaring
the api prefixes, websocket providers and screens it expands to.
four kinds. core is every account and is not grantable because it is not
deniable. app is the grantable surface. admin is the platform administering
itself. execution is never grantable at any level: terminal, chat, tasks,
files, desktop and browser all run as the owner's os user in the owner's
home, so granting one is co-ownership of the machine rather than a feature.
the part that matters is assertCapabilityTotality. the websocket hole fixed
in 2873948 was not a wrong rule — it was a door added without telling the
rule, because bun's route table matches /api/terminal/ws before the /api/*
catch-all that reaches hono's middleware. so the server now refuses to start
unless every mounted prefix and every user-facing socket maps to exactly one
capability. hono.ts mounts from a table and exports it, so the check reads
the real surface instead of a copy that can drift from it.
verified: passes against the live surface, and refuses all four ways — an
ungated router, an ungated socket, a claim on a deleted router, a claim on a
deleted socket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list collapses each chain to its newest link — the only one that can be
resumed — carrying the root's title and start time, the summed message count
and a part badge. The detail splices the chain's transcripts oldest-first with
a divider between parts, server-side, so the client's index-window pagination
needed no change.
The divider says "context cleared — nothing above this is in memory", because
the whole risk of merging is that the history reads as continuous when the
agent's context is not. Delete cascades the chain and the confirm says how many.
Supersedes the "continues X" line from the previous commit: there is nowhere to
link to once the parent is scrolled up above you.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nothing reads it and nothing writes it any more, but a refresh re-requests the
address bar verbatim, so one left over from before the path-based groups sits
there indefinitely looking like it means something. on a bare /chat it still
says which group you wanted, so upgrade it to /chat/g/<path> rather than
dropping it — an old bookmark keeps working. anywhere else the session decides
its own directory, so it is just removed. other params untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/clear starts a new claude session and the list showed it as an unrelated
conversation. claude records no parent link anywhere — not in compactMetadata,
logicalParentUuid, summary.leafUuid, the per-session slug, or the live process
registry — so infer it: a cleared transcript opens with /clear, and /clear
happens inside one process, so the parent is the conversation in the same group
that was writing to disk at the instant this one began (4ms apart, measured).
matching is on per-minute activity rather than updatedAt, because resuming a
parent moves its end time past its child's birth and lost the link entirely for
two of the three cleared sessions here. the window is symmetric because clearing
makes claude summarise the conversation it is ending, so the parent's final
record can land after the child's first. ambiguity fails closed — the wrong
parent also renames the conversation.
read-only: nothing is written back to claude's store, and an explicit title
always wins.
also: cleared sessions were titled "<command-name>/clear</command-name>" because
claude does not set isMeta on slash commands; and the chat header was hardcoded
to undefined, so it read "New chat" above every conversation you opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.
the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.
loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.
the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.
also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.
walkthrough doc gains item 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
twelve items in click-through order, each with where to look and what the old
behaviour was — several are only visible if you know what was broken. states
plainly at the top that none of it has been rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read-aloud swallowed every error: the spinner stopped, the speaker icon came back, and a
TTS service that was simply down looked identical to a button that did nothing. Both the
synthesis failure and the playback failure now say so.
errorText moves to helpers — useClient rejects with a plain { status, message } object
rather than an Error, so the reflex instanceof check reports every API failure in the app
as "unknown error". Two callers now, and it is the wrong thing to reimplement per file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--duck-dark inverts to near-white in dark mode, so every place the chat used
it as a text or border colour was drawing light-on-light: the composer's own
text, the question prompts, the launcher textarea, the session detail bar, the
"send a message to start" placeholder. all of it moves to the semantic tokens,
along with the raw red-500s, which had no dark story at all.
the background task tray had seven labels below the 12px floor, including the
live log itself, and hardcoded green-600/red-600/amber-500 where the shared
tones exist. its detail panel was capped at a flat max-h-64 while the composer
it docks in is shrink-0 and the transcript above is flex-1 — so in a short
panel an open task could leave almost no conversation visible. capped against
the viewport too.
a failed background task drew the same CircleSlash as a stopped one: the two
outcomes you most need to tell apart were one glyph.
the attach menu offered four things and did two — "Text File" and "PDF" had no
onSelect at all. text files now inline into the composer as a fenced block
with the filename, size-capped and rejected if they turn out to be binary. PDF
is removed rather than faked: nothing in the platform extracts PDF text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the list drew its own border per row on top of nothing, so every boundary
between two rows was a double hairline, and it emphasised two things per row
where the design language allows one. DataRow/DataList settle both.
loading and empty were the same grey sentence, which made a slow transcript
read look like an account with no history; they are now LoadingBlock and
EmptyBlock, and a failed read gets an ErrorBlock with the actual message
instead of rendering as "no sessions".
rename and delete swallowed their failures whole — useClient only raises a
dialog for 401, 403 and 5xx, and the likely error here is a 404 from a
transcript that vanished under you. both toast now, as does a deep link to a
session that cannot be read, which used to open an empty pane and say nothing.
the New Chat button was duck-teal filled with duck-yellow text: duck-teal is
a bright cyan in dark mode and duck-yellow has no dark override, so the pair
sat near 2:1 contrast in both themes.
active-row highlight now comes from the route rather than the selection
channel, so it is right on a deep link before any panel has published, and
deleting the open session navigates out of it instead of leaving a dead route.
deletes SessionBar and SessionContextMenu: the first was exported through two
barrels and imported nowhere, the second was never imported at all and typed
its session id as a number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the most-looked-at panel in the chat was the worst rendered: an Edit showed
old_string and new_string as two flat monospace blobs, so the only question
you had — what changed — was the one thing you had to work out by eye.
adds a small LCS line diff rather than a dependency; jsdiff would ship to the
browser to do a textbook algorithm that is shorter than its own integration.
no line numbers, deliberately: Edit fragments carry no file position, and an
invented line number is worse than none.
the collapsed row now carries +n −m, so the size of an edit is legible without
expanding it, and copy yields the resulting text rather than a key: value dump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the provider label was bg-duck-dark/80 text-white, and --duck-dark is
near-white in dark mode, so it was white on white for every session that
had already started. same class on the active provider tab.
the streaming bubble bailed on empty text, so the wait between send and
the first token — tens of seconds with thinking on — rendered nothing at
all. it now shows the bubble with pulsing dots.
also: tool status tones onto tone.ts instead of a third copy of the same
green/red/amber ternary, sub-12px labels up to text-xs, break-all to
break-words so shell commands stop breaking mid-identifier, and
break-words on the user bubble so a pasted url stays inside the pane.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sessionKey to claude uuid map is what --resume needs to reattach a
conversation after the agent sidecar dies, so a 30s debounce put exactly
the wrong state behind a window. flushAndSave on SIGTERM covers a pm2
restart but not a crash or SIGKILL, which is the case resume exists for.
an equality guard keeps onSessionId from thrashing the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commits, branches, cross-repo issues, notifications and organizations all move
onto DataRow, which is what makes them agree with each other — five lists that
were each hand-assembled from the same flex/gap/truncate parts now genuinely
share one row.
The repo header gets the same owner-muted / name-semibold split as the list row,
so the two screens agree about what a repository is called. Its description goes
from 12px to 14px, and the tab strip with it — those are read, not scanned, and
they were the smallest text on the busiest screen.
DataRow gains `href` for destinations outside the app. Notifications needed it:
Gitea's subject URL can usually be parsed back into an in-app route and
sometimes cannot, and the row should stay clickable either way rather than
becoming a dead div on the payloads that do not parse.
Empty states across the dashboard now say what the search actually covers.
"No open issues" was hiding that Gitea's cross-repo search only ever looks at
issues you created, are assigned, or are mentioned in — which is the difference
between a quiet week and a misconfigured token.
Typechecks clean and prettier is clean. Still not rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gitea goes first because it is the first Officer surface anyone outside this
machine will see.
GiteaBits is now a thin adapter over components/Data rather than its own set of
styles: it translates Gitea's vocabulary into the five tones and gets out of the
way. Export names are unchanged so the ten call sites did not all have to move
at once.
The one judgement call worth flagging is CLOSED ISSUE -> neutral instead of a
colour of its own. Most issues in any list are closed, so giving them a tone
paints most of the screen and leaves the open ones no quieter than the rest;
neutral is what makes "open" findable, which is the only thing anyone scans an
issue list for. A closed PULL request keeps danger — that one was rejected, and
rejection is a real outcome. Merged is info, because a merged PR and an open one
were previously the same green.
Repositories and issues now build on DataRow. Repo rows put the owner prefix in
muted and only the name in medium, so one weight per row survives; issue rows
truncate rather than wrap so every row keeps one height and the list can be
scanned down its left edge. Timestamps are RelativeTime, so "2 months ago" now
carries the exact date as a hover title instead of losing it.
Swept the whole app for drift: emerald/amber/sky/red palette numbers to
success/warning/info/destructive, and every text-[10px] and text-[11px] to
text-xs. ring-black/5 and text-black/50 are gone — both were invisible in dark
mode. Nothing below 12px remains anywhere in Gitea.
Empty states say why they are empty now. "No open issues" on a repo with fifty
closed ones was technically true and useless.
Typechecks clean. Still not rendered in a browser — needs pm2 restart officer
and a hard refresh. text-xs is still 65 uses against 21 text-sm; the remaining
pass is per-case judgement about which of those are meta and which are content
that should be readable, and it is not a sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DuckSuite_Design_Language.md covers icons only — mascot geometry, lighting,
one-idea-per-icon, readable at 64px. It says nothing about type, density or
data, which is why the chrome looks considered and every data view does not.
This is the companion document plus the primitives that enforce it.
The diagnosis it is written against, from grepping two apps: 11 uses of
text-[10px], 7 of text-[11px], 52 of text-xs and 3 of text-base, with no rule
about which meant what; six radius values; hand-picked emerald/amber/red/purple
next to unused --success/--warning/--destructive tokens; text-black/50 and
ring-black/5, which are invisible in dark mode. None of that was a bad decision,
it was the absence of one thirty times over — so the fix is to remove the choice
rather than to have better taste.
docs/design-language-interface.md sets four type ranks with a 12px floor, one
focal point per row, five state tones, three radii and a spacing rhythm. The
principles are lifted from the icon language rather than invented, because
"one focal point, reads instantly, no unnecessary decorations, if it needs
explanation it is too complicated" is already the right rule for a dense list.
components/Data/ is how you spend that vocabulary: DataRow/DataList/RowMeta,
StatusPill/StatusIcon, LoadingBlock/ErrorBlock/EmptyBlock, RelativeTime. Rules
you have to remember are rules thirty views already broke, so the shape encodes
them — DataRow takes exactly one title and everything else is meta, RowMeta
puts separators between items so a trailing dot cannot appear, RelativeTime
carries the absolute timestamp as a hover title.
Adds --info (violet) as the fifth semantic tone, light and dark. "Merged" and
"in progress" are neither good news nor bad, and painting them with --success
makes a merged PR and an open one look like the same thing.
Entirely additive: nothing imports these yet, so no existing view changes and
there is no collision with the other agent working in this tree. Typechecks
clean. Not yet rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit's explanation of retargetUrls was inverted, in the message and in the comment.
It said /user and /repos build from ROOT_URL and /contents from the public host. It is the other
way round: this instance's app.ini has ROOT_URL = https://gitea.pastilhas.dev/, which is what
/contents stamps, while /user and /repos echo the REQUEST host — loopback, because Officer dials
http://localhost:9004 directly instead of going through the reverse proxy.
Behaviour is unchanged and was already right: rewrite only a private/loopback URL, only onto a
public target. Which half of the response is wrong does not affect that rule, which is why the bug
did not show up in testing. But a comment that names the wrong cause is how the next change
re-breaks it, so this corrects the record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.
Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.
Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.
retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.
Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.
Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the config cache is keyed by user, but a member's entry holds the owner's
base — resolved at read time, since a member's row stores no url. dropping
only the caller's entry was correct while each row was self-contained; once
rows inherit, the owner moving or disconnecting the instance left every
member cached against the previous host until the sidecar restarted.
also returns the resolved instanceUrl from connectionState. members not
being able to set it is the invariant; not seeing it never was and could
not be — every avatar_url the instance hands back is on that origin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gitea is the first service where the server is shared but the account is not.
The owner connects the instance; every other user supplies only their own access
token and sees their own repositories, notifications and issues.
The plumbing was already per-user — createSidecarProxy injects the authenticated
caller's id as X-Officer-User, the sidecar refuses a request without it, and
service_connections is UNIQUE (user_id, service). What was wrong is that url and
token lived in the same row and a save demanded both, so a member would have had
to type the instance URL. That is worse than inconvenient: a member who can name
the URL has a per-user SSRF hop behind a settings form, and the sidecar would
dutifully attach their token to it.
`url` is now nullable, and NULL means "inherit the instance". The owner's row
carries the URL and IS the instance; everyone else's row is a credential. A
member's URL is therefore not stored rather than merely hidden — which is what
makes "members never see the instance URL" a property of the schema instead of a
filter somebody has to remember on every response.
Resolution lives in one place (getResolvedServiceCredentials / getServiceInstanceUrl)
rather than in each sidecar, so there is a single answer to "where is this
service" and no sidecar can accidentally trust a member-supplied URL.
Rules, all enforced in the sidecar rather than the UI, because a form that hides
a field is a suggestion and these are rules:
owner PUT /_config { url, token }, as before
member PUT /_config { token } only; a url in the body is REJECTED, not
ignored — ignoring it would leave someone debugging a
screen quietly talking to a different server
member, no instance 409, "the server owner has not connected a Gitea
instance yet"
member GET /_config has no url to return
owner disconnects members keep their tokens and resolve to nothing; no
instance, no service
GET /_config also now answers `instanceConfigured` and `isOwner`, which is what
lets the UI tell "you have not connected yet" from "there is nothing here to
connect to" — different screens.
memos, slskd and transmission front a single daemon and always store their own
URL; they now treat a null as a malformed row rather than reaching for somebody
else's instance.
Verified on a scratch database: pushing twice adds no diff churn beyond the known
pk_music_now_playing pair, and the resolution behaves — member GET returns a null
url, member credentials resolve to the owner's base with the member's own token,
and deleting the owner's row leaves the member's token intact but resolving to
nothing. All four live rows have a url today, so the column change applies
without touching data.
Not reachable by a real member yet: the account backstop still confines
non-owners to /api/auth + /api/music. This works the moment the capability model
lands, and until then is testable only by minting a token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.
/api/file-browser/video-info POST {url} -> GET ?url=
/api/file-browser/video-playlist POST {url} -> GET ?url=
/api/server-settings/ocr/models POST {url} -> GET ?url=
/api/transmission/_officer/port-test POST -> GET
/api/jellyfin/_config/:id/test POST|GET -> GET only
The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.
Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).
So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.
Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.
docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
`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>
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>
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>
mints a dav app password, renders a configuration profile carrying both the
caldav and carddav payloads, and parks it behind a single-use five-minute token
that safari can fetch without a session.
one profile with both payloads is not a convenience: ios keys accounts by
server+username, so adding carddav separately gets folded into the existing
caldav account and contacts silently never appear.
the profile holds the password in plaintext, so it is held in memory only —
persisting it would falsify createDavAppPassword's "not stored" guarantee.
signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default;
this box has no tls certificate, tls terminates upstream. signed at mint time
reading the cert from disk, so a renewal needs no restart and no hook.
the download route is registered before the /dav mount because hono matches in
registration order and the sync door's /* would otherwise demand http basic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three non-owner accounts had no directory of their own — only the owner did,
grown organically as features created what they needed. Adds an idempotent script
that creates the skeleton, and runs it for those three.
DATA_PATH/<email>/{home,attachments,cache,dashboards,email_accounts,
general_chat_sessions,logs,sidecar}
Most of those are also created on demand by whichever feature owns them, so
pre-creating buys legibility rather than function: the tree now shows the shape a
user has without having to use it first. `home` is the exception and the reason
this exists — nothing creates it today, because getOwnerHomeDir returns
process.env.HOME_DIR whenever it is set, which it always is on a real install. It
is where a non-owner's sessions will run.
Takes emails as arguments rather than reading the user table. The layout does not
depend on the database, keeping the DB out means it runs with nothing else up,
and at invite time the caller already knows the email. It refuses arguments that
are not email addresses, since a stray one would create a junk directory sitting
next to real user roots and looking like one.
Deliberately NOT a revival of the provision.ts deleted two commits ago. That one
was ~90% per-container scaffolding — shell rc files, a generated CLAUDE.md and
settings.json describing the container — wrapped around the two useful lines this
keeps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first iteration gave every user their own Docker container: the user's whole
world lived inside it, and only the super admin could see the real filesystem.
That model is gone, but its scaffolding was still in the tree, and it had already
cost time today — the /usr/local/bin/claude symlink removed a few commits ago
existed only because the bwrap jail ro-bound /usr and could not see the
installer's target.
Deleted:
generate-container-context.ts built the CLAUDE.md and settings.json that told
an agent what its container looked like. Its
only importer was the provisioning removed in
the previous commit, so it had zero consumers.
getUserPiConfigDir pointed into the managed container home. No
consumers anywhere in the tree.
Renamed:
DATA_PATH/<email>/.container-context -> agent-config. It holds one file, the
MCP server config handed to the CLI, and has nothing to do with containers. The
path is written and consumed through a return value, so nothing else reads it;
an old directory left on disk is inert.
Documented rather than removed, because both still have live callers and pulling
them out is a refactor rather than a cleanup:
getHomeDir the container's home. Nothing executes there now — terminals,
chats and task runs all use getOwnerHomeDir — but it survives
as that function's fallback and in pipeline-executor.
toShellUsername named for deriving a Linux username inside the container,
32-char limit and all. Nothing creates a Linux user now; the
value ends up only as a claim in the signed task token, so it
is a sanitiser wearing an old name. Unpicking it means
changing that token and WSData.
Nothing to clean on disk: DATA_PATH/<email> has no home/ tree and no
.container-context/. The docs that still mention any of this are the two marked
"Historical" at the top, which are records of what was true then and should keep
saying so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
provisionUserEnvironment seeded a managed home under DATA_PATH/<email> — shell
configs from a template dir, plus a generated CLAUDE.md and settings.json — and
bootstrap called it fire-and-forget when the owner account was created. It is
outdated: the managed home is not where anything runs. Task runs, terminals and
chats all execute in getOwnerHomeDir (the real login home, HOME_DIR), not the
getHomeDir tree this was populating.
Removed the function, its four template files, and the call in bootstrap. No
setup script referenced it — checked all of scripts/*.sh — and bootstrap was its
only caller anywhere in the tree.
getHomeDir and toShellUsername stay in data-path.ts: pipeline-executor and
pipeline-job-manager still use them.
This leaves src/servers/generate-container-context.ts with zero consumers, since
provision was the only thing importing it. Left in place rather than deleted in
the same commit — it is 173 lines that build a CLAUDE.md and a settings.json for
an agent, which is plausibly wanted somewhere else, and that is a call for the
owner rather than a side effect of this cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A light install is reached at localhost on the machine running it, so PUBLIC_URL
has exactly one right answer. setup.sh required it with a re-prompt loop; under
the light profile it now defaults to http://localhost:$PORT. The macOS installer
already did this — this is parity, and it removes the one prompt in a light run
whose answer a non-technical user could not be expected to produce.
setup_mac_light.sh also wrote PUBLIC_BUILD_ENV="development", justified in a
comment as "what makes plain http://localhost work". That is no longer true, and
the cost of it is not small. IS_DEV_BUILD gates exactly three things:
origin validation already off regardless — ALLOW_ANY_ORIGIN defaults to true
password rules validatePassword is skipped entirely on change-password
rate limiting the limiter returns next() before doing anything
So the only live effects were losing the last two, for a benefit that another
default already provided. It now writes "production", matching setup.sh. Nothing
about localhost needed relaxing: browsers treat http://localhost as a secure
context, so passkeys, getUserMedia and the clipboard all work over plain HTTP,
and passkeys in particular derive their RP ID from the request origin rather
than a configured domain.
That last point is the boundary worth knowing: http://192.168.x.x is NOT a
secure context, so reaching a light install from another device means putting
an HTTPS proxy in front of it. Recorded in the comments at both prompts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renamed for parity now that Linux has a light profile too:
scripts/setup_mac.sh -> scripts/setup_mac_light.sh
ecosystem.mac.config.cjs -> ecosystem.mac.light.config.cjs
The macOS process list was still a hand-copied subset, which is the shape that
broke it: written 2026-07-28, within days it was running the Anthropic proxy
under the name officer-claude with nothing spawning `claude`, and pointing at a
pty entry point that had moved. Both silent. It now declares names and reasons
and reads script/args from ecosystem.config.cjs, so a launch change on the host
reaches it for free.
The include/exclude checks moved into ecosystem.profile.cjs rather than being
copied into the second profile — duplicating the guard rails would have repeated
the mistake they exist to catch. Both profiles were re-tested against a mutated
host ecosystem: renaming an included app and adding an unclassified sidecar each
throw in both, and an unmodified host loads five apps in both.
Kept as two files rather than collapsed into one, even though they currently
produce identical output. The exclusions do not mean the same thing: on macOS
officer-vnc CANNOT run, there being no Xorg; on a Linux light install it could
run fine and you have chosen not to. Merging them would lose that, and they
diverge the moment one profile gains something the other cannot have.
setup_mac_light.sh's verification loop now reads app names with node instead of
grepping for `name:` — the derived profile has no literal keys, so the grep
would have silently listed no services at all, which reads the same as a healthy
install with nothing configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OFFICER_PROFILE=light installs the same thing the macOS build does, on Linux:
the file browser, the terminal, and Claude/opencode chat. It skips the archive
extras, the sudoers entry and auto-suspend disabling, Go, Rust, PulseAudio,
cliamp, Neovim, the shell tooling and yt-dlp, brings up Postgres alone of the
five Docker services, and starts ecosystem.light.config.cjs. Unset or `full`
behaves exactly as before.
The profile changes which processes start, not which code ships — every API
route stays mounted, so features whose sidecars are absent report themselves
unavailable rather than disappearing.
ecosystem.light.config.cjs DERIVES its apps from ecosystem.config.cjs rather
than copying them, because the hand-copied Mac list was broken within days of
being written by a sidecar split in two and a pty entry point that moved, and
both failures were silent. Here a script/args change on the host propagates for
free, and two consistency checks turn the silent cases loud:
- a name the profile needs that the host no longer defines throws at load
- an app added to the host that is in neither the include list nor the annotated
exclusion list throws, so a new sidecar cannot default to "not in the profile"
without someone deciding
Both were tested against a mutated copy of the host ecosystem: renaming
officer-agent and adding an unclassified sidecar each throw, and the unmodified
file loads five apps.
The verification block now reads app names with node instead of grepping for
`name:` — the derived file has no literal keys to match, so a grep would have
silently verified nothing — and skips the checks for tools the profile did not
install, so a clean light run does not report Go and cliamp as missing.
Full-profile behaviour is unchanged by construction: every guard wraps the
original code in an else branch. The preamble was tested across unset, full,
light and an invalid value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ecosystem.mac.config.cjs was written on 2026-07-28 and broken within days by two
changes it never caught up with, leaving two of its four processes dead:
- it ran `officer-claude` against src/servers/sidecar/claude/index.ts. That file
is the Anthropic credential proxy; the process that actually spawns `claude`
is user-instance.ts, which was never started. Chat had a credential holder and
nothing driving it. Split into officer-anthropic-proxy + officer-agent to
match the host, and retired the `officer-claude` name that caused it.
- officer-pty pointed at src/servers/api/terminal/pty-sidecar.mjs, which no
longer exists; the sidecar moved to src/servers/sidecar/pty/index.mjs. The
terminal could not start at all.
No startup ordering is needed between the proxy and the agent: the agent reads
the proxy secret from disk and, when it is not there yet, warns and re-reads
before the next spawn.
Five sidecars have been added since the file was written (memos, caldav, photos,
notify, wallet). All are excluded, and every exclusion is now listed with its
reason so the next reader can tell "not applicable on macOS" from "forgotten" —
which is the failure this commit is fixing.
setup_mac.sh needed no path corrections; its verification loop derives the
process names from the ecosystem file, so it picks the new list up on its own.
Two gaps closed there:
- it called `pm2 save` without `pm2 startup`, writing a process list that
nothing reads — a reboot left the machine with nothing running. Added the
launchd equivalent behind SETUP_BOOT, optional because a laptop is not a
server, and non-fatal because pm2's launchd integration can want a prompt.
- the closing "not installed on macOS" note listed four omissions when there are
now fourteen.
Both files parse and every ecosystem entry point resolves to a file that exists.
The launchd path is the one thing not verified here — it cannot be exercised
from the Linux host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The symlink existed because the Claude sidecar hardcoded that path, and that
hardcoding came from the bwrap-sandboxed architecture: the jail ro-bound /usr
and could not see the installer's real target in ~/.local/bin. The sandbox is
gone, and claude-manager.ts now resolves the CLI itself — $CLAUDE_BIN, then
PATH, then ~/.local/bin/claude, /usr/local/bin/claude, /opt/homebrew/bin/claude.
Verified before removing rather than assumed:
- the only references left in the tree are the resolver's own fallback list and
this step; nothing in capabilities, no systemd unit, no crontab, no ecosystem
file and no shell rc mentions the path
- the agent sidecar's PATH under pm2 contains ~/.local/bin ahead of
/usr/local/bin, so Bun.which resolves to the installer's target and the
symlink is never consulted
- replaying the resolver in that exact environment with the symlink treated as
absent returns the same path, so it is not load-bearing
- resolveClaudeBin runs at claude-manager module scope, which ES import ordering
puts before user-instance.ts reassigns process.env.HOME — so the homedir()
candidate is evaluated against the real home, not the managed one
The install-and-verify step above is untouched, so a failed claude-code install
is still reported. Only the sudo-owned link into /usr/local/bin goes, a
directory macOS does not ship at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three files, all additive — nothing on master is modified by this beyond the
CLAUDE_BIN change below, and no file is deleted. The branch predates master by
about 180 commits, but it touches nothing master has touched since, so the
merge is clean.
The part that matters beyond macOS is claude-manager.ts. CLAUDE_BIN was pinned
to /usr/local/bin/claude, which dated from the bwrap-sandboxed architecture:
the jail ro-bound /usr and saw nothing else, so the installer's real target
(~/.local/bin/claude) had to be symlinked somewhere the sandbox could reach.
That sandbox is gone, and the hardcoded path left the sidecar unrunnable on any
host without it. It now resolves an explicit CLAUDE_BIN pin, then PATH, then the
locations Anthropic's installer actually writes to — mirroring how OPENCODE_BIN
is already resolved in the opencode sidecar.
ecosystem.mac.config.cjs is deliberately a trimmed set of processes rather than
a mac port of the full ecosystem. It is also stale in two specific ways, left
as-is here and worth fixing separately: it names officer-claude, which master
renamed to officer-anthropic-proxy, and its officer-pty runs
src/servers/api/terminal/pty-sidecar.mjs, which moved to
src/servers/sidecar/pty/index.mjs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One-tap calendar/contacts setup for the iOS and Android apps: the server
contract that already exists, and specs for the two platform mechanisms
that don't yet — a signed .mobileconfig for iOS and a DAVx5 intent for
Android.
Payload keys, intent identifiers and install flows are researched against
Apple's device-management reference and davx5-ose source rather than
recalled; the doc marks what is verified against a running deployment and
what is still specification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup wrote /etc/sudoers.d/officer-service with tee and then chmod'd it.
Two problems, both with the same worst case: a malformed or wrongly-permissioned
file there breaks sudo completely, and you cannot sudo to repair it — on a
remote machine that means physical access or a rescue boot.
Generate into a temp file, gate on `visudo -c`, and only then install. Use
install(1) rather than tee+chmod so the content and the 0440 mode land in one
step; tee creates at the default umask first, and sudo refuses to read a sudoers
file with loose permissions, so the old ordering had a window where sudo could
reject its own configuration.
The re-run guard also grepped for the username anywhere in the file, so a
comment mentioning it counted as configured. Match the actual rule instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guards that checked something other than the state they were protecting.
Section 17 skipped the entire remote desktop setup when `dpkg -s ubuntu-desktop`
succeeded, treating one package being present as proof that seven steps of
configuration had run. A host can have ubuntu-desktop and still be missing GDM
auto-login, the forced Xorg session, the captured EDID and its kernel command
line, and the login-time mode setter — which is exactly what this machine was
on 2026-08-02, while the guard cheerfully reported "skip". setup-desktop.sh is
idempotent throughout, so the guard bought nothing and cost a converged host.
The starship step had the opposite bug: it cp'd over ~/.config/starship.toml on
every run, so a customised config was silently destroyed. The nvim step two
sections down already guards on its config's existence; this now matches, and
distinguishes "absent" (deploy) from "identical" (skip) from "yours differs"
(keep, and say how to take ours).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a DAV client tells you almost nothing when it fails — iOS reports every setup
failure as "Cannot connect using SSL" regardless of cause — so the only way to
know whether a phone ever asked for an address book is to record that it did.
one line per proxied request with the client's own user-agent, and a warning
when a credential is present but wrong. a missing credential is the normal
opening move and stays unlogged; a wrong one is indistinguishable from it at
the client end, where both just say "password incorrect".
no body is ever logged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
two bugs, both of which iOS reports as "Cannot connect using SSL" — a message
about TLS for a problem that has nothing to do with TLS. the certificate was
never involved.
the well-known routes were registered with .get. RFC 6764 §6 has the client
probe the well-known URI with the method it actually intends to use, and iOS
sends PROPFIND — which fell through to the SPA catch-all and 404'd. they
answered correctly in a browser, which is why they looked healthy.
hono's cors() answers every OPTIONS itself as a preflight and never calls the
route beneath it, so OPTIONS /dav/ returned a bare 204 with no DAV header.
OPTIONS is not a preflight to a DAV client — it is how the client asks what the
server can do, and iOS refuses an account whose server does not advertise
calendar-access. cors now skips the DAV paths entirely; a CalDAV client is not
a browser and has no origin to check.
verified from the public internet: PROPFIND on both well-known paths 301s to
/dav/, and an authenticated OPTIONS now returns
`DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cleanup-desktop.sh still described the era when Officer installed a desktop of
its own — XFCE on a TigerVNC Xvnc — so tearing the remote desktop down meant
deleting a desktop nobody else used. That stopped being true when the setup
moved to mirroring the machine's existing session with x11vnc, and the script
was left actively dangerous: it purged dbus-x11 and Brave, deleted ~/.vnc, and
reinstalled gnome-keyring, all of which the current GNOME setup depends on or
deliberately removes. Running it today broke the desktop rather than cleaning
it up.
Rewritten as the actual inverse of setup-desktop.sh:
- removes what Officer added — x11vnc, ~/.vnc, the GDM auto-login and forced
Xorg keys, the forced EDID and its kernel command line, the login-time mode
setter, the legacy officer-vnc unit, the VNC entries in .env
- keeps ubuntu-desktop, gdm3 and dbus-x11, which are the machine's own desktop
and not Officer's to delete
- still purges XFCE and TigerVNC when present, since a host set up by the older
script carries them and they are precisely what this undoes
- Brave is opt-in behind --purge-brave: setup installs it, but by teardown time
it is usually just the user's browser with their profile in it
The GRUB and GDM edits were checked against copies of the real files: stripping
the EDID parameters leaves other kernel arguments intact wherever they sit in
the line, and the GDM revert does not disturb the commented examples that ship
in custom.conf. The package matcher names each xfce-family prefix rather than
globbing '^libxf', which would have taken libxfixes, libxft and libxfont with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
two workspace screens backed by one app folder: the collection list on the
left, the selected calendar's agenda or address book on the right. both read
the officer-caldav sidecar through the /api/caldav auth proxy, which holds no
DAV credentials of its own.
the selected collection is a DAV path with slashes in it, so it lives in
?collection= on the same route rather than as a path segment — still in the
URL, still bookmarkable, no selection channel. an absent or unknown value
resolves to the first collection inside the panels instead of redirecting,
because until the list has loaded there is no canonical URL to redirect to.
the agenda is a grouped list, not a month grid: the sidecar returns RRULE
unexpanded, so a grid would have to invent occurrences the server never
claimed existed. a repeating event gets a badge instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nightly full rebuilds every album by definition, so "it completed" says
nothing about whether it is still needed. Diff the from-scratch build against
the index that was already live, just before the slot swaps in, and log the
delta: albums added, removed, and entries whose contents changed.
A run reporting NONE did no useful work. A string of those is the evidence for
retiring the nightly; a delta that keeps coming back names the albums to go and
look at instead of guessing.
Entries are compared field by field rather than by JSON.stringify: the optional
fields are spread conditionally, so two entries built by the same code can
serialise with different key order, and stringify would report every album as
changed every night. A cache-format upgrade is labelled, since that rebuilds
everything legitimately and would otherwise read as total rot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>