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:
+43
-34
@@ -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
|
||||
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 routes throughout 200
|
||||
```
|
||||
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||
time.
|
||||
|
||||
| Piece | Where |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||
| Disk ⋈ database, and the rebuild | `servers/plugins/mount.ts` |
|
||||
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Not wired yet
|
||||
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
|
||||
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||
|
||||
### 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
|
||||
still the six hardcoded providers.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` remains the core list, so plugin mounts are
|
||||
not covered by the boot check — and the check is reading the wrong list anyway (see below). The
|
||||
assertion wants moving into `buildHonoApp`, which is now the single place routes are mounted.
|
||||
|
||||
### Deliberately not done
|
||||
|
||||
**Offscale is not extracted.** The infrastructure is ready for it, but moving it means deleting working
|
||||
code across ~50 files, and that should happen with someone watching rather than unattended.
|
||||
still the hardcoded providers. No plugin owns a socket yet.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||
`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
|
||||
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
|
||||
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { UserRole } from 'officerdb';
|
||||
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
||||
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
||||
import { capabilityAvailability } from '../../app-store/availability';
|
||||
import { pluginDockManifests } from '../../plugins/mount';
|
||||
|
||||
// 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,
|
||||
// and the owner is as subject to that as a member — see app-store/availability.ts.
|
||||
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
|
||||
// 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
|
||||
* 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.
|
||||
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
|
||||
|
||||
@@ -162,3 +162,43 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
|
||||
...(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],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user