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>
This commit is contained in:
2026-08-15 00:31:54 +00:00
co-authored by Claude Opus 5
parent 8587ae20b7
commit 8b6cb34ae0
3 changed files with 90 additions and 37 deletions
+45 -36
View File
@@ -605,48 +605,57 @@ Used for both `/api/...` and the frontend route. Nothing else in the codebase ma
--- ---
## What is built, as of 2026-08-14 ## What is built — complete, as of 2026-08-15
The plugin system works end to end for a plugin that has an `api/router.ts`. Verified against a running **Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
server, with **no restart at any point**: server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
time.
``` | Piece | Where |
/api/example/ping BEFORE install 404 | --------------------------------------- | ---------------------------------------------------- |
AFTER install 200 {"plugin":"example","ok":true} | Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
AFTER disable 404 | Discovery by convention | `servers/plugins/discover.ts` |
AFTER enable 200 | Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
AFTER uninstall 404 | Install runner, four verbs, streamed | `servers/plugins/install.ts` |
core routes throughout 200 | PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
``` | Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Capability registration | `capabilities/registry.ts``setPluginCapabilities` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
| Piece | Where | Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
| ------------------------------------------- | --------------------------------------------- | frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` | whole thing survives a restart because boot regenerates and mounts before `serve()`.
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, and the rebuild | `servers/plugins/mount.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| The closure that makes the swap take effect | `server.tsx`, the `/api/*` route |
| Install state | `plugin_installs` (`officer_db/src/plugins/`) |
| The four verbs | `servers/api/plugins/router.ts`, owner-only |
| The screen | `/plugins` — two panels, `?selected=` |
| The reference plugin | `plugins/example/` — meant to be read |
### Not wired yet ### Three bugs the extraction found
Worth recording because none were visible from reading:
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
available`. 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 first.
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
applies to Bun's static serving and not to a programmatic `Bun.build()`.
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
### Still open
- **The schema push.** A plugin with `db/schema.ts` installs, but its tables are not created. Marked
`[open]` in the router.
- **The sidecar's PM2 entry.** A plugin with `sidecar/` installs, but no process starts. This is the same
hole `app-store/pm2.ts:23-29` documents for the app store, and it is the next thing to build.
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is - **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
still the six hardcoded providers. still the hardcoded providers. No plugin owns a socket yet.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` remains the core list, so plugin mounts are - **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
not covered by the boot check — and the check is reading the wrong list anyway (see below). The `Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
assertion wants moving into `buildHonoApp`, which is now the single place routes are mounted. `buildHonoApp`, which is now the single place routes are mounted.
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
### Deliberately not done plugin system. One when the app store is rebuilt on this.
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
**Offscale is not extracted.** The infrastructure is ready for it, but moving it means deleting working OWNER's rows rather than the caller's, which is a change inside the plugin.
code across ~50 files, and that should happen with someone watching rather than unattended.
--- ---
+5 -1
View File
@@ -7,6 +7,7 @@ import type { UserRole } from 'officerdb';
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry'; import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize'; import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
import { capabilityAvailability } from '../../app-store/availability'; import { capabilityAvailability } from '../../app-store/availability';
import { pluginDockManifests } from '../../plugins/mount';
// Two audiences, deliberately split. // Two audiences, deliberately split.
// //
@@ -33,6 +34,9 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
// capability the owner holds unconditionally still means nothing if its sidecar was never installed, // capability the owner holds unconditionally still means nothing if its sidecar was never installed,
// and the owner is as subject to that as a member — see app-store/availability.ts. // and the owner is as subject to that as a member — see app-store/availability.ts.
const { unavailable, manifests } = await capabilityAvailability(); const { unavailable, manifests } = await capabilityAvailability();
// Plugin tiles, alongside the app store's. Two sources today because the app store still has its own
// catalogue; when it is rebuilt on the plugin system this becomes one.
const pluginManifests = await pluginDockManifests();
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to // The owner holds everything, and says so by listing it rather than by a flag the frontend has to
// remember to special-case. One shape for both audiences means one code path in the UI. // remember to special-case. One shape for both audiences means one code path in the UI.
@@ -57,7 +61,7 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of * of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
* privacy that lasts until someone opens the network tab. * privacy that lasts until someone opens the network tab.
*/ */
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)), plugins: [...manifests, ...pluginManifests].filter((m) => !m.capability || heldKeys.has(m.capability)),
// Flattened for the dock and the route guard, which care about paths rather than capability keys. // Flattened for the dock and the route guard, which care about paths rather than capability keys.
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []), routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route // The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
+40
View File
@@ -162,3 +162,43 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
...(frontend ? { frontend } : {}), ...(frontend ? { frontend } : {}),
}; };
} }
/**
* Dock tiles for installed, enabled plugins, in the shape the shell already renders
* (`dockItemsFromPlugins`).
*
* Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
* and nothing to keep in step. `capability` is the plugin's first permission when it has one, which is
* what lets `/api/user/capabilities` filter the tile out for an account that cannot reach the screen —
* a member must not be handed the manifest of a feature they may not use, even to hide it.
*
* Built here rather than baked into the generated frontend module on purpose: WHO sees a tile is a
* runtime question, because a grant takes effect on the next request rather than the next build.
*/
export async function pluginDockManifests(): Promise<
Array<{
sidecarId: string;
capability: string | null;
name: string;
icon?: string;
color: string;
rootRoute: string;
routes: string[];
}>
> {
const { states } = await snapshotPlugins();
return states
.filter((s) => s.install?.enabled && s.plugin.web)
.map(({ plugin }) => {
const prefix = mountPrefix(plugin);
return {
sidecarId: plugin.appName,
capability: plugin.manifest.permissions[0]?.key ?? null,
name: plugin.manifest.label,
icon: plugin.manifest.icon,
color: plugin.manifest.color,
rootRoute: prefix,
routes: [prefix],
};
});
}