Commit Graph
42 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 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>
2026-08-18 13:43:32 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 12:47:34 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 09:23:25 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 03:19:48 +00:00
pastilhas e88e3a70cf email in the bundled catalogue, the offline floor under the store 2026-08-18 00:45:12 +00:00
pastilhas 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.
2026-08-17 13:24:27 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 23:36:49 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 10:56:26 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 10:33:25 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 10:17:25 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 10:15:38 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 10:01:07 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 09:46:16 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 09:20:11 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 04:18:40 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 04:10:23 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 04:07:49 +00:00
pastilhasandClaude Opus 5 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>
2026-08-16 02:57:48 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 18:42:06 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 18:21:51 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 18:08:05 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 17:56:18 +00:00
pastilhas 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.
2026-08-15 16:31:11 +00:00
pastilhas 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".
2026-08-15 16:03:22 +00:00
pastilhas 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.
2026-08-15 15:21:25 +00:00
pastilhas 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>.
2026-08-15 15:10:17 +00:00
pastilhas 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.
2026-08-15 02:33:18 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 01:07:48 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 00:47:18 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 00:42:35 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 00:31:54 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 00:21:11 +00:00
pastilhasandClaude Opus 5 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>
2026-08-15 00:15:38 +00:00
pastilhasandClaude Opus 5 543e88a9a6 every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:47:48 +00:00
pastilhasandClaude Opus 5 2e3c935da6 the built SPA had no tailwind, and the build could destroy itself
three fixes to last night's build switch, all found by using it.

THE CSS. bunfig.toml declares the tailwind plugin under [serve.static], which
applies to bun's static SERVING — the html-import path the app used until
yesterday — and not to a programmatic Bun.build(). so the first build emitted
the xterm css and no tailwind at all: layout intact, every utility class
missing. a plugin list is not inherited from bunfig; it has to be passed. css
goes 110KB to 278KB, 1127 --tw- variables, .flex present, --color-duck present.

THE BUILD DIRECTORY. clearing it before building was meant to stop 20MB of
content-hashed chunks accumulating per install, and instead meant a FAILED build
left nothing — the exact opposite of the promise in the comment directly above
it. it was also a race: two builds overlapping had one process's rm delete the
other's shell, leaving js and css with no html and a 503 that read as a build
failure when the build had succeeded.

now it builds into build.next/ and swaps only a complete, successful build into
place, and refuses to swap one that produced no shell at all — a build can
report success and emit no html, and serving that is worse than serving the
previous one.

AND IT SAYS WHICH PATH IS SERVING. "is it serving the build I just made, or the
one bundled at import?" was answerable only by hiding the shell and watching for
a 503, which is how it got answered once. the distinction matters precisely
where it is hardest to see: the html import is fixed when the module graph
loads, so an install would rebuild build/ and serve something else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:26:28 +00:00
pastilhasandClaude Opus 5 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>
2026-08-14 23:07:18 +00:00
pastilhasandClaude Opus 5 a00116b2c0 a plugin's permissions become real capabilities, and survive a restart
two gaps between "a plugin can mount routes" and "a plugin is part of the
platform". both closed.

FIRST: nothing registered a plugin's declared permissions, so the capability
gate could not resolve a plugin path at all. it resolved to null, and null is
denied — the owner never noticed because isSuperAdmin short-circuits every
check, which is exactly the shape of bug that reaches a member first.

the registry is now rebuildable the same way the hono app is: CORE_REGISTRY
holds the platform's own, CAPABILITIES is core plus whatever the installed
plugins declare, and setPluginCapabilities replaces the plugin half wholesale
rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_-
CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can
never put itself in the fresh-install baseline and can never become `core`
(every account, undeniable). a key colliding with a core one is refused and
logged, because a plugin able to redefine `chat` could widen it.

ownerOnly maps to admin, everything else to app. those are the only kinds a
manifest can express, and it has no field for a kind at all.

capabilities are registered BEFORE routes are mounted: the gate runs ahead of
every router, so mounting a route whose permission is not yet registered would
403 the freshly installed plugin until something else happened to refresh.

SECOND: nothing mounted plugins at boot. honoServer is built with none at
import, because discovery reads disk and database and neither can be awaited at
module scope, and every install verb rebuilt — so it tested perfectly and would
have silently unmounted everything on the first restart.

server.tsx now refreshes before serve(), so there is no window where an
installed plugin 404s, and a plugin that will not load is logged rather than
fatal.

verified: after a restart, [plugins] mounted /example, the row survived,
officer-example came back online from its ecosystem entry, capabilityForApiPath
resolves /api/example/ping to the example capability at kind=app, and it appears
in the owner's grantable list. 756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:43:49 +00:00
pastilhasandClaude Opus 5 62ee0d1e60 the beat between steps is feedback, not decoration
the comment called it cosmetic and worth being honest about, which reads like an
apology and invites the next reader to delete it as a pointless sleep.

the real reason is better. some of this work is genuinely slow — pm2 start
measures ~770ms — and some is effectively instant. without a pause the fast
steps land in one frame, the log jumps from empty to finished, and you cannot
tell 'it worked' from 'nothing happened'. the interval is what makes a step
something you saw happen rather than something you found already done.

only applied when something is listening, so the json path still runs flat out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:33:53 +00:00
pastilhasandClaude Opus 5 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>
2026-08-14 21:50:50 +00:00
pastilhasandClaude Opus 5 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>
2026-08-14 21:38:21 +00:00
pastilhasandClaude Opus 5 282a64a637 plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:22:18 +00:00
pastilhasandClaude Opus 5 2e6c263751 the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.

hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.

so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.

buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.

alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.

discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.

a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.

verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:11 +00:00