7e31564c15f0ea91072a221bf54f73232f3b1e55
515
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7e31564c15 |
?view= survives a page load — it never has
Opening or refreshing a URL with `?view=` gave you a bare file browser and a URL
that had quietly rewritten itself to drop the param. Reported as "has never
worked", and it never had.
`useFileViewerPanels` deleted EVERY ephemeral key in a mount effect, filed under
"clear stale ephemeral params". So the page came up, the effect stripped `view`,
and the pane never opened. It arrived incidentally in
|
||
|
|
98ba604aaf |
?view= is a name, not a second copy of the path
before /files/archive/Tests/platform/docs?view=%2Farchive%2FTests%2Fplatform%2Fdocs%2Fagent-coordination.md after /files/archive/Tests/platform/docs?view=agent-coordination.md The pathname already says which folder you are in. Repeating it in the query said the same thing twice, and the encoded copy was the unreadable half. ── Absolute is still read, just never written ── `resolveInFolder` takes either form. That is not politeness, it covers three real cases: links made before this change, `?ephemeral=` pointing at a generated file that may not live beside the original, and anything genuinely outside the folder being browsed. `relativeToFolder` only shortens a DIRECT child. Something nested deeper stays absolute rather than becoming `sub/b.md`, because a relative value containing a slash reads like a path when it is meant to be a name, and there is nothing to gain from the ambiguity. A near-miss is handled too — /docs2 is not under /docs. ── The one that would have broken ── The widget's Recent and Pinned lists opened `/files?view=<absolute>` and let the screen work it out. With the folder in the pathname that now means "open this file while sitting in home", which is not where the file is. It navigates to the file's folder and names it from there. All three providers resolve, not just the viewer: `ephemeral` and `ephemeral2` are paths too, and leaving them literal would have made the short form apply to whichever panel happened to be first. 6 more tests on the round trip, including the near-miss and the nested case. tsgo clean, frontend builds, 837 pass / 7 fail — +6 new, same 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
559560de48 |
the folder is the URL: /files/Tests/test folder
`?path=/Tests/test_folder` becomes `/files/Tests/test%20folder`. Directories only —
an open file stays `?view=`, because it is what is on top of you rather than where
you are, and that split is what keeps closing a pane from moving you.
── The landmine, found before building on it ──
`server.tsx` 404s any path ending .js/.css/.map/.png/.svg/.ico/.webmanifest/.woff2
that is not in `build/`, so it never reaches the SPA. A folder named `logo.png` is
unusual but legal, and it would have deep-linked to a 404 that reads as a broken
link rather than a rule.
Narrowed to SINGLE-SEGMENT paths, which is safe on evidence rather than by
loosening a rule: the bundler emits exactly three files, all at the root of
`build/`, so a real asset never has a second slash; and multi-segment assets
(`public/**`, `/plugins/<app>/icon.png`) are served by Bun's `routes` table before
this handler runs.
── Encoding is the part that breaks quietly ──
Per SEGMENT, never the whole path — encodeURIComponent would eat the separators.
14 tests over the round trip, because a filename may carry anything but `/` and
NUL. The ones that matter: `#` truncates a URL at the fragment if unescaped, a
literal `%` is mistaken for an escape, and malformed input must not throw
(decodeURIComponent('%zz') raises URIError, and that would take the screen down).
Parentheses stay readable — encodeURIComponent leaves them alone — so
`Kanban (copy 2)` survives as `Kanban%20(copy%202)`.
── The rest ──
- `/files` → `/files/*`. The bare route still matches with an empty splat, so every
existing link, the dock entry and DEFAULT_DOCK_PATHS keep working.
- Breadcrumbs take a `To` instead of a query string and are still real links.
- Old `?path=` URLs redirect once, in place, with `replace` so it is not a
back-button step. A bookmark from this morning still lands where it meant to.
- `setViewerParams` stopped copying `path` across, because there is nothing to
copy — the folder is the pathname and is untouched by a search-param write.
Checked and needing nothing: the page-title rule is `startsWith('/files')`, and the
dock uses react-router NavLink without `end`, so `/files/Tests` keeps the tile lit.
tsgo clean, frontend builds, 831 pass / 7 fail — +14 new, same 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4874c21dfd |
search results take you to a thing, they do not open it
Clicking a file result navigated to its folder AND opened a viewer pane in one gesture. Search is how you find where something is; opening it is a separate decision — so a search for a name you were merely locating left a pane you then had to close. file → its containing folder, with the row selected directory → inside it, which is what opening a folder means The file is selected rather than merely listed, so the thing you searched for is picked out of whatever else is in that folder. That makes clicking a FILE identical to Show location — one behaviour reached two ways — so the menu stops offering it twice under two names. "Open folder" now appears only on a directory result, which is the only kind that has somewhere to go into; a file gets Show location alone. `ExternalLink` went with it, since nothing was left importing it for. `handleShowLocation` is unchanged and still reveals a DIRECTORY's parent, so the two menu items on a folder are genuinely different: Open folder goes in, Show location goes to where it sits. tsgo clean, frontend builds, 817 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a84eedd126 |
zip from the folder, extract on double-click, show location
Three things from testing. ── The download zip carried the path to itself ── Selecting files in ~/Tests/test_folder and downloading produced an archive whose contents were `Tests/test_folder/…`. `POST /download` spawned zip with `cwd: rootDir` and root-relative paths, so the whole chain from home went in. Nobody selecting three files is asking for the route to that folder to be part of what they downloaded. Now it zips from the items' own folder using bare names. A MIXED selection keeps the old form, because two `notes.txt` from different folders would collide without the paths keeping them apart — the UI only selects within one folder, so that branch is a safety net rather than the case. `/compress` already did it this way; this is the same fix on the endpoint that predates it. Verified against real zip: `Tests/a.txt` → `a.txt`, `Tests/test_folder/b.txt` → `test_folder/b.txt`. ── Double-clicking an archive extracts it ── It opened a pane that rendered "Archive file" and the name you could already see in the listing. That is a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a second double-click makes a sibling rather than overwriting. The menu's Extract stays — a double-click is not a discoverable way to learn what will happen. ── Show location ── A search result answers "here is the file"; its path underneath raises "where does this live", and clicking only ever answered the first. This goes to the containing folder, clears the search and SELECTS the row, so a folder of forty files does not leave you hunting. A directory hit reveals its parent — where is this means the folder it sits in, not the folder it is. tsgo clean, frontend builds, 817 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5ef83ef0d0 |
say WHY a file could not be opened, and offer the thing that still works
Opening a large text file showed "Failed to read file" and nothing else. The
server had already said exactly what was wrong — "File too large to read
(max 5 MB)" — and the viewer threw it away: `.catch(() => setError('Failed to
read file'))` ignored its argument.
`hono.ts:297` renders a CustomError as `ctx.text(error.message)`, so that sentence
arrives verbatim as `ApiError.message`. It is shown now.
Guarded rather than trusted, because error paths are where surprises live. A body
is only displayed when it looks like prose: an HTML page from a proxy, a JSON
envelope from a route that sets `returnValue`, an empty string or something
300 characters long all fall back to the generic line. Checked all seven cases.
The error state also stopped being a dead end. Being told a file is too big, with
nothing to do about it, leaves the reader stuck — but the bytes are still there
and only the UTF-8 READ is capped; `/raw` has no limit. So it offers "Download
instead", which is the action that still works.
This is the same class as the empty-file bug found earlier today: the viewer knew
the answer and did not pass it on.
tsgo clean, frontend builds, 817 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
31d8d72527 |
closing a file no longer sends you home
Open ~/Tests, double-click a file, edit it, save, close the pane — and the browser
was back at ~. Reported from a real session, not found by reading.
`onCloseViewer` was `setSearchParams({})`, which wipes the WHOLE query string. The
folder lives in `?path=`, so closing the viewer deleted where you were along with
what was on top of it. It also discarded anything `useGlobalQueryString` had put
in the URL.
The rule was already written down one file away, in
`useFileBrowserApp.setViewerParams`: "`path` is not one of them — it is where you
are, not what is on top of it — so it survives." Only the OPEN path honoured it.
Close now deletes the ephemeral keys by name, which is exactly what
`onCloseEphemeral`, `onCloseEphemeral2` and `onCloseChat` directly below it have
always done — it was the single outlier among four handlers.
Checked the other exits before calling it fixed: there is one `onClose` wired to
the viewer, every other handler already used the targeted form, and the
mount-cleanup effect deletes the same keys off `prev` rather than replacing the
string. This was the only wholesale wipe in the repo.
tsgo clean, frontend builds, 817 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c945f50e60 |
creating a file just creates it — and an empty file is editable
Two halves of one behaviour. ── New file no longer navigates ── It created the file and then took you to /code-editor, which made it the only action in the browser that leaves the browser. Creating a folder does not leave the folder; uploading does not open what you uploaded. Now the file appears in the listing and that is the whole operation. Editing is the same double-click that opens anything else — the viewer's pane already has an editor in it, with a save. One way to open a thing, one place it appears. ── The bug that would have broken that on the first try ── FileViewerHeader gated its Edit toggle on `editable && content`. `content` is `string | null` and an empty file is the EMPTY STRING, which is falsy — so a file with nothing in it rendered no pencil and could not be edited. That is precisely the file you most want to edit, and it is exactly what "New file" produces: the flow above would have dead-ended on its first use. Now `content !== null`, which is what FileViewerBody already used two files away. Checked the other two truthiness gates rather than changing them blindly, and both are right: `isJson` returns false for empty (an empty string is not JSON, and JSON.parse would throw), and Read Aloud stays hidden because speaking an empty file is not a thing. `Open in editor` still goes to /code-editor and is untouched — that route is a different tool, with a tree and tabs, and today's links are the first way to reach it at all. Worth deciding separately. tsgo clean, frontend builds, 817 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d7b602ff34 |
a real dialog for New file, New folder and Clone
`New file` shipped this morning behind `window.prompt()`, and prompt is the wrong
tool for a reason that is not cosmetic: it cannot validate. It takes whatever you
type, so the first thing you learned about a bad name was a red toast after the
round trip — and for New file specifically, the server refuses to clobber, so the
way you discovered a name was taken was by having the creation rejected.
One dialog now serves all three. It checks on every keystroke, against the names
already on screen:
- empty is not an error, just nothing to submit — no red "required" before you
have typed anything
- `.` and `..` refused; a LEADING dot is fine, because a dotfile is a real
thing to want
- `/` refused rather than quietly doing mkdir -p, which is a surprise from a box
labelled "Folder name" and is why the rename endpoint refuses slashes too
- a collision is caught before the request, and case-SENSITIVELY: this is Linux,
so Notes.txt and notes.txt genuinely coexist and refusing one would refuse a
legal name
The server keeps its own refusal. This is the courtesy; that is the lock.
Clone joined them because it was the last prompt() I had added, and the URL check
is deliberately loose: git takes ssh, git://, a local path and a host alias from
~/.ssh/config, so validating for https would break working setups to prevent a
mistake git already reports well. Only whitespace is refused.
The open state moved into `useFileBrowserApp` because two surfaces open the same
dialog — the background menu and the toolbar — and each having its own would be
two dialogs that could both be open. One mount, five call sites.
The toolbar's New folder was on prompt() before today and is now on the dialog
too, so there is no prompt() left anywhere in the file browser.
16 tests over the two validators. tsgo clean, frontend builds, 817 pass / 7 fail —
the 7 unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
84d6d7e7ce |
file browser: the eight defects from the audit
1. Kebab Cut/Copy silently did nothing, and sometimes cut the wrong rows. `EllipsisMenu` stops click propagation so opening it does not toggle selection — but Cut, Copy, Download, Delete and Compress all act on the SELECTION, which meant the kebab operated on whatever was selected elsewhere, or returned early with no toast at all. Right-click never had this because it selects first. Both now call one `ensureSelected`, on open. 2. Every menu vanished during a search. That branch had no ContextMenu and its rows are not FileItems, so right-click fell through to the browser. The folder menu is now declared once and mounted in both branches — it acts on `currentPath`, which a search does not change, so it stays meaningful. Search ROWS get their own three-item menu rather than the row menu: a hit carries an absolute path from anywhere in the tree, while every row-menu handler builds its path from the folder being browsed. Reusing it would have acted on a different file with the same name. `DirEntry.path` is optional — only search results carry one — so it is guarded, not asserted. 3. Download and Copy path now fan out over a multi-selection, the rule Delete and Compress already used. The menu downloaded one of six selected files while the toolbar zipped all six. Chat is deliberately NOT included: `chatContext` is a single path that the provider string-splits into a working directory, so a list would need Chat's contract changed. Left alone rather than half-done. 4. Read Aloud stopped offering TTS on binaries — same guard the editor got, since `text` is `getFileType`'s fallback and caught .exe and .sqlite. 5. `POST /file-browser/download` is filed as a read. It zips a selection and writes nothing; it is a POST only because a list of paths does not fit a query string. A read-level member could download a whole folder but not two files. Proved by hand that `readOnlyWrites: ['/download']` permits GET and POST /download, and still denies /download-video, /rm and /write — `isPrefixOf` is segment-aware, so the sibling route does not leak. 6. `disabled` had no use anywhere in the feature — the shared item type did not even expose it. It does now, and the first real case: `/file-browser/read` refuses over 5 MB, so Open in editor on a 40 MB log opened an editor that then failed. Hiding it would have suggested the file is not text; disabled says the true thing. 7. The grid kebab could fade out while its own dropdown was open — list view had `data-[state=open]:opacity-100` and grid did not. 8. Dead exports `getMatchingTasks` / `getMatchingAgents` removed. Both were the ungrouped input to the grouped form the menu actually renders; exporting them invited a second code path that never arrived. 9 is untouched on purpose — a plugin cannot register a menu item, and the music plugin is where that should be designed. tsgo clean, frontend builds, 808 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e2b0d5c06b |
edit any text file, and make new ones from the empty panel
Two things, both asked for. ── Open in editor now covers text ── It was markdown and code only, because `text` is `getFileType`'s FALLBACK and so also catches binaries. That was the wrong trade: media, archives and pdf are already claimed by earlier branches, so what actually lands in the fallback is .txt, .log, .csv, .conf, dotfiles, Makefile-likes and anything with an extension the platform has never heard of. Refusing all of that to avoid one bad case cost far more than it saved. The bad case is named instead of guessed at — a ~40-entry list of extensions that reach the fallback but are not text (.exe .so .sqlite .docx .woff …). A denylist that is too short costs one bad render; a `text` test that is too strict costs the feature. Not a security control: `/file-browser/read` is UTF-8 and capped at 5 MB, so the worst outcome is mojibake. Checked against 18 names: notes.txt, server.log, data.csv, nginx.conf, .env, Makefile, script.sh, and an extensionless file all offer it; jpg, mp3, zip, pdf, exe, so, sqlite and docx all do not. ── New file, from the empty panel ── Right-click → New file takes a name WHOLE, extension included, creates it empty and opens it in the editor. The extension is what the editor uses to pick a language, so guessing one would be wrong more often than not; no extension is fine and opens as plain text. It refuses to clobber. `/write` is an overwrite, so creating over an existing name would silently empty it — the one outcome nobody wants from a menu item called "New file". There is no stat endpoint, so the check is a read that is expected to fail. `prompt()` to match New folder directly above it. Both deserve a real dialog and neither has one; making this one different would just be inconsistent. ── Verified rather than assumed ── `getActiveFile()` only matches ALREADY-OPEN files, so a path arriving in the URL could have landed on an empty editor — the exact silent failure this whole audit keeps turning up. It does not: CodeEditor.tsx:60-76 fetches and opens an unopened `?file=`, with an `unreadable` set guarding the retry loop. Confirmed /code-editor renders with `urlState`, without which the param is ignored entirely. The param is imported as `EDITOR_FILE_PARAM` rather than written as 'file' twice, so renaming it cannot leave this behind. tsgo clean, frontend builds, 808 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cc889a864c |
file browser: close the accidental gaps in the context menu
The audit split the menu's omissions in two. Three were deliberate — OCR, Transcribe and Extract Audio were removed in July because the task system does them better, and those stay gone. The rest were nobody's decision. This is the rest. ── One menu body instead of two ── `DropdownMenuItems` and `ContextMenuItems` were character-identical apart from the component prefix: 159 lines duplicated, including the same comment twice. Radix gives both families the same props, so the components are now a parameter and there is one list. That is why this diff removes more than it adds while adding seven items — and why the next item only has to be written once. ── Added to the row menu ── - Open — double-click was the only way. Every file manager puts it first. - Open in editor — `/code-editor` is on the SAME `files` permission as `/files`, so anyone who can browse could already edit; there was just no way to get there from a file. Narrower than Read Aloud on purpose: that one uses the `text` FALLBACK type, which matches .exe and .bin. - Paste into folder — a move was only expressible as cut → navigate → paste. Right-clicking the destination is the obvious gesture and did not exist. - Duplicate — one call away the whole time; `/copy` already resolves collisions to " (copy 2)". - Compress — the missing half of Extract. `/download` shelled out to zip but streamed the result away, so nothing could make an archive and keep it. New `POST /file-browser/compress` writes it beside the original. - Pin / Unpin — pinning existed only in the widget, which is not where you meet a file worth keeping to hand. ── Added to the background menu ── - Clone repository and Show/Hide hidden files. Both were toolbar-only, and the toolbar hides them under `md:` — so on a phone neither existed. All four desktop-only toolbar actions are now in this menu, which long-press reaches. - Paste is gated on the clipboard, as both toolbars already were. Ungated it fell through to the OS clipboard path and either toasted a secure-context error or silently did nothing — an item that looked available and was not. ── Removed ── `pcm-worklet-processor.js`, orphaned when cliamp left for the music plugin this morning. Verified unreferenced; the plugin ships its own copy inline. The `Play` hole itself is NOT closed and cannot be from here: there is no way for a plugin to register a context-menu item. Tasks and agents are the only extension mechanism and a task can only contribute a task entry. That is a platform gap and wants its own decision. Verified the zip invocation by hand for all three shapes — single file, directory (recursive, contents included), and a multi-item selection. Archive naming strips the extension only for a file, so a directory called `my.photos` stays `my.photos.zip` rather than becoming `my.zip`. tsgo clean, frontend builds, 808 pass / 7 fail unchanged. SelectionActions.tsx and usePipelineRunner.ts are prettier reflowing long lines — no semantic change, caught by formatting the folder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd26e4a688 |
example leaves too; plugins/ is documentation now
Done with `tea` as admin: plugins/offscale made public, plugins/example created public, both verified anonymously over https — which is the check that matters, because fetch.ts clones with no credentials and a private plugin simply cannot be installed from the marketplace. plugins/example 8 files, cloned back and diffed identical plugins/offscale now public (was private, so was uninstallable) `.gitignore` is one line now — `/plugins/*/`. The trailing slash is the whole design: it ignores DIRECTORIES, so files at the top of plugins/ stay tracked. The documentation about plugins belongs to the platform; the plugins do not. All three are in the marketplace catalogue, which is what makes this reversible: before this, untracking offscale meant a fresh install could never get it back. Now the store is the way in for all three. CatalogueEntry gains `icon` (a lucide name) alongside `iconUrl`. Both new entries ship glyphs rather than artwork, and without it they drew the generic puzzle piece in the store and their real icon after install — which reads as the icon changing rather than the store not knowing it. EXTRACTING-A-PLUGIN.md pointed at four directories a fresh checkout no longer has. It now gives repository URLs, says up front that this directory holds documentation rather than plugins, and carries a table of where each one went. Checked first that no tracked source statically imports a plugin directory — none does, so a fresh clone still builds. tsgo clean, frontend builds, 787 pass / 7 fail (unchanged). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29170dd922 |
installing a plugin pins its dock tile, with no reload
Installing Music left the dock unchanged. The tile existed server-side the moment the plugin was enabled, and the only way to see it was Profile → Dock, by hand, after a page refresh. Installing something is already the decision to use it; saying so twice is the bug. Three separate causes, which is why it looked like one stubborn one: 1. `dock_configs.paths` is a COMPLETE ordered list, not a diff — useDock renders the saved list rather than everything available, so anything not named in it is invisible. Nothing in the plugin system had ever written to that table. installPlugin now appends the new route, for the caller (threaded through the route) rather than a guessed owner id. Only for a user who HAS a row. A user without one is left alone deliberately: there is nothing to append to, and writing one would freeze today's defaults into their account permanently. That case is (2). 2. The fallback for a user who never customised was DEFAULT_DOCK_PATHS, which is core-only — so a plugin tile was hidden there too. `defaultDockPaths(plugins)` is now that list plus a tile per installed plugin, and the dock and its settings screen both call it so they cannot disagree about what "default" means. 3. useDock holds ['DOCK'] at staleTime: Infinity and the plugin verbs invalidated only ['plugins'] and ['self-permissions']. So even once the server wrote the path, nothing asked again — that is the part that made a reload necessary. Also fixes a guard I broke reaching for this: typing VERBS as Record<string, VerbFn> widens keyof to `string`, which makes `isVerb` prove nothing and lets an unknown verb index the map. `satisfies` instead, and there is now a check that Verb is still the four-member union. Uninstall deliberately does not remove the path. A stale path with no item behind it is dropped by useDock, and keeping it means disable/enable and reinstall put the tile back where the user had it rather than at the end. tsgo clean, frontend builds. 787 pass / 7 fail — 6 identical to before; the 7th is plugins/music/cliamp, which install pulled into test discovery and which the manifest documents as parked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dadfdc26af |
plugins page becomes a marketplace: catalogue, clone-on-install
The /plugins route showed only what was already on disk, so a plugin nobody had copied in by hand did not exist. It now shows a third list — what a remote catalogue offers — and installing one fetches its source. - plugins/marketplace.ts — a stand-in for the remote store, a JSON array in process. One entry: music, pointing at gitea.officer.dev/plugins/music. It is deliberately a module and not a fetch, so the shape of a catalogue entry gets settled before anything depends on a server answering. - plugins/fetch.ts — git clone --depth 1 into plugins/<appName>/, INSIDE the platform repo because bun resolves the workspace links by walking up and a plugin cloned beside it cannot import anything the platform provides (measured, not assumed). https only, no credentials, GIT_TERMINAL_PROMPT=0 so a repo that turned private fails instead of hanging. A failed clone removes the directory: a half-written one reads as a broken plugin rather than an absent one, which is the worse of the two. - install.ts fetches first when the directory is missing, then RE-DISCOVERS rather than assuming what it cloned. Everything after is the existing path, including the frontend rebuild — so a cloned plugin's panels reach the SPA without a restart. - GET /api/plugins returns `available`, catalogue minus what is on disk. Each entry gets the same live PATH probe a local plugin gets, so music's ffmpeg and ffprobe are answered for this machine BEFORE the clone rather than after. - The detail panel is a separate component for a catalogue entry, not a mode of the installed one: there is no sidecar status, no enabled state and no route to open, and what there IS — the source URL — the installed panel has no reason to show. The streaming log is shared, because the marketplace install is the case that needs it most: it clones before it does anything, and a slow clone behind a blank panel looks like a hang. The catalogue's icon is an absolute URL to the store's copy, which is the [open] question the previous commit left: assets only publish at install, so a plugin you have never installed had no artwork to draw. Not done: no update verb, no signature, no private repos, and removePluginSource has no caller — deleting source drops tables on the next push, and that wants a verb that says so. tsgo clean. 767 pass / 6 fail, all 6 identical on HEAD without this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
2e8ec845c8 |
step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
|
||
|
|
9b1c0a75b2 |
a dock tile can be bare, so artwork is not framed in a swatch
The tile background exists so a white lucide glyph has something to sit on — on nothing it is invisible. Artwork does not need it, and a logo framed in an arbitrary coloured square reads as a mistake. badge rounded square filled with `color`, icon inset (what a glyph needs) bare no background, artwork fills the tile (what a mark wants) DEFAULTED from whether the plugin ships assets/icon.png — artwork gets bare, a glyph gets badge — with `tile` on the manifest to override either way. Presence is the declaration, as everywhere else here; the field exists only for the plugin whose artwork genuinely wants a backdrop. Music sets nothing and gets bare. `color` is still required either way. It does four jobs and only one of them is the square: the active glow, the indicator dot and the plugins-list tint all read it too. The icon was also smaller than it looked. The source carried ~8% transparent margin on every side and the artwork is 233x221 inside a square frame, so with the old `h-7 w-7` inset inside a `w-10` box the duck occupied roughly 39% of the tile's area. Cropped to its alpha bounding box, padded back to square to keep aspect, and rescaled: it now fills 95% of the frame instead of 79%, and `bare` gives it the whole tile instead of half. Measured rather than eyeballed — the bounding box came from extracting the alpha plane and scanning it, then the crop was applied to the 1254px original so nothing was resampled twice. tsgo clean, 797 tests, 787 pass, same 7. Verified live: music reports tile=bare, example and offscale still badge. |
||
|
|
f78abbe05a |
a plugin ships its own dock icon as a file, not a lucide name
The manifest's `icon` was a lucide NAME, resolved by `resolveIcon` — which knows 106 glyphs out of lucide's ~1,500. A plugin naming one outside that set silently rendered a neutral box, and a plugin from a marketplace had no way to see the ceiling coming. So the icon is a FILE now: `plugins/<name>/assets/icon.png`, discovered by presence like everything else here. `manifest.icon` stays as an optional fallback for a plugin with no artwork — example and offscale still use it — and the file wins when both exist. No new mechanism was needed. The app store already published sidecar assets: `<dir>/assets/` → `public/plugins/<id>/`, served by a dynamic `/plugins/*` route, with `DockItem.image` rendering an <img>. `pluginDockManifests()` simply never emitted `image`. Install now publishes and uninstall unpublishes — the one thing uninstall is allowed to delete, because these are copies whose originals are still in the plugin's source. Base64 in the manifest was considered and dropped. It would ride in every /api/user/capabilities response for every user on every page load, can't be cached separately, and puts a 5KB string literal in a source file — against the rule this manifest keeps: what a directory listing can say, it says. It also needs no support: `image` goes straight into <img src>, so a data: URL already works for anyone who wants one. Music ships OffMusic.png, resized 1254² → 256² (1.6MB → 108KB) with alpha intact, sized for 3× DPI at the dock's 32px render. It also drops `icon` — the artwork is a voxel duck in headphones, not a glyph. The plugins page showed a generic Puzzle for every plugin; the list and detail header now show the plugin's own icon when it has one. Two bugs found while testing. Dock.tsx imported 26 lucide icons and used 14. The twelve dead ones — Music, Bitcoin, Receipt, Images, CalendarDays, Contact, Clapperboard, Mail, Network, ArrowDownUp, FileText, FolderKanban, MonitorSmartphone — were residue from when every feature had a hardcoded tile. Deleted. And uninstalling a plugin left its icon URL answering 500, not 404. server.tsx globs ./public at BOOT into one exact route per file, each holding a Bun.file handle, spread into the route table AHEAD of the /plugins/* wildcard. So an icon present at boot got an exact route that outlived the file, returning ENOENT on every dock render with the error logged each time — exactly what the wildcard's own comment says it exists to prevent. The comment covered the ADD case; this is its mirror. `plugins/` is now excluded from the boot glob, so the wildcard owns that prefix alone. Verified: 200 installed, 404 uninstalled, 200 reinstalled. [open] A plugin's icon cannot be seen BEFORE installing it, which is the one place an app store most wants to — assets are published at install by design, and an authenticated icon route is no use to an <img>. |
||
|
|
0a55964db5 |
the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue is global state so returning to /music remounts and reloads. Solving it properly needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes. |
||
|
|
f1bd75853d |
the music widget moves into the plugin, parked like cliamp
src/workspaces/widgets/MusicPlayer/ → plugins/music/widgets/, its export dropped from the widgets package, and its registration removed from WidgetRegistry. Plugins cannot contribute widgets and that mechanism is not being invented now, so it is parked next to cliamp rather than left in a workspace package the platform ships. `../Widget` became `widgets/Widget` — the sibling import turned into that package's declared export, which it already had. This removes the last non-plugin consumer of officerdev/src/MusicPlayer, and that matters more than the move. The argument for keeping the player in the platform was that the widget imported useMusicPlayer and PlayerTrack from officerdev, and the platform cannot import from a plugin — so the player STATE had to stay whatever was decided about the UI, and the engine stayed with the state. That constraint is now gone. The complete remaining platform dependency on the player is one line: DashboardLayout.tsx:66, `<MusicPlayerHost />`. So the seam is no longer "the widget pins it". It is purely the global overlay question, which is a platform gap about plugins owning a render slot outside <Routes> — the same shape as plugins owning a websocket. Recorded rather than acted on; the decision is the owner's and the previous reasoning for it no longer holds. bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. |
||
|
|
1e79b4effd |
revert prettier churn on four files the cliamp move never touched
I ran `bunx prettier --write` over whole directories instead of the files I edited, so FileViewContainer, SelectionActions, usePipelineRunner and Providers got rewrapped into a diff about cliamp. Pure whitespace, zero behaviour, and exactly what CLAUDE.md warns about — unexplained churn in someone else's file. Worth noting what it revealed rather than just undoing it: those four were not prettier-clean to begin with, so `bun format` on a clean tree would rewrite them too. That is a pre-existing inconsistency, not mine to fix in this commit. bunx tsgo clean. |
||
|
|
a9bf51407e |
cliamp moves into the plugin, and the platform loses its last music file
The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.
The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.
Everything cliamp now lives in plugins/music/cliamp/:
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
api/cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx
src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.
Two of the things that moved were live, not inert.
The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.
That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.
The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.
Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.
Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.
bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
|
||
|
|
e930586878 |
plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and ffprobe — and the way it fails without them is the reason this is a check rather than a line in a README. It does not fail. Missing ffprobe means the indexer catches the spawn error and returns a track carrying its filename and nothing else: no title, artist, album, duration or embedded lyrics. It then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches, no log, no counter, and the only tell is coversSaved: 0 in a report nobody reads. So `osDependencies` is a manifest field: the binary to probe on PATH, why it is needed, and a package name per package manager. The shape is taken from scripts/setup-old/setup.sh rather than invented — probe the binary, case on $PM — and the names are per-manager rather than canonical-with-overrides because lib/packages.sh already recorded why that indirection was rejected. Probing the binary is what makes "built-in on this OS" free: on PATH means the package map is never consulted. Four decisions worth naming. Missing and uninstallable REFUSES the install, first, before a table is created or a row written — so there is nothing to undo, and the alternative is a plugin that installs, answers 200 and quietly produces nothing. The status is on GET /api/plugins and rendered before the button, because the owner is deciding whether to let the server run a package manager as root and that needs answering first. Installing by hand and watching it flip to present is the escape hatch on a machine without passwordless sudo. Package names get a deliberately narrow regex and reach Bun.spawn as an argv ARRAY, never a shell. Both halves are load-bearing: the regex means a metacharacter cannot get there, argv means it would be an argument rather than syntax if it did. Narrower than package managers actually accept — no `:`, no `+` version pins — because a plugin needing one wants a conversation. Success is OBSERVED, not inferred: after installing, the binaries are re-probed. A package manager exiting 0 having installed something that does not provide the binary is exactly the failure this exists to catch. installCommand mirrors lib/packages.sh's pkg_install_now exactly, including apt's non-interactive environment, so there is one definition of "install a package" rather than two that drift. sudo always gets -n: under PM2 a password prompt is not a slow path, it is a hang. brew never escalates. Verified live. ffmpeg and ffprobe were absent on this machine all evening; the page showed both missing with the exact root command, the install streamed `dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`, and X-Audio-Duration appeared on a stream response for the first time. The refusal path was exercised against a temporary probe dependency: HTTP 400, steps: [], reason named. THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt. Found on the way: a manifest is read once per process. Discovery does `await import()` and the module cache holds it, so editing a manifest changes nothing until pm2 restart officer — including `outdated`. Cost ten minutes and is now in the runbook. bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new. |
||
|
|
de3340398c |
music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.
Three things stayed, each on purpose.
cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.
The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.
api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.
Two bugs found on the way, neither visible from reading.
The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.
[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.
registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.
music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.
Not yet verified on the live server — that is next.
|
||
|
|
2c89281bfc |
drop a comment describing an export that left with offscale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0e24aa3d52 |
a way into the plugin from its detail panel
installing something and then having to guess its url is a small thing that makes the whole flow feel unfinished. the detail panel now links to the plugin's screen. shown only while installed AND enabled, and only when the plugin has a frontend at all. a link to an unmounted route lands on the home page, because the shell redirects an unknown path — which reads as a broken link rather than a plugin that is switched off. a backend-only plugin has no screen to open and gets no link rather than a dead one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b4137ccca |
a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.
a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.
App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.
serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.
verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.
three things worth recording because they were found rather than reasoned:
the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.
App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.
seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.
756 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a220342b22 |
stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and the detail panel renders them arriving. POST rather than GET, so EventSource is unavailable — it sends no Authorization header and these routes are owner-only. The client reads the body and parses frames by hand, which is what useCompanionLogStream already does for the headscale container logs; the parser only has to understand what our own endpoint emits. the runner does not know whether anyone is listening. it takes an optional onStep and calls it, so the non-streaming path is the same code with no callback rather than a second implementation of the same four verbs. there is a 220ms beat between steps and it is cosmetic — worth saying out loud. pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the router do not, and four lines landing in one frame look like a stall followed by a jump. small enough not to matter to a script, long enough to follow. writing to a closed stream is caught rather than fatal: navigating away mid-install must not abort the install, because by then it is the server's work and half an install is the one outcome the ordering was designed to avoid. verified over the wire with timestamps — frames arrive incrementally, the sidecar step showing its real duration rather than the beat. afterwards pm2 holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02e049cae8 |
terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2634df7a04 |
the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.
verified against a running server with a sidecar in the tree:
install ecosystem added · sidecar started · recorded · mounted /example
route 200, pm2 online
disable sidecar stopped · unmounted
route 404, pm2 stopped
enable sidecar started · mounted
route 200, pm2 online
uninstall record removed · unmounted · sidecar stopped, deleted, entry gone
route 404, not in pm2, tables untouched
afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.
the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.
order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.
each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.
the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.
720 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
98c400bf33 |
a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either side of a selection that lives in ?selected= and is read by both independently, so neither can be telling the other something stale — rows are real Links, not buttons holding the name in a closure. the detail panel shows what the tree declared (api, schema, sidecar, web), because "installed and nothing happened" is otherwise a mystery, and it names what uninstall does NOT do: neither disable nor uninstall deletes anything the plugin stored, and the screen says so rather than leaving someone to guess whether a button destroys their data. a directory whose manifest will not parse is listed with its error rather than skipped. a malformed plugin that simply does not appear is indistinguishable from one nobody wrote. `outdated` is surfaced as an Update button: the version on disk moving after an install is the normal state on a developer's machine, and it should be visible rather than inferred. the four mutations are written out rather than generated in a loop — useMutation is a hook, and a hook called from inside a helper is a rules-of-hooks violation even when the call order happens to be stable. caught before it shipped. verified against a running server: the spa builds (19.8 MB bundle containing the new screen), / serves 200, /api/plugins answers authenticated and 401s without a token. full suite 719 pass, same 10 pre-existing failures. live server and plugin_installs left untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d000cedf2f |
let anyone toggle hidden files again
the show/hide dotfiles button was dead for members. they open the browser at their own home, that home is `/`, and the toggle is disabled at `/`. it was never meant to apply to them. |
||
|
|
d6f01862fe |
fix a stack overflow in the wallet copy button
Mine, from the clipboard sweep. format.ts exported copyToClipboard wrapping navigator.clipboard; the sweep replaced the body call with copyToClipboard(value), so the function called itself. CopyField.tsx is the caller, so every copy button in the Wallet was an infinite recursion. Removed the wrapper rather than repointing it — helpers/clipboard already does more (execCommand fallback on an insecure origin) and CopyField imports it directly now. Found by finally running tsgo, in the officerdev-test tree, which has node_modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd209483e3 |
fix the clipboard over http, and audit the rest
navigator.clipboard is secure-context only, like crypto.randomUUID before it —
over plain http on a tailnet address the object does not exist. Twenty call
sites across eighteen files, in three states that all looked fine in review:
bare calls that threw and killed the handler, optional-chained calls that
silently did nothing, and one carrying the comment "Officer is always behind
HTTPS", which it is not.
The optional-chained ones are the worst of the three: a copy button that reports
success and copies nothing is indistinguishable from a working one until someone
pastes.
helpers/clipboard.ts falls back to document.execCommand('copy') over an
off-screen textarea — deprecated, and it works on any origin because it predates
the secure-context rule. Off-screen rather than hidden, because display:none and
visibility:hidden elements cannot be selected and the copy fails silently.
Reading the clipboard has no equivalent: execCommand('paste') was never permitted
from script. The file browser's paste-a-file path now checks canReadClipboard()
and explains itself instead of throwing.
docs/http-secure-context-audit.md is the full sweep the owner asked for: what was
fixed, what cannot be, and what was checked and found clear. crypto.subtle is
used nowhere in the frontend, which was the one worth confirming since it has no
cheap fallback. Notification's six matches are type names, not the API.
geolocation and navigator.share are already guarded. getUserMedia is in four
files and is being removed — but QrTransfer uses it for the CAMERA, not a
microphone, so "remove audio" does not cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
74b4c7908a |
fix the crash over http: crypto.randomUUID is secure-context only
Chat took the whole page down at the end of every turn with `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that is not localhost it is not defined at all. Officer is reached at http://officer-dev:9000, which is neither, so all eighteen call sites in the frontend were throwing. The stack shows why it was fatal rather than merely broken: it was called inside a `useState` initialiser, so the throw happened during render and unmounted the tree. The assistant message that has no id yet is created at the end of a turn, which is exactly when it fired. No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on `Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG: same 122 bits, same version and variant bits. Verified both paths produce a UUID matching the v4 pattern, including with randomUUID deleted. `crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole of the problem. Audio recording is a different matter — getUserMedia genuinely requires a secure context and cannot be polyfilled. Nine files, eighteen call sites. The vendored hls.mjs is left alone. Two of my own mistakes on the way, both caught by parsing rather than by reading: the rewrite added an import of the helper TO the helper, and inserted another one inside a multi-line import block — the same trap as the officerdb move earlier tonight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8207824a81 |
build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in $OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the service user, created on first use. The design doc planned to move ONE at-rest key into the store. What shipped splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all seven, so one leak opened all of them — and it was named after whichever plugin needed it first, which is why it read as safe to change if you did not run a vault. A core install bootstraps two, jwt and headscale; the rest appear when their plugin first asks. The file IS the secret. No second key unlocks it, because a key beside the store it opens buys nothing. The gain was never secrecy, it is blast radius: bun auto-loads .env into all twenty pm2 processes, so a key there is readable from /proc/<pid>/environ of twenty processes — officer-music held the key that decrypts wallet seed envelopes. Two defects found by testing the store rather than reading it, both of which would have shipped: The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than inheriting the database's mode, and a freshly written key lives in the WAL before checkpoint — so the 0600 on the database was decorative. The 0700 directory covered it, but only until someone loosened the directory. PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set AFTER it. With twelve concurrent openers, six died on that line with SQLITE_BUSY. Every sidecar opens this store at boot, so they open it simultaneously by definition: most of them would have failed to start on a cold boot and none on a warm one. Fixed by ordering the pragmas; re-tested with twelve racing processes, one key, one row. crypto.ts takes a purpose as its first argument now, which the design doc had explicitly promised would not happen — 32 call sites across seven query modules. That promise is corrected in the doc rather than quietly dropped. Also live, not just comments: wallet/upstream.ts gated wallet storage on process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It asks the store now, and the question it answers changed — not "did somebody set a variable" but "can this process open the store", since the key is created on demand. assertSecretsClosed covers the store, its directory and its WAL. The jwt key mints owner tokens, so a member's shell reading it is strictly worse than the .env leak that check was written for. Not typechecked: node_modules is empty and installs are frozen, so the officerdb/secret-store subpath could not be resolved at runtime here — verified that officerdb/types fails identically, so it is the empty tree and not the new export. The store module itself was tested directly: creation, idempotence across processes, hasKey not creating, permissions, and the twelve-way race. Every changed file parses; the setup section runs and degrades correctly when the import is unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f063fc0c08 |
remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag defaulted to ON, so none of it ran on a real install — what comes out is documented defence in depth that was already switched off. The file said so itself: "Both flags and their call sites come out once the tailnet is the perimeter." Origin was never authentication here in any case. An app's `officer://<hex>` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt, originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which existed only to pin them. CORS now echoes whatever Origin it is given, which is what every install already did. What SURVIVES is the reason this needed care. origin-validation.ts held two unrelated things, and the second was the global authorization gate — a valid non-owner token reaches only what its role grants, deliberately NOT under the flag because it is account-based rather than origin-based. Its own comment called it "the airtight half". Deleting the file wholesale would have deleted authorization. So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with the name matching what it does: nothing in it reads an Origin header any more. hono.ts mounts it in the same position, ahead of every router. origin-middleware.ts stays and is untouched — it extracts the Origin for six auth handlers that log it, and for passkeys. Extraction, not validation. Also updates every claim that rested on the old model: CLAUDE.md's security section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five messages in machine-setup's Tailscale section which told the owner to set ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to follow, and the honest version is different: with no tailnet the token is the whole lock, so put a proxy in front and restrict who can reach it. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes four variables now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
86079adb9a |
mail transport is configured in the app, not in .env
MAIL_TRANSPORT was a fallback left from the old registration flow that sent
confirmation mail. That flow is gone; the variable outlived it.
It was never the primary source anyway. getTransport reads server_config
('server-settings' → smtp) first, which already backs a full UI at Settings →
Server → SMTP and its API in api/server-settings/smtp.ts, supporting resend,
smtp and mailhog. The env var only answered when that was absent — which is a
second source of truth for something the owner can already set, with the failure
mode that a stale URL in .env silently answers for a server whose settings row
is simply empty.
Removed from transport.ts, .env.example and the setup script's Environment
section, which no longer asks for it. setup-old/ still mentions it; that is the
archive and is left alone.
Also split the try. It wrapped the read AND the transport construction and
swallowed both, so three different problems produced one message. Unreachable
database, nothing configured, and stored settings that do not build a transport
now say different things, because the fix for each is different and this message
is all the caller ever sees.
The two consumers — queue/engine.ts and auth/forgot-password.ts — now raise
until SMTP is set in the UI, which is the honest answer rather than a regression.
Not typechecked: node_modules is empty here and installs are frozen. transport.ts
parses under `bun build --no-bundle`; the setup script was run and no longer
prompts for or writes the variable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ec1997fd0e |
a chat with no chosen directory runs in the caller's own home
The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat sessions formed their own Claude project group instead of cluttering the home. It is a sibling of the home, and confineUserTree makes every sibling the platform's at 0700 because the others are attachments and email_accounts. So it was unreachable for a member: the first live member turn started there and every Bash call failed on its own working directory before doing anything. A per-member copy inside each home fixed the symptom and left two rules to remember. The owner chose one rule instead — the account's own home, whoever they are — and accepted the trade knowingly: /chat sessions now share a project group with anything else run from that home, which was the reason the dedicated directory existed. Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd, ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it. Existing directories are untouched and their transcripts stay where they are — Claude groups by cwd, so the owner's old /chat history remains under its own project slug rather than moving. The UI labels move with it: the default group now reads "home" rather than naming a directory that no longer has a role. ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by general_chat_sessions being email-derived, which is now gone — but the distinction it encodes is real (the email says who, the home says where), so the comment explains that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2a8f0049a3 |
handle OSC 52, so "press c to copy" reaches the user's clipboard
xterm.js does not handle OSC 52 unless something registers for it, and nothing did. A program offering "press c to copy" emitted the sequence and it vanished, so its confirmation was true about having sent it and false about anything arriving. Found while signing a member into Claude Code on the production host: its first-run login prints an OAuth URL too long to read off a wrapped pane and offers to copy it, "(Copied!)" appeared, and the clipboard was untouched. The URL had to be recovered by running claude under tmux on the server and reassembling it from the captured pane — which is not a thing a member can be asked to do, and first-run login is every new member's first five minutes. Writes only. A lone `?` in the data position is a read request — a program asking the terminal to hand over whatever the user has copied — and it is deliberately not answered: a shell should not be able to exfiltrate the clipboard of the person watching it. The clipboard API needs a secure context and generally a user gesture; the keypress that caused the sequence is that gesture. A refusal is swallowed rather than thrown, since a copy that does not land is the status quo rather than a reason to break the pane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e4acf19a35 | Merge remote-tracking branch 'gitea/master' into sidecar-app-store | ||
|
|
b4f88ec161 |
routes refuse at the route, and a new home is empty
Three things, from a member sitting on /music with no music capability on a server with no music sidecar: an empty library, and 403s in the console. PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was hidden and the route was wide open — typing the path, following an old link or restoring a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside the error boundary. It does not redirect. Sending someone to `/` erases what they asked for and reads as a bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so a reload after installing the thing just works. And it says which of the two reasons applies, because they need different screens and send the reader to different places. `not-installed` is a fact about the SERVER — the owner gets a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can change it. Presenting either as the other sends you looking in the wrong place. ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers "held but its sidecar is not installed", so an uninstalled feature has no tile AND no screen. The dock, the Permissions list and the routes now agree because they read one answer. NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that made them — the member's provisioning and, older and worse, `/ls`, which created folders in somebody's home as a side effect of LOOKING at it. A listing that invents its own contents is a listing you cannot trust, and the platform has no standing to choose a person's folder layout. A new home is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b058a6703 |
fix the sign-out reload loop I shipped an hour ago
The 401 handler ended with location.replace('/'), guarded by "unless the path starts
with /signin". There is no /signin route — the sign-in screen IS path="/". So every 401
on the signed-out landing page navigated to the page it was already on, fetched again,
401'd again. A hard refresh loop with no way out of the tab.
The reload was never what fixed anything: useAuth already renders the sign-in screen
when there is no token. It only existed to drop a stale query cache. So it is now the
last thing attempted and bounded three separate ways, any one of which breaks a loop
alone:
1. no token -> return. A 401 while already signed out is expected, not a revocation.
This one alone ends it, because a reloaded document has nothing left to clear.
2. once per document, module flag.
3. once per tab, sessionStorage marker — which also covers a host that re-injects the
token on every load, where clearing storage cannot help and guard 1 never fires.
Anyone stuck in the loop from the previous build: localStorage.clear() in the console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d3bed0add9 |
the file browser can actually read a member's home, and plans is gone
"This folder is empty" was a lie. The five seeded directories were sitting there and the platform's readdir raised EACCES: a member's home is 700 and owned by them, which is correct for a shell and locks out the file browser, which runs inside the platform process. /ls caught the error and returned an empty listing, so a refusal looked exactly like data. Two doors, two boundaries, and that is the point rather than a compromise. The terminal and the agent RUN AS the member and the kernel is the boundary there. The file browser acts on the member's behalf from inside the platform, which already applies its own containment and is the owner's process on the owner's machine — it can read anything via sudo regardless. Giving it access describes who is doing the work. Done with named POSIX ACLs, because it has to hold in BOTH directions: a file the platform writes must be editable by the member and vice versa. Mode bits cannot say that — whichever party is neither owner nor group lands in "other", and widening "other" opens the home to every account on the box. A shared group fails the same way, since both parties would have to be in it and that puts every member in a group that can read every other member's home. Two named entries plus `d:` defaults grant exactly two users and are inherited by whatever either side creates, whatever their umask. Verified: platform lists the home, member edits a platform-written file, platform edits a member-written file, and a SECOND member is refused on both ls and cat. /ls now distinguishes EACCES from a missing directory. An empty result is data and must never be how a refusal looks. acl joins the core packages in setup.sh — the alternative is an account that provisions and then cannot list its own home. Also: the file browser's own useTasks/useAgents fired /tasks, /agents and both category endpoints on every render, which is where the last four 403s came from — they are the context menu's Run Task and agent submenus, execution-only. Gated. And plans is deleted: router, screen, routes, dock tile, hook, page title and its capability. It read markdown from <repo>/plans, which does not exist. Fresh-install Permissions is now Files alone, with Terminal to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e393d0f5c2 |
a member's screens render, and the shell stops asking for things it cannot have
Three findings from granting Files to a role and signing in as the member. THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded was the success flag of GET /api/dashboards — which the `dashboards` capability gated. So a member with files granted got a completely blank Files screen and no request to /api/file-browser at all: the panel never mounted. Terminal, Chat and every other workspace screen were the same. /api/dashboards is not a feature. It is the per-user key-value store where every screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it does not restrict an account, it breaks it — which is the definition of `core` at the top of the registry. Moved there. And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as well as a successful one, with `loadFailed` for the difference, so a screen that cannot remember its layout still renders with defaults instead of showing nothing and explaining nothing. THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on capability, so a member's first paint fired 403s at /server-settings/settings, /jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing and the chat access policy. Each now checks the capability it needs. JobsIndicator and RescanButton also render nothing without `tasks` and `items` — the header was offering two links to a screen the member cannot open and a button that would 403. THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none of their sidecars are installed. Offering to grant Photos on a machine with no Immich is not a permission decision. It now shows only what is installed, lists the rest as "nothing installed for these yet" so their absence reads as a fact rather than a bug, and marks confined rows as needing a Linux account. Fails open on a degraded read. Found while checking that: the headscale catalogue entry claimed only the `headscale` capability, but the same sidecar also serves `vpn` — a member enrolling their own device — so vpn was never subtracted. Hence `alsoServes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0281ca62d2 |
a deleted or blocked account loses its session on the next request
Reported from two browser windows: an account deleted from the dashboard survived a page refresh in the other one. Two independent halves. Server: userMiddleware looked the account up, then read the result as `dbUser?.passwordChangedAt` — so a DELETED account fell through the optional chain and the request proceeded on a token that is still cryptographically valid, for up to the full 30 days. `status` was the same hole from the other direction: signin refuses anything that is not Active, but nothing rechecked it afterwards, so marking someone Blocked did not end the session they already had, which is exactly when you would be doing it. Now the account must exist and be Active on every request. Client: nothing reacted to a 401 at all. onError fed the bug-report form and stopped there, so the window kept rendering off cached React Query data. A 401 now clears every storage key createClient reads and returns to the sign-in screen. /auth/ is exempt because a wrong password is also a 401 and reloading the form would look like a crash. window.officerBearerToken was declared non-optional, which made "there is no token" unspeakable. It has always been one of five sources, any of which may be absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
801eba9284 |
say which harness owns a chat row, on both kinds
The list is merged from two stores and only OpenCode rows were badged, so Claude was marked by the ABSENCE of a badge — legible only if you already knew the list mixes two harnesses. Both carry one now, and since `harness` is absent on older Claude rows, anything not OpenCode reads as Claude, matching the server's own default. The badge no longer replaces the message count, it sits before it: the count is real on Claude rows and a hardcoded 0 on OpenCode ones (the session list has no count field and a real one costs an HTTP call per row), so those rows show the badge and no count rather than a zero that means "never asked". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |