1e03c600b8bbd93bf1b9863ae49005b5361321ef
60
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1e03c600b8 |
stop naming a branch when pulling the capabilities repository
The plugin and capabilities repositories were standardised on `master`, and five places said `origin main` — the setup script's sync and the Update-all path. Swapping them to `master` would be the same mistake wearing a new name. `HEAD` resolves to whatever the remote's default is, so the question stops being asked, exactly as it now does for the marketplace icon URLs. Only the LOCAL branch still needs a name. Third time today that naming a branch was the bug: the icon URLs 404'd on four repositories created as `master`, and these would have broken the moment the rest were renamed to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5b486ed019 |
build marketplace icon URLs from HEAD, not from a branch name
Every available plugin drew a broken image on any server reading the remote catalogue. The URLs said /raw/branch/main/, and four plugin repositories were created on `master` — so those 404'd, and the others only worked by accident of which branch somebody happened to use. The comment above the builder already said "a repo whose default branch is `master` gets a 404", marked [open]. It then happened. A warning in a comment is not a guard. Gitea resolves /raw/HEAD/ to whatever the default branch is, so the question stops being asked. Verified against both spellings, and all 12 icons in the live catalogue now answer 200. It did not show on alpha because every plugin there is INSTALLED, and an installed plugin's tile is served locally from /plugins/<name>/ rather than from the catalogue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
09f2cfa267 |
a plugin can depend on a release binary, and prove it is the right one
osDependencies could only say "this package, from this manager". Gitea's `tea`
is not in any distribution — it is a Go binary from dl.gitea.com — and Debian
has an UNRELATED package of that name: a graphical text editor. So the one
dependency gitea actually wants could not be expressed, and its marketplace
page showed no dependencies at all while music's showed two.
`release: { url, identify }` covers it. {os} and {arch} are filled from the
host in the spelling Go releases use, and the binary is fetched to
~/.local/bin — no root, and nothing a plugin downloaded from the internet
sitting beside what the distribution installed. Written to a temporary name and
moved into place, so a half-downloaded file is never on PATH under the real
one.
`identify` is the part that matters. Bun.which('tea') cannot tell Gitea's CLI
from the text editor, so presence was never the same question as identity —
and reporting the wrong program as satisfied is worse than reporting it
missing, because the install succeeds and every call afterwards fails
strangely. A binary that fails to identify is now `present: false, imposter:
true`.
Found while testing: Bun.which() with no options reads the PATH the PROCESS
started with, which under PM2 frequently excludes ~/.local/bin entirely. A
release binary fetched during an install would have been reported missing
seconds later by stillMissing, failing an install that had just succeeded. The
search now passes PATH explicitly, with ~/.local/bin first.
Verified against the real binaries on this machine: the genuine tea identifies,
a fake that prints text-editor output is caught as an imposter, and both
dl.gitea.com URLs (linux-amd64, darwin-arm64) answer 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8fe1b1824b |
a plugin can ship affordances, and installing publishes them
A plugin is a new SURFACE arriving at runtime, which is exactly the case hand-written prose could never cover: install gitea and no agent learns that `tea` is authenticated, or that plain `git` is the better tool for what git already does. The plugin knows; nothing carried it across. An `affordances/` directory in the plugin repository is copied to capabilities/affordances/<publisher>/<plugin>/ on install and removed on uninstall — the same shape as assets/ and publishAssets, deliberately, because two mechanisms for "the plugin ships some files" would drift and that one is already understood. Namespaced by publisher because two plugins named `gitea` from different publishers must not overwrite each other, and because an affordance in the tree should say whose it is without being opened. It mirrors mountPrefix. Uninstall removes the PLUGIN's directory, never the publisher's: every first-party plugin publishes under one officerdev/, and taking that level out because one was uninstalled would strip the rest silently. That is what the tests are mostly about — a copy going wrong is obvious, this would not be. Publishing is not fatal to an install. An affordance that failed to copy costs an agent some knowledge; refusing the install over it costs the owner the plugin. Verified against the real gitea plugin: published, the loader saw officerdev/gitea/gitea beside officer/core/image-rendering, uninstall removed it and left core alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d49776307d |
gitea, extracted into plugins/gitea — the platform half
Eleventh extraction, and the only one that deleted its UI instead of moving it. Seventeen files under apps/Gitea/ reimplemented a fraction of an interface Gitea already has; the plugin renders the real one in an iframe over a same-origin proxy. Deleting rather than moving removed a platform change nobody now has to build: useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a plugin gets two, so an as-is extraction would have required teaching the plugin system to declare extra route patterns. Nothing inside an iframe needs a route. Nothing working was lost. The router had been commented out of hono.ts since 2026-08-13 with api: [], and officer-gitea was running nowhere — the screen rendered and every request 404'd, like soulseek, jellyfin and invoices before it. Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS, the only installable feature that was, on the reasoning that it fronts a remote instance and installs nothing locally. As a plugin its tile arrives from assets/icon.png like every other — which is also why the icon added to public/icons/ this morning was reverted rather than kept. 'gitea' stays in the ServiceName union: the connection lives in core service_connections, and gitea is the reason that table has its "member rows carry only a token and inherit the owner's URL" shape at all. registry.test.ts's grantable-permission test named gitea as "the case this was built for"; a plugin's permissions are registered at install and are never in that compile-time list, so it now names a core one. catalogue.test.ts re-anchored to vnc/notify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b2c896523 |
ship the capabilities repository, and update it from the same button as plugins
capabilities/ is the file-based item store, and most of it belongs to the owner: the six item directories are created by the platform at boot and extensions/tool-loader is written from platform source, so neither is tracked anywhere. What IS tracked is the small set the platform ships — today one affordance, telling an agent that markdown images render and that the USER'S DEVICE fetches the URL, which it cannot discover from the filesystem. That now lives in officerdev/capabilities, public so a fresh install can clone it without credentials. Two ways in. The setup script syncs it during the layout step — non-fatal, and it says what an install without it costs rather than failing: agents go back to guessing, which is what they did before this existed. And "Update all" on the marketplace screen carries it alongside the plugins, because it is the same question — get me what has been published since I installed — and a second different gesture for it is one nobody remembers. Three states in both places, and the third is the normal one on an existing machine: a checkout is pulled, an empty directory is cloned into, and a directory that already holds the platform's generated items gets a repository initialised around them. UNTRACKED files never block a pull, because the owner's own skills and tools living here is the ordinary state — only tracked changes do. `cloned` and `adopted` are reported in the UI where `pulled` is not: they mean this machine had no affordances at all until the button was pressed. Verified against the real checkout: sync reports pulled, and the loader finds officer/core/image-rendering afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ef078210a |
update a plugin, or all of them, from the marketplace
There was no update path. fetchPlugin REFUSES an existing directory — cloning over one is how uncommitted work disappears — so install was a one-way door, and the answer to "my plugin changed" was uninstall and reinstall, which drops its tables on the way past. An update is a git pull and then the ORDINARY install path. installPlugin already skips the clone when the directory is present and re-runs everything else: os dependencies, schema push, assets, the ecosystem entry, the sidecar, the remount and the frontend rebuild. So there is no second implementation of "apply a plugin" here to drift from the first. Two refusals, both about not destroying work. A dirty working tree is refused rather than stashed or force-pulled — every plugin directory on a developer's machine is a live checkout, and this repository's own are edited in place. Non-fast-forward is refused for the same reason: a diverged local commit is somebody's work and merging it automatically is not the platform's decision. The per-plugin button replaces one that was quietly broken: it appeared only when `outdated` and called `install`, which cannot fetch, so it re-applied stale code. Nothing bumps a version today either, so with everything pinned at 1.0.0 that flag never fires and the button never appeared. Now always offered, and it pulls. Update-all is a plain POST rather than the streaming door: twelve interleaved step streams is not a readable log, and the useful answer is the summary. It runs sequentially — each update pushes schema, republishes assets, rewrites the ecosystem file and rebuilds the frontend, and two of those at once is a race over one file each. One failure does not stop the rest, and failures are named with their reason, because "2 failed" sends someone to the terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfbec0b757 |
plugins: a splash is part of a plugin, like its icon
assets/splash.png is discovered by presence, published by the same copy that already took the whole assets directory, and carried on DiscoveredPlugin beside icon. The platform draws no splash — a plugin has no launch. It is carried because the plugin repository is where a product's artwork lives now, and the phone app that fronts the same product needs somewhere to read it from that is not a designer's folder. The pair is the point: the icon is a reduction of the splash's world, and keeping them together is what stops them drifting. Publishing no longer gates on the icon alone, which left a plugin shipping only a splash serving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bbb16d6ed3 |
bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half
Tenth extraction, and the first where the blocker was a permission rather than a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin permissions are always kind: 'app'. mount.ts forces that and the manifest has no field for anything else, deliberately, so a plugin cannot ask to be restricted. Extraction therefore could not preserve admin. Raised before moving any code, because turning "the server owner's bitcoin" into a row in the grants list beside Photos and Music is the kind of change nobody notices until someone ticks it. Made anyway, and the objection inverts: every wallet query is scoped by user_id, taken from X-Officer-User which the platform injects from the session, so granting the permission gives an account its OWN wallet rather than access to anyone else's — including to the owner, whose bypass covers permission checks and not query scoping. kind: 'admin' was single-user thinking. Named bitcoin-wallet, not wallet: appName drives route, API prefix, table prefix and install key together, so the name should say what it is. Tables are bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before committing to it — no wallet table exists on any server, and db:push would treat a rename as a drop plus a create. registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture and its no-selfService fixture. Now user-admin/server-admin and terminal, with a note that a plugin's permissions are always `app` — which is exactly why this one needed a decision rather than a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d6f093532d |
install radicale and register officer-caldav on an existing server's next boot
Calendar and contacts became core in |
||
|
|
f566fda0b2 |
invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee321f49fd |
refuse a plugin install whose permission key the platform still claims
Three times in one day — photos, soulseek, jellyfin — the same failure on the same machine: install reports success, the dock tile appears, the frontend rebuilds with the route in it, and every request 404s. The plugin never mounted, because setPluginPermissions refused its key as already core. That refusal was correct and useless. It happens at mount time, after the install has reported success, and its only trace is one line on stderr that nobody is reading. The cause is always the same: the key was a CORE permission until the extraction commit, so a server whose platform predates that commit still owns it. The fix is always git pull, and nothing in the product said so. installPlugin now checks the manifest's keys against the core registry before anything is written, and refuses with a message naming the colliding keys and telling the operator to pull the platform and restart. Placed with the other pre-flight checks, so it changes nothing that would need undoing. corePermissionCollisions is exported from the registry rather than reimplemented in install.ts — the core key set has one owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aebd6ff0c0 |
jellyfin, extracted into plugins/jellyfin — the platform half
Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Also closes the same data hazard — jellyfin_servers was absent from the drizzle barrel, so bun db:push would have dropped it. No frontend call site needed repointing: mountPrefix derives /jellyfin from the appName, which is what the router already hardcoded. Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin broke 18 tests, the FOURTH extraction in a row to break the same files — photos and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it pins planSteps and runInstall, which are installer logic, and a service leaving for plugins/ says nothing about whether the installer still plans the right steps. catalogue.test.ts stays the place real entries are checked well-formed. members/catalogue/preflight re-anchored to memos, which has jellyfin's exact shape. The .prettierignore rule for the vendored hls.mjs moved into the plugin rather than being left behind naming a deleted path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b196c9cb55 |
soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8028364f1 |
transmission, extracted into plugins/transmission — the platform half
Sixth extraction, straight after photos. Everything transmission now lives at gitea.officer.dev/plugins/transmission. Deleted, not commented: the sidecar, the api router, the Transmission app (28 files including detail/ and dialogs/), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, the hono.ts import and mount comments, and the app-store catalogue entry. No database change: transmission owns no tables. Its connection lives in service_connections, a core table it now depends on from outside — a real coupling, recorded in the plugin's own PLUGIN.md rather than left to be discovered. ── Test fixtures, repointed rather than deleted ── Transmission was the canonical `members: 'none'` fixture — a single-tenant daemon with no user concept — across three app-store test files. slskd has the identical shape, so the assertions moved there. Deleting them would have quietly dropped coverage of the no-members install path along with the plugin, which is the same trap the vault extraction hit with `members: 'invite'`. ── Vocabulary swept, per the runbook ── Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as the worked example of the Workspace/Panel shape. Both are plugins now, so those pointers dangled at code no longer in the tree. Repointed at /command-hq and /soulseek, which are core. bunx tsgo clean. 1080 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a327dfc82c |
photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration already server-side, frontend already panels. Everything photos now lives at gitea.officer.dev/plugins/photos. Deleted, not commented: the sidecar, the api router, the db directory, the Photos app (25 files), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, both database barrels, the hono.ts import comment, and the app-store catalogue entry. The catalogue deletion is required rather than tidying — the trap the runbook records as having bitten twice. permissionAvailability() derives from sidecar_installs, which a plugin never gets a row in, so a leftover entry makes the permission permanently 'unavailable' and withholds the route and dock tile on a server where the plugin is installed and working. ── Test fixtures, repointed rather than deleted ── Photos was the canonical app-store fixture across four test files, chosen for being existing|provisioned with members: 'accounts'. Jellyfin has the identical shape, so the assertions moved there — they are about the installer, not about photos, and deleting them would have quietly dropped coverage of the provisioned install path along with the plugin. One loss worth naming: Immich's `provisioned` install mode went with the catalogue entry. Docker provisioning for plugins does not exist yet, so photos is point-at-your-own-instance until it does. bunx tsgo clean. 1080 pass / 0 fail, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8367fada13 |
plugins: let a plugin claim top-level paths and divert /api by header
Restores the setup the vault extraction regressed: paste the bare Officer URL
into a Bitwarden client and log in. Nothing else.
The extraction moved the Bitwarden door to /api/vault/bw, which is correct and
unusable — a client derives /api, /identity, /icons and /notifications from ONE
url it is given, so a prefixed base turns setup into five fields copied by hand
into an advanced pane. That is not a feature anyone completes, and the owner
said so. My regression, my wrong call: I treated "a plugin cannot serve
top-level paths" as a fact about the platform when it was a fact about what had
been built.
Two additions, both manifest-declared, both narrow:
unauthenticatedRootPaths single lowercase segments (`/identity`), served
above the gate. Refused if reserved (`/api`,
`/auth`, `/plugins`, `/dav`, …), refused if another
plugin already claims it, refused if it is anything
more structural than one name.
protocolHeaders hands `/api/*` to the plugin when the request
carries one. Needed because /api is the one
namespace both servers claim — Vaultwarden has
/api/sync, Officer has /api/chat — and the path
cannot tell them apart, so the client says who it
is. ROUTING, not authentication: a forged header
reaches the plugin, which demands its own
credential, and cannot reach anything of Officer's.
The worst it does is cost that caller Officer's API.
The Bun route table gets an entry per claimed segment, built from the plugin
set — `refreshPluginMounts` runs at line ~204, long before serve() at ~378, so
the claims are known. This is the constraint I earlier asserted made the whole
thing impossible, without checking it. The real cost is smaller and stated in
the code: a plugin claiming a root path is not fully installed until
`pm2 restart officer`, because the table is fixed once serve() is called.
Every claim is logged at boot by plugin — a root path answering without a
session should not be something you read a manifest to discover.
13 more tests (30 in the file): reserved segments, malformed segments, two
plugins colliding, and segment-not-substring matching so `/identityx` never
matches `/identity`. Suite 1074 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
dc190bb790 |
plugins: implement unauthenticatedPaths — the half that was never built
`unauthenticatedPaths` has been in PluginManifest since it was designed, with
validation (`publicPathProblem`), a long docstring, and a promise that
`assertPluginPublicPaths` re-checks it at boot. Nothing read the field. No
function by that name existed. Kiosk, the plugin it was written for, declares
none — so it validated, documented itself, and did nothing, and no plugin has
ever actually been served above the gate.
Vault is the first real user: a stock Bitwarden client speaks Vaultwarden's
protocol and has nowhere to put a platform JWT, so it cannot use the
authenticated /api/vault router at all.
── What was missing, and where it had to go ──
Two places, not one, and the second is the one that matters:
• the permission gate skips declared paths — but that gate only runs when a
token WAS presented (`payload && !isOwner && …`), so it is what stops a
valid non-owner reaching something ungranted.
• `protectedRouter.use(userMiddleware)` is what stops an ANONYMOUS request.
A path meant to answer without a session has to skip that too, so it is now
conditional on the same predicate. Fixing only the gate would have left
every declared path returning 401 — passing its own tests and failing in
the one case it exists for.
── Containment ──
Paths resolve against the plugin's own mount (`/api` + mountPrefix + path) and
containment is re-checked there, so a plugin cannot name `/auth` or another
plugin's routes however it edits its manifest. Refusal is per-path: one bad
entry does not take the good ones with it, and each is logged by name.
Matching is on segments rather than characters — `/api/vault/bwx` must not
match `/api/vault/bw/*`, the same class of bug `permissionForApiPath` guards
against with `/user` vs `/username`.
Every plugin with public paths is logged at boot: a route that answers without
a session should not be something you have to read a manifest to discover.
17 tests, pinning the containment rules rather than the happy path. Suite
1061 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f6a1194905 |
vault, extracted into plugins/vault — the platform half
Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.
Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.
The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.
── The one new piece of platform: src/servers/account-lifecycle.ts ──
Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.
Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)
Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.
── Two things deleted rather than carried, both dark since 2026-08-13 ──
The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.
The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.
── Tests ──
Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.
bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.
Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3803d42243 |
plugins: don't require a literal .git suffix on a catalogue clone URL
Found chasing a real install failure: music's marketplace row was hand- entered as https://gitea.officer.dev/plugins/music with no .git suffix, and fetchPlugin's URL check refused it outright with a message ("https URLs only, no credentials") that named neither actual reason. The suffix was never a security property — https-only, safe character set, no embedded credentials are the real checks, and Gitea/GitHub both clone identically with or without .git. Made the suffix optional rather than required, so a future hand-entered row missing it doesn't hit the same wall. Also fixed the row itself in officer_marketplace (added .git, matching every other plugin's entry) for consistency, and confirmed the gitea repo itself was never the problem — plugins/music already exists there with both commits, unaffected by this bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b7d8bbf36 |
add gen:ecosystem script to rebuild ecosystem.config.cjs from the database
bun gen:ecosystem writes the whole file from three sources: CORE_PROCESSES read directly out of scripts/setup/officer-setup/lib/services.sh (shelled out to bash, so there is exactly one list rather than a TS copy to keep in sync by hand), marketplace plugins that are installed AND enabled in Postgres and actually ship a sidecar/, and app-store services that are installed AND enabled, using each catalogue entry's `process` name against the src/servers/sidecar/<id>/index.ts convention. Extracted `pluginPm2Entry` (a pure DiscoveredPlugin -> Pm2App) out of addPluginToEcosystem so the live install/uninstall path and this from-scratch rebuild share one definition of what a plugin's entry looks like, rather than two that can drift apart. addPluginToEcosystem's own behavior is unchanged. Complements, not replaces, the existing incremental add/remove in servers/plugins/ecosystem.ts — that edits the live file at install time; this regenerates the whole thing, for a fresh clone, drift, or restoring from a backup. Found while testing against this machine's real file, worth flagging rather than silently working around: officer-marketplace (the marketplace.officer.dev process, src/servers/marketplace/server.tsx) is in this machine's ecosystem.config.cjs by hand and is modeled by neither the plugin system nor the app-store catalogue, so this script does not know about it and would drop its entry on a real run. Left alone rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f31d464be |
plugins: declare unauthenticated paths, validated; mounting designed and measured
half of the primitive kiosk needs. the manifest field and its validator are in;
the mounting is documented and not wired, so nothing serves anything publicly
today.
paths are relative to the plugin's own mount and refused rather than corrected:
no leading-slash-missing, no .., no //, no query or fragment, wildcard only as a
trailing /*, and never the bare / or /*. the plugin is trusted with what those
paths DO, never with which paths.
the mounting was measured rather than reasoned about, and two obvious designs
are wrong:
mounting the public router at the plugin prefix gives correct fall-through but
lets the router serve any route it defines - the manifest becomes documentation
rather than enforcement.
adding a use('*') guard blocks that leak and BREAKS the plugin's authenticated
api: a use('*') middleware always matches, so the parent never continues.
measured - an authenticated route 404'd with a valid token.
what works is registering only the declared patterns, delegating into a
pre-mounted app. undeclared paths never register a handler, so they fall through
naturally. enforcement by registration rather than by checking, which is why a
plugin cannot widen it.
docs/plugin-unauthenticated-paths.md has the probe results and the six
remaining steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e88e3a70cf | email in the bundled catalogue, the offline floor under the store | ||
|
|
ed3806f998 |
installing a plugin resets its saved layout
a layout stores appType STRINGS. rename a panel key between versions and every saved layout still names the old one; normalizeLayout swaps each unknown appType for allowed[0] — whichever panel the plugin lists first — and WorkspaceView persists the repair. so renaming headscale-* to offscale-* rewrote the owner's layout into three copies of the server picker and saved it. ServersView never mounted, there was no register form on the page at all, and it survived a server restart, a sidecar restart and a plugin reinstall because nothing on disk was wrong. found by counting elements in a real browser: 3 links, 0 buttons. install is the only place that knows a plugin's panels may have just changed shape, so that is where the stale row dies — for every user, since the damage is not per-user and install is owner-only. nothing is lost: a plugin screen is locked, so the layout is the author's design rather than someone's arrangement, and useDashboardState re-seeds from defaultLayout on the next load. does NOT fix the underlying trap: renaming a panel key is still a silent data migration for any screen that is not a plugin. see TODO. |
||
|
|
df92a985d2 |
build failures say what went wrong
Every failed frontend build has reported `Bundle failed` and nothing else, for as long as anyone remembers. The cause is one line that was never there: `Bun.build` THROWS on failure by default. It throws an `AggregateError` whose own message is the string "Bundle failed" and whose `errors` array holds the real diagnostics. So the carefully written `result.success` branch — with its comment about what a failed build must not do — was unreachable, and the catch dutifully reported `err.message`. `throw: false` makes the returned result carry `logs`, which is what the code below it always assumed. The catch keeps a fallback that unpacks `AggregateError.errors` anyway: the build should no longer throw, and the failure mode this hides is exactly the one that has been wasting people's evenings. Truncation went 500 -> 1000 characters. A single bundler diagnostic with a file, a line and a code frame routinely exceeds 500, and cutting mid-path leaves it unactionable. Proven by breaking the app on purpose: before: Bundle failed after: ResolveMessage: Could not resolve: "./this-module-does-not-exist" ── The first attempt at that proof was worthless ── An UNUSED import reported `ok: true`, which briefly looked like a much worse bug — a broken app building clean. It is not: an unused import is elided by the TypeScript transform before anything tries to resolve it, so there was no error to report. The break only becomes a break once the import is used. Worth recording because "I broke it and the build passed" is an alarming sentence to arrive at by accident. Also verified that a failed build leaves `build/` alone — the staging swap does what its comment claims, so a bad build cannot take the running app down with it. This one shows up in the UI too: the Rebuild button in Settings -> System already displays `error`, so it stops saying "Rebuild failed" and starts naming the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc420ba5a2 |
stop assuming bunx is on the service's PATH
`Executable not found in $PATH: "bunx"` — installing music died there, after the
clone and after apt had installed ffmpeg. My bug, one commit old: the schema step
spawned `bunx drizzle-kit`.
PM2 starts the platform with a PATH that has `bun` and not `bunx`. That is a
perfectly normal way for a service to be started — a symlink into /usr/local/bin
for the runtime and nothing else — and an install has no business depending on
which of a runtime's shims someone happened to link.
So neither name is looked up now. `process.execPath` is the interpreter already
running this code, so it cannot be missing, and drizzle-kit is invoked by path
through `node_modules/.bin/drizzle-kit` rather than by resolution. A missing CLI
there is reported as "run bun install" instead of as a PATH mystery.
Verified the way the claim demands: re-ran the schema push under
`env -i PATH=/usr/bin:/bin`, where `Bun.which('bunx')` and `Bun.which('bun')` both
return nothing. It works. The previous run proved nothing, because bunx was still
there.
Worth noting for the next spawn added to this path: `git`, `pm2` and `sudo` are
still PATH lookups, and they are the ones that were already working on the
machines we have. Anything new should follow this pattern instead.
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e0d3f59b9b |
name the tables nothing on this machine owns
The marketplace screen now lists tables in the database that no code here claims, with a row estimate and, where the prefix says so, which absent plugin they look like they belong to. This is the information that cost an evening. `bun db:push` saw tables the schema did not declare, asked an interactive question no non-TTY caller could answer, applied nothing and exited 0 — and the install reported success while the plugin was unusable. The cause was two tables from before an extraction, found by reading `\dt` by hand. Nothing in the platform would ever have mentioned them. ── What counts as an owner ── The core schema, or a plugin DIRECTORY — not a plugin INSTALL. A plugin that is present but uninstalled still owns its tables, because that is the promise uninstall makes: keep every row so a reinstall puts you back where you were. So this reports only what no code on this machine describes at all, which on a healthy install is nothing, and the block does not render. A plugin whose schema will not import has its prefix treated as claimed rather than having every one of its tables reported as unowned — a broken manifest should not read as data with no home. ── No delete button, deliberately ── Dropping a table is not something to offer beside a list of plugins, and the right action differs per row: `music_favorites` wants the music plugin installed, `headscale_servers` wants dropping. Naming them is the part that was missing. Verified by planting `headscale_servers` and `music_playlists_old` and reading them back through the same call the route makes: the first attributed to nothing, the second to music, which is not on this machine. Row counts come from `pg_class.reltuples` — an exact count on a large table is not worth blocking a page render for a number that only answers "is there anything in here". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8d8fbe2903 |
delete the global push from the server, keep the warning
`pushSchema()` had no callers left once installs stopped using it, and it is not the kind of dead code worth keeping around: it is a one-line way to run the exact command that stopped applying anything on a real server, and dropped tables when forced. The doc comment stays in its place, because the useful artefact is the reason rather than the function. `bun db:push` remains correct in exactly one situation — `officer-setup`'s build step, on a machine with no tables yet, where "make the database match this schema" is an instruction with no ambiguity to resolve. Every other caller now has a scoped path. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
221e370442 |
a plugin install creates its own tables and cannot touch anything else
The logs from the last commit showed what was actually wrong, and it was worse
than a failed push.
── What the install was doing ──
`bun db:push` hands drizzle-kit the whole schema and the whole database and asks
it to make them match. On a machine holding tables the current schema does not
declare — a plugin that is not on disk, anything left from before an extraction —
it sees a new table appearing while others are due to be dropped, and asks:
Is offscale_servers table created or renamed from another table?
❯ + offscale_servers create table
~ headscale_servers › offscale_servers rename table
~ music_favorites › offscale_servers rename table
That prompt needs a TTY. `--force` does not answer it, `< /dev/null` does not
answer it, and piping newlines does not either — measured: it hangs until killed.
Without a terminal the push applies NOTHING and exits 0, which is exactly how an
install reported success on a machine where its table was never created.
And `--force` would have been the wrong fix. Force means "run the destructive
statements without asking", and the destructive statements there are `drop table`
for every plugin whose directory is absent. Installing one plugin could delete
another's data.
── `tablesFilter` was tried, and is not enough ──
Scoping the push to the plugin's tables stops it dropping other tables and it
still diffs sequences. Measured on a scratch database: installing offscale issued
`drop sequence passkey_challenges_id_seq` — a CORE platform sequence — because the
plugin's schema does not declare it. Postgres refused only because a column
default depended on it. A sequence without dependents would have gone.
── So: generate the SQL, then apply it ──
`drizzle-kit generate` against the plugin's schema alone, into an empty directory,
emits exactly the statements that build that schema from nothing. It diffs against
the migration journal — empty — not against the database, so it cannot emit a DROP
of anything, of any object type. There is no destructive statement to guard.
Applying is idempotent by exception: an object that exists raises 42P07/42710 and
is skipped, which is the normal case for a reinstall, since uninstall keeps data
on purpose.
`[open]` This creates; it does not migrate. A plugin version that adds a column
will not get it. Plugin upgrades need real migrations — a separate feature. The
old push appeared to handle that case, at the price of being able to drop half the
database.
── Verified ──
Fresh install: 4 statements applied, table verified, 761ms. Reinstall over
surviving tables: 0 applied, 4 already present. Core sequences before and after:
18. Orphan tables on a scratch database shaped like the failing server
(headscale_servers, music_favorites): untouched.
The reinstall case failed on the first attempt — drizzle wraps driver errors, so
the SQLSTATE saying "duplicate table" is on `cause`, and checking only the top
level made every second install fail on its own CREATE TABLE. Found by testing the
second install rather than stopping at the first.
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c98b5643aa |
show what the commands are actually saying
Install, uninstall and purge narrated what the platform was doing and hid what it was running. Every spawn captured stdout and stderr, held them, and surfaced the last 600 characters only if the exit code was non-zero. That is enough to explain a crash and useless for the two things people want while watching an install: knowing a slow step is still moving, and seeing the warning a command prints on its way to exiting 0. The second is not hypothetical — `db:push` reported success on a machine where it created no tables. Its output was captured, discarded because the exit code was fine, and the failure surfaced minutes later as 42P01 in a sidecar log. Whatever drizzle said about that push, nobody ever read it. `runLogged` streams both pipes as they arrive, so they interleave the way they do in a terminal. Following only stdout would have been silent for exactly the commands worth watching: git writes everything to stderr, drizzle-kit writes progress there and results to stdout. Two pieces of noise control, because a log nobody can read is not an improvement: `git clone` gets `--progress` instead of `--quiet` (git says nothing when it thinks it has no terminal, and that is the step where a slow link looks like a hang) with per-percent rewrites collapsed to the last fragment per chunk; and drizzle's spinner, which is eight lines saying the same thing with a different braille glyph, collapses to the first — otherwise "Changes applied" scrolls past underneath it. Steps and output share ONE ordered list rather than two. Their order relative to each other is the whole information: "which step was this output from" is the only question the log exists to answer. Output renders dimmer and indented under the step that produced it. Verified by running a real purge and reinstall: the clone's object counts, the barrel generation naming both plugins, drizzle's push, then "schema: applied in 2029ms (1 table verified)". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c03e61c5e |
delete a plugin and everything it owns, and stop trusting exit codes
Two halves of the same problem: a plugin installed onto a fresh machine, reported success at every step, mounted, started its sidecar, registered — and then failed its first query with 42P01, because `offscale_servers` had never been created. Nothing said so, and there was no way back to a clean machine to try again. ── The install now asks Postgres ── `pushSchema` only checked the exit code, and `db:push` is a compound command: generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing. So the install now reads the plugin's schema for the tables it declares and verifies they exist, failing the install with a message naming what is missing instead of leaving a stack trace to appear in a sidecar log minutes later. Tables that are NOT prefixed with the app name are reported as a warning rather than a failure — they still get created, but `purge` will not remove them and a reinstall would find them already there. ── `purge`, next to an uninstall that deliberately keeps everything ── Uninstall is careful on purpose: every table and row survives so a reinstall puts you back where you were. That is what makes it safe to try, and it left no way to get to a CLEAN machine. A second install finds the directory (so the catalogue hides the plugin) and the tables (so push has nothing to do, and a broken schema stays broken). So a different verb, a different word, its own confirmation. It reads the table list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar — nothing may be running against tables about to vanish), drops, deletes the directory, then regenerates the barrel unconditionally, since a barrel naming a directory that no longer exists is what stopped a server booting yesterday. The table list comes from the plugin's own schema, not from `like '<app>_%'` — that query eventually drops somebody else's table, because the prefix is a convention nothing validates and `music` is a prefix of `music-videos`. The prefix is used as a GUARD instead: a schema module could export `users`, and anything outside the plugin's own namespace is refused rather than dropped. ── Verified as a full loop on this machine ── purge offscale → table dropped, directory deleted, barrel regenerated → the marketplace offers it again → install → cloned, ssh checked, "schema: applied in 2072ms (1 table verified)", dock pinned, mounted, sidecar started. I left offscale INSTALLED here rather than restoring the half-state it was in (no install row, table present, sidecar running). tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ad5a5563e |
clicking a plugin works again, and offscale gets its icon
Two regressions and one long-standing hole, all found by actually opening the page. ── Clicking a row did nothing. That one is mine ── Rows link to `?selected=<app>`, and they still pointed at `/plugins`. My redirect was `<Navigate to="/marketplace" replace />`, which DROPS the query string — so a click went to `/plugins?selected=music`, redirected to a bare `/marketplace`, and the detail pane sat on "Select a plugin." Both halves worked; the redirect threw away what the link had just set. Rows now point at `/marketplace` directly. The redirect also preserves `search`, because old bookmarks carry the same param and losing it is exactly what made this wrong the first time. ── Offscale's icon 404'd since it shipped one ── Assets were published to `public/plugins/<app>/` at INSTALL and nowhere else. Offscale was installed before it had an `assets/icon.png`, so the directory never existed and the tile drew a 404 permanently — nothing short of uninstalling and reinstalling would have fixed it. "Reinstall the plugin to see its new icon" is the same bad answer as "restart the server to see it", which the route right next to this already refuses to accept. Publishing now happens on every mount. It is a copy of files that still exist in the plugin's own source, so re-running it is free and cannot lose anything — the property that already makes unpublish the one thing uninstall may delete. A failure is logged, never thrown: an icon is not worth refusing to mount over. Published offscale's by hand too, so the fix is visible without waiting for a restart. Verified 200 through the running server for both plugins. ── The third report was not a bug ── "No connection to the marketplace" is the catalogue working correctly: all three plugins are already on disk here, and `router.ts` drops catalogue entries whose plugin is installed, because offering to install something installed is worse than showing nothing. With nothing left to offer, a working store and a broken one look identical on that screen. Nothing in the logs, which is what a reachable store looks like — the client only speaks when it cannot reach one. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba20155a09 |
the store's README shows in the platform, not just on the website
A plugin's page in the Marketplace screen now renders the README the marketplace holds — the same text the public site shows, in the pane where somebody actually decides whether to install it. Fetched by the SERVER and proxied, though CORS would have allowed the browser to ask directly. An Officer client is routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The server already had to reach the marketplace for the catalogue, so routing through it means the README appears in exactly the cases the plugin list did — rather than in a strictly smaller set, which is the kind of difference nobody reports and everybody notices. Separate request from the list, because the list endpoint deliberately carries no READMEs: three of them is ~38KB of markdown on the request that draws the tiles. This one fires when a plugin is opened. Absence renders nothing at all — no error, no empty state. A plugin without a README is normal and a store that is down must not put a failure message on a page whose job is installing things. Under `/plugins` so the existing permission covers it: reading the store and installing from it are the same privilege, and a second key would be a second thing to grant for no separate decision. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b65b83f1f |
the plugins screen becomes /marketplace
`/plugins` redirects rather than 404s: the word is in the dock, in the docs and in whatever anyone bookmarked, and a route that worked yesterday returning nothing reads as the feature having been removed. The API keeps its name — only the screen moved — so the permission entry lists both paths, because a client-side redirect is still a route that has to resolve before it can send anyone anywhere. Also: the fetch stubs in marketplace.test.ts needed `as unknown as typeof fetch`. Bun's `typeof fetch` carries `preconnect`, so a bare stub is not assignable — tests passed while tsgo did not, which is the wrong way round to find out. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
921fbff9fe |
marketplace.officer.dev: the catalogue becomes a service
The plugins page read a TypeScript constant. It now reads a public API backed by its own database, and falls back to that constant when it cannot. ── Four pieces ── `src/databases/marketplace_db` — a second Postgres database, `officer_marketplace`. Two tables, split by owner and lifetime: `plugins` is the registry (app name, developer, clone URL) and is authoritative; `plugin_metadata` is a CACHE of what each repository says about itself, so the website can render without cloning three repos per page load. Keeping them apart is what makes the cache safe to be wrong — a refresh can never lose the fact that a plugin exists. `scripts/marketplace-sync.ts` — fills it from the plugin repositories. Reads each `manifest.ts` (imported, since a manifest is TypeScript), derives `has` from the directory rather than trusting a declaration, and rewrites SSH remotes to HTTPS: `example` and `offscale` had `git@gitea…` origins, which would have handed every installer a URL that only works on the machine that published it. `src/servers/marketplace/server.tsx` — its own process, its own port, two GET routes and no others. Not part of the platform server: it has no users, no sessions and no request it answers differently per caller, and mounting a public unauthenticated surface inside the process holding mail, a wallet and passkeys means writing permission-gate exemptions that later turn out to be too wide. `src/apps/marketplace` — the public site. A window, deliberately: nothing here installs anything, because installing means cloning a repo and starting a process on someone else's machine. Every plugin page ends at "install it from your own Officer". ── The platform side is the part that had to be careful ── `fetchCatalogue()` now answers live → cached → bundled, in that order, and never throws. The plugins page is where an owner manages plugins they ALREADY have, so the store being down must not take that page with it. Network responses are validated per entry — not against a hostile marketplace, which has already won if the owner installs what it lists, but against a wrong one: a proxy serving HTML, a half-deployed API, a field renamed on one side. A non-https `source` is refused outright because that string reaches `git clone`. 8 tests on the degradation path. Verified end to end against the running service: live catalogue, cached answer surviving the store dying, bundled fallback. ── One thing found on the way ── `bunfig.toml` and CLAUDE.md both document `bun install --no-frozen-lockfile` as the way to change a dependency. Bun 1.3.14 has no such flag — only `--frozen-lockfile` — so the documented escape hatch does not work. Sidestepped by not making marketplace_db a workspace member (relative imports, zero lockfile change), and `bun install` is frozen-clean again. Worth fixing the docs. tsgo clean, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23bfacf412 |
a missing plugin can no longer stop the platform booting
A server died on `Cannot find module '../../../../plugins/music/db/schema'` before a line of its own code ran. The pull that broke it was the one untracking `plugins/` — git deleted those directories from the working tree, and the gitignored `plugin-schemas.gen.ts` still named them. The barrel being wrong is NORMAL. It is written by `db:push` and by installing a plugin, and nothing rewrites it when a directory goes away by other means: a pull, `git clean -xfd`, `rm`. It describes a machine, not the project, which is why it is gitignored in the first place. The defect was not the stale file. It was that a file allowed to be wrong sat in the import graph of the process that must always start — via `db.ts` → `schema.ts` → the barrel. An import graph is resolved before `main`, so this was not catchable, loggable or survivable. Everywhere else the plugin system already holds the line: `refreshPluginMounts` isolates a bad plugin, `discoverPlugins` reports a broken manifest instead of throwing. This was the one remaining path that could take the whole server down, and it took it down. So the two consumers get their own entry points. `schema.ts` keeps the core tables and is what the runtime imports. `drizzle-schema.ts` re-exports it, adds the generated barrel, and is read only by drizzle-kit — which regenerates the barrel before every run and can afford to fail loudly on a genuinely broken plugin. `drizzle.config.ts` points at the new one. Verified by reproducing it: with the barrel naming a plugin directory that does not exist, importing the server's db module now succeeds while importing `drizzle-schema.ts` still fails with that exact error — so the runtime is immune and drizzle-kit's strictness is intact. First attempt at that test was worthless, since I "poisoned" the barrel with a path that existed here; redone with an absent one. `bun db:gen` then loaded the new entry and emitted both core and plugin tables (music_*, offscale_servers), which is what proves `db:push` still works. Pulling this is enough to boot a server in that state — the stale barrel stops being loaded rather than needing to be repaired. tsgo clean, frontend builds, 865 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
640529ccba |
offscale in the catalogue, and the comments the rename falsified
The plugin half is in gitea.officer.dev/plugins/offscale (95b84ea). This is what
the platform owed it.
- marketplace: offscale now ships assets/icon.png, so the catalogue carries an
iconUrl and a `bare` tile like music's. The lucide `icon` stays as the fallback
for a failed asset publish — a glyph beats the generic box.
- Four schema files cited `headscale_servers` as the shape they copied. The table
is `offscale_servers` as of today, so those sentences named a table that no
longer exists. Same for secret-store.ts's list of purposes.
The crypto purpose moved with it: `decryptSecret('headscale', …)` in the plugin
is now `'offscale'`, and the secret-store row was renamed rather than abandoned,
so the key material is preserved and the change is reversible. Safe only because
offscale_servers held 0 rows — one stored API key and this would have been a
re-encryption, not a rename.
Verified: offscale_servers, uq_offscale_servers_one_active and
uq_offscale_servers_user_url all exist in Postgres and nothing named headscale
does. Dropped the empty table by hand first, because drizzle-kit push treats a
rename as an interactive prompt and would have hung.
tsgo clean. 808 pass / 7 fail — +21 from offscale's first tests, same 7 failures.
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>. |
||
|
|
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. |
||
|
|
4a9f23c759 |
the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about the plugin system generally rather than about offscale, which is exactly why it belongs with the worked example — the reasoning is most useful beside the code it produced, and the platform's docs should not carry the history of something it no longer knows exists. every reference updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
585c046a64 |
the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway, which is worse than either — the whole reason for the rename was that the word already means three things here. renamed what was mine: setPluginCapabilities -> setPluginPermissions, pluginCapabilities -> pluginPermissions, and the prose throughout. what remains is the platform's own vocabulary, not the plugin system's: the type it imports, and the field on a dock manifest, which is the shape the shell already renders. renaming those is a separate change to a separate system and is the owner's to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8cc51cfb40 |
every plugin permission is grantable, and there is no field to say otherwise
offscale was missing from the permissions page because it declared ownerOnly, which mapped to kind admin, and admin capabilities are never offered for granting. correct by the old rule and wrong by the standard: every plugin follows the same platform-level permission model, appearing on the same page with the same read/write/none per role. so the field is gone rather than flipped. a plugin has no way to say owner-only, which makes the standard structural instead of remembered — the same move as the workspace rule. core, execution, confined and admin stay the platform's to assign and a plugin cannot name any of them, so the escalation question is removed rather than answered. this is the second draft of this decision to be deleted: first a full CapabilityKind with three of five values forbidden, then an ownerOnly boolean, now nothing. the doc records all three so the reasoning is visible rather than just the conclusion. finer visibility stays the plugin's job. offscale is the worked example of the gap that leaves and its manifest says so: it is grantable now, and its queries still scope by the caller, so a granted member would see their own empty server list rather than the owner's. closing that is a change inside the plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8b6cb34ae0 |
plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a plugin installed through the plugin system was reachable only by typing its url or following the link on /plugins. built at runtime rather than baked into the generated bundle, deliberately — WHO sees a tile is a permission question, and a grant takes effect on the next request rather than the next build. presentation comes from the manifest and the route from mountPrefix, so there is one source for both, and is the plugin's first permission so the endpoint can filter a tile out for an account that cannot reach the screen. two sources for tiles today, because the app store still has its own catalogue. one when it is rebuilt on this. the doc now records the system as complete rather than half-stubbed, including the three bugs the extraction found — the sidecar-before-mount ordering, the missing tailwind, and the build that could delete its own shell — and what is genuinely still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8587ae20b7 |
a plugin creates its own tables, and uninstalling never drops them
the last unwired step. install now generates a drizzle barrel of plugin schemas and runs db:push, so a plugin with db/schema.ts brings its tables with it. the barrel follows the plugin DIRECTORIES on disk, not the install table, and that difference is the entire safety property. push drops what it cannot see, so a barrel tracking installs would delete a plugin's tables the moment it was uninstalled — turning "stop running this" into "delete my data", which is the one thing the install model refuses to do. following the directory means: directory present, not installed in the barrel, tables exist unused installed in the barrel, tables in use uninstalled STILL in the barrel, every row survives directory deleted out of the barrel, a push may drop them so reinstall is a restore, and losing data requires deliberately deleting a plugin's source. proved end to end rather than argued. with offscale uninstalled and its entry removed from the barrel, db:push DROPPED headscale_servers. installing it recreated the table in 1882ms — columns, both unique indexes including the partial one that enforces a single active server, and the fk. a canary row then survived an uninstall AND a subsequent manual db:push, which reported "No changes detected". it shells out to the same `bun db:push` a human runs rather than driving drizzle-kit in-process: one definition of applying the schema instead of two that can disagree, and an owner can reproduce exactly what an install did. --force because the barrel only ever gains entries unless source is deleted, and a prompt with no terminal would hang an install rather than fail it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |