diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md index f31d8713..a21ee42b 100644 --- a/docs/offscale-plugin.md +++ b/docs/offscale-plugin.md @@ -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**: +**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. -``` -/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 -``` +| Piece | Where | +| --------------------------------------- | ---------------------------------------------------- | +| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` | +| Discovery by convention | `servers/plugins/discover.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` | +| 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 | -| ------------------------------------------- | --------------------------------------------- | -| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` | -| 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 | +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()`. -### 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 + `: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. --- diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts index 8734a3e5..0d05af10 100644 --- a/src/servers/api/users/capabilities-routes.ts +++ b/src/servers/api/users/capabilities-routes.ts @@ -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 diff --git a/src/servers/plugins/mount.ts b/src/servers/plugins/mount.ts index e807ad61..7ba5d6bd 100644 --- a/src/servers/plugins/mount.ts +++ b/src/servers/plugins/mount.ts @@ -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], + }; + }); +}