Commit Graph
19 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 eba0e86758 a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a
plugin installed afterwards could never add a socket route. That was a hard
ceiling, not a preference: /minesweeper had to live in the platform and
plugins/desktop could not stream a display at all.

The way out was already in the tree and had one consumer. dev-proxy serves a socket
with no WS_ROUTES entry, through a WILDCARD route whose handler calls
server.upgrade itself. Nothing has to become dynamic, because a plugin name is a
path segment rather than a route — one wildcard declared at boot serves every
plugin that will ever be installed.

Authorisation is the PLUGIN's own capability, asked as if the socket were a GET
against its HTTP prefix. That is the safety argument: there is no second permission
to grant, forget, or drift, so granting a plugin grants its socket and revoking it
closes it. It is also why plugin-ws is exempt from the boot totality check rather
than carrying a registry entry — one entry covering every plugin's socket would
mean granting one plugin granted them all.

A target is a TCP address and never a URL, which keeps the relay ignorant of what
it carries. That ignorance is the only reason one relay can serve an RFB display
and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an
arbitrary host would be a request-forgery primitive handed to every plugin author.

sidecarPort() is new because createSidecarProxy learned announced ports per-proxy
and that state was unreachable from anywhere else — a second subscription would
have been a second thing to keep in step.

Verified: boots past the totality gate, and both an existing and a non-existent
plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte.
Minesweeper is about to be the first caller, which is what proves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 15:21:10 +00:00
pastilhasandClaude Opus 5 4efc2552d0 os-deps: a dependency declares who has to be able to run it
The probe asked the owner's login shell where a binary was, and the install reported that as a fact
about the machine. For a confined plugin the work happens as the member's own Linux user, in their own
home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable.

On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an
absolute path resolved. At 750, the more usual default, every member would have lost every binary with
nothing reporting it: the install would still have said satisfied, having only ever asked the owner.
Nobody chose 751, and that is the part worth fixing.

`scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a
manifest error rather than a silent fall back to the permissive one.

Shared changes three things. It is searched for on system directories only — asking the owner's shell
would find the copy in the owner's home and call it satisfied, the exact false pass this exists to
stop. Reachability is checked as world-executable AND every parent traversable to the root, because a
binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a
release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so
the download itself never runs as root.

An unreachable shared dependency gets its own state and refuses the install ahead of every other
check, because no package manager fixes it: the binary is installed and works, and what is wrong is
where it lives — reinstalling would report success and change nothing.

Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is
runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's
data is gated; its executable is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:15:38 +00:00
pastilhasandClaude Opus 5 10feb76880 permissions: a plugin can mark part of its surface owner-only
A plugin worth granting to a Member can also hold the credential the rest of it runs on. Jellyfin's
/_config stores a server's admin token and drives account provisioning; the rest of the plugin is
exactly what a Member should have. The choice was all of it or none of it.

`ownerOnly` on a plugin permission takes sub-paths relative to the mount prefix. The permission stays
fully grantable — this is not the ownerOnly PERMISSION that manifest.ts refuses, and that refusal
still stands: a permission nobody can delegate is a contradiction. This says a few paths administer
the plugin rather than use it.

Declared by the plugin, enforced by the platform. A plugin could check this itself and the first one
would; the tenth would add a route and forget, with no boot failure and no warning — the shape
assertPermissionTotality exists to prevent. In the gate it is enforced once, on the door they all
pass through, and a route added later by someone who never read the manifest is covered by default.

Checked before selfService and before kind. A path that is somehow both self-service and owner-only
is a contradiction, and the safe reading has to win. A write grant does not unlock it either — level
is not the question this answers.

Also forwards X-Officer-Owner and X-Officer-Level to a sidecar whose proxy names its permission key,
for the finer decisions the platform cannot make for it: whose rows a read returns, which fields a
level may see. Facts, never the role name — a plugin branching on "Super Admin" would give every
plugin repository an opinion about the platform's role vocabulary, and renaming a role would change
authorization in code the platform does not own. These are safe to trust only because the proxy
copies a three-item allowlist off the inbound request; that is now written where it would be changed.

The owner's level is reported as write rather than the absent 'none': they hold everything implicitly
and appear in no grants map, so the raw lookup inverts the meaning for the one account that can do
anything.

No plugin declares one yet. Jellyfin cannot until connections stop being per-user — locking /_config
today leaves a Member with a plugin that only shows a connect form they may not submit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:56:10 +00:00
pastilhas a6e61d0c26 user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich
next — needs to know who the platform's users are. it now gets told.

DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles`
arrive on an unauthenticated socket and are believed, so a capability claimed
there would be a plugin granting itself one. the manifest was reviewed when the
owner installed it, and sits beside `permissions` where "this plugin will be
told who your users are" belongs — on the install screen, not discovered later.

PUSHED, not requested. a sidecar has no authenticated way to ask, so a request
could only be answered, never authorised. a push inverts that: the platform
decides who gets it, and a caller registering under a name with no such
manifest gets nothing.

`listProvisionableUsers` is a hand-written projection — id, email, username,
name, role — and NOT `select *`. `users` holds `password` and `os_user`, and
`getUsers()` returns both, so a plugin reaching for the obvious function would
get every account's password hash. naming the columns means adding a secret to
the table cannot silently add it to what plugins receive. pinned by a test that
asserts the key set, not just the absence of today's secrets.

the owner is excluded. they bypass every permission check and already have
whatever upstream account they want; provisioning them is at best a no-op and
at worst a second admin nobody asked for.

sent after the ack rather than inside it, so registration never waits on a
database query — a slow postgres would otherwise stall every sidecar's startup,
and none of them need the list to start.

and re-sent on every account create and delete, because the registration copy
is a snapshot: a plugin still holding a deleted user would keep an upstream
login alive for someone the platform has removed. read once per broadcast, so
two plugins cannot be told different things about the same moment.
2026-08-20 19:18:37 +00:00
pastilhas 21bedf1418 npmDependencies in the manifest, installed once for every plugin
replaces the per-plugin package.json from earlier tonight. the owner's call,
and the reason is one i had not weighed: sharp is ~50 MB of platform binaries
and the obvious dependency for anything touching images, so a second and third
plugin wanting it would have meant a second and third copy with no version they
could converge on.

so every plugin's `npmDependencies` merge into ONE generated plugins/package.json,
installed into plugins/node_modules/. bun resolves node_modules up the tree, so
that directory serves every plugin and none of the platform's own code.

ranges intersect rather than alternate. two plugins asking for ^0.33.5 and
^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved
version must satisfy both. `||` would have meant "either", which is the silent
version of this failure: one plugin quietly getting a version the other cannot
import. incompatible requirements now fail the install with bun's own message,
and both askers are named in the steps either way.

generated from the plugins ON DISK, like generatePluginSchemas, and never
edited — so a package cannot appear there without a plugin having declared it.

what this gives up, knowingly: a committed lockfile. ranges are the whole point
and a range resolves per machine, so plugin packages are resolved at install
time like osDependencies are, and unlike the platform's own. CLAUDE.md is
emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest
type rather than left to be discovered.

the generated plugins/package.json and lockfile are gitignored — /plugins/*/
only ignores directories, so files at that level needed naming explicitly.
2026-08-20 18:22:58 +00:00
pastilhasandClaude Opus 5 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>
2026-08-19 08:48:18 +00:00
pastilhasandClaude Opus 5 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>
2026-08-19 06:29:41 +01:00
pastilhasandClaude Opus 5 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>
2026-08-18 14:47:04 +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 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 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 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