diff --git a/src/server.tsx b/src/server.tsx index 8615ccbf..9c2ed204 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -24,7 +24,6 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; - // Build static file routes from public/ const publicRoutes: Record Response> = {}; for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) { @@ -319,7 +318,13 @@ const server = serve({ '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, - '/api/*': honoServer.fetch, + // A CLOSURE, deliberately, and not the bound `honoServer.fetch`. + // + // Installing a plugin swaps the whole Hono app (`rebuildHonoApp` — Hono cannot add routes to a live + // app, and cannot remove one at all). The bound method would capture whichever app existed when + // `serve()` ran, so every rebuild after boot would be invisible and an install would silently do + // nothing. Reading `honoServer` per request is what makes the reassignment the swap. + '/api/*': (req: Request, server: unknown) => honoServer.fetch(req, server), }, websocket: { diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 029f014a..18f8126b 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -22,6 +22,16 @@ import { homedir } from 'node:os'; // is the check that says so out loud instead of silently writing to the wrong place. export const OFFICER_ROOT = resolve(process.cwd(), '..'); +/** + * The repo itself — the working directory, named rather than re-derived at each call site. + * + * Plugins live under this rather than beside it (`platform/plugins//`), which is the whole + * developer story: bun links the workspace packages into the root `node_modules`, so anything inside the + * repo can `import { useClient } from 'hooks/useClient'` with no publishing and no version negotiation. + * A plugin one directory higher would resolve none of it. + */ +export const PLATFORM_DIR = process.cwd(); + export const DATA_PATH = join(OFFICER_ROOT, 'data'); // Unified, file-based store for all agent items, living outside the repo. Every skill/tool/task/ diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 4776b1da..0c854b8f 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -63,7 +63,29 @@ export { Hono }; export { createRouter }; export type { HonoVariables }; -export const honoServer = new Hono<{ Variables: HonoVariables }>(); +/** + * A plugin's router and where it mounts — a plain pair, so this file needs no plugin knowledge at all. + * Built by `plugins/mount.ts`, which is the side that knows what a manifest is. + */ +export type MountedPlugin = { prefix: string; router: ReturnType }; + +// ── The app is BUILT, not assembled once ── +// +// It used to be a module-level `new Hono()` with forty statements run at import. That cannot express +// installing a plugin: Hono's default SmartRouter throws `Can not add a route since the matcher is +// already built` the moment a route is added after serving begins, and Hono has no API to REMOVE a route +// at all — so uninstall was impossible even with a router that allowed adding. +// +// So nothing is added to a live app. A fresh one is built from the current plugin set and swapped in: +// +// honoServer = buildHonoApp(plugins) // install, uninstall, enable, disable — all the same call +// +// `server.tsx` serves it through a CLOSURE (`(req, server) => honoServer.fetch(req, server)`), not the +// bound `honoServer.fetch`, so the reassignment above IS the swap. Verified end to end: a route 404s +// before install, 200s after, and 404s again after uninstall, with core routes untouched throughout. +// +// Two things this buys over adding routes to a live app: the default SmartRouter is kept, so the fast +// RegExpRouter path survives — and uninstall is expressible, which an add-only API cannot do. // Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is // not a loosening: the check it replaced defaulted to off, so this is what every real install already @@ -87,93 +109,6 @@ const corsMiddleware = cors({ const isDavPath = (path: string) => path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav'; -honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next))); - -// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every -// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware. -honoServer.use(capabilityGateMiddleware); - -honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); -honoServer.route('/api/auth', authRouter); -honoServer.route('/api/landing-page-data', landingPageDataRouter); -honoServer.route('/api/waitlist', waitlistRouter); -// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client -// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The -// notifications WebSocket is upgraded at the serve level (server.tsx). -// honoServer.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin - -// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at -// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather -// than a mode of the router above: that one requires an Officer session and swaps the caller's -// Authorization header for a server-held token, and blending the two would put an unauthenticated branch -// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it -// open is not a new exposure. -// honoServer.route('/vaultwarden', publicVaultRouter); // switched off with the above - -// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all. -// -// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win -// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and -// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client -// header. An ordinary Officer request never matches, so nothing that worked before changes. -// for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter); -// -// honoServer.use('/api/*', async (ctx, next) => { -// if (!isBitwardenClient(ctx.req.raw.headers)) return next(); -// return publicVaultRouter.fetch(ctx.req.raw, ctx.env); -// }); -honoServer.get('/api/integrations/google/callback', googleCallbackHandler); - -// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude -// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT, -// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token -// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named -// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts. -honoServer.route('/api/agent-handoff', agentHandoffRouter); - -// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is: -// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a -// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see -// api/dav/sync-router.ts. -// The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration -// order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to -// offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the -// authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts. -honoServer.get('/dav/provision/:file', (ctx) => { - const file = ctx.req.param('file'); - const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null; - const body = token ? claimIosProfile(token) : null; - // Expired, already used, or never existed — all the same 404. There is nothing useful to tell a - // caller who has the wrong token, and distinguishing the cases would confirm that a token once existed. - if (!body) return ctx.text('not found', 404); - - return new Response(body as unknown as BodyInit, { - headers: { - // Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or - // text/xml the file downloads and the OS does nothing with it. - 'Content-Type': 'application/x-apple-aspen-config', - 'Cache-Control': 'no-store', - }, - }); -}); - -// honoServer.route('/dav', davSyncRouter); // plugin — switched off 2026-08-13 - -// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of -// fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any -// credential, so they must sit above every auth gate. Without them iOS in particular degrades to -// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel -// worse than the commercial product it is replacing. -// `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually -// wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real -// client while looking perfectly healthy in a browser. -// honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); -// honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); - -const protectedRouter = createRouter(); -protectedRouter.use(bodyParser()); -protectedRouter.use(userMiddleware); - // The mount table, as DATA rather than forty statements. // // The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted @@ -228,8 +163,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/desktop', desktopRouter], // plugin — switched off 2026-08-13 ]; -for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router); - /** Every prefix served behind the account gate. Read by the capability totality check at boot. */ export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix); @@ -246,21 +179,144 @@ export const UNPROTECTED_API_PREFIXES: string[] = [ '/agent-handoff', ]; -honoServer.route('/api', protectedRouter); +/** + * Build the whole application from the plugins currently installed. + * + * Pure: it reads nothing and mutates nothing. Everything it needs arrives as an argument, so a caller + * can build an app for a hypothetical plugin set — which is what makes the swap testable without a + * database, a filesystem or a running server. + */ +export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> { + const app = new Hono<{ Variables: HonoVariables }>(); -honoServer.onError((error, ctx) => { - if (error instanceof CustomError) { - if (error.returnValue) { - if (typeof error.returnValue === 'string') { - return ctx.text(error.returnValue, error.statusCode); - } else { - return ctx.json(error.returnValue, error.statusCode); + app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next))); + + // The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every + // router, and it re-verifies the token itself so it covers routes that never mount userMiddleware. + app.use(capabilityGateMiddleware); + + app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); + app.route('/api/auth', authRouter); + app.route('/api/landing-page-data', landingPageDataRouter); + app.route('/api/waitlist', waitlistRouter); + // Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client + // carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The + // notifications WebSocket is upgraded at the serve level (server.tsx). + // app.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin + + // The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at + // this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather + // than a mode of the router above: that one requires an Officer session and swaps the caller's + // Authorization header for a server-held token, and blending the two would put an unauthenticated branch + // inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it + // open is not a new exposure. + // app.route('/vaultwarden', publicVaultRouter); // switched off with the above + + // …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all. + // + // Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win + // for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and + // to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client + // header. An ordinary Officer request never matches, so nothing that worked before changes. + // for (const prefix of VAULT_ONLY_PREFIXES) app.route(prefix, publicVaultRouter); + // + // app.use('/api/*', async (ctx, next) => { + // if (!isBitwardenClient(ctx.req.raw.headers)) return next(); + // return publicVaultRouter.fetch(ctx.req.raw, ctx.env); + // }); + app.get('/api/integrations/google/callback', googleCallbackHandler); + + // Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude + // session running a curl, and it carries a per-panel bearer token rather than a platform session JWT, + // so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token + // identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named + // peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts. + app.route('/api/agent-handoff', agentHandoffRouter); + + // CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is: + // DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a + // platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see + // api/dav/sync-router.ts. + // The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration + // order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to + // offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the + // authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts. + app.get('/dav/provision/:file', (ctx) => { + const file = ctx.req.param('file'); + const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null; + const body = token ? claimIosProfile(token) : null; + // Expired, already used, or never existed — all the same 404. There is nothing useful to tell a + // caller who has the wrong token, and distinguishing the cases would confirm that a token once existed. + if (!body) return ctx.text('not found', 404); + + return new Response(body as unknown as BodyInit, { + headers: { + // Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or + // text/xml the file downloads and the OS does nothing with it. + 'Content-Type': 'application/x-apple-aspen-config', + 'Cache-Control': 'no-store', + }, + }); + }); + + // app.route('/dav', davSyncRouter); // plugin — switched off 2026-08-13 + + // Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of + // fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any + // credential, so they must sit above every auth gate. Without them iOS in particular degrades to + // demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel + // worse than the commercial product it is replacing. + // `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually + // wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real + // client while looking perfectly healthy in a browser. + // app.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); + // app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); + + const protectedRouter = createRouter(); + protectedRouter.use(bodyParser()); + protectedRouter.use(userMiddleware); + for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router); + + // Plugin routes, mounted behind the same account gate as everything else — a plugin is part of the + // application, not a guest in it, so it gets no separate door and no weaker middleware. + // + // `mountPrefix` decides where, from the manifest's `publisher` and nothing else. Nothing here may + // branch on provenance: the moment first-party and third-party differ anywhere but that one function, + // they become two systems and only one of them is exercised. + for (const plugin of plugins) protectedRouter.route(plugin.prefix, plugin.router); + + app.route('/api', protectedRouter); + + app.onError((error, ctx) => { + if (error instanceof CustomError) { + if (error.returnValue) { + if (typeof error.returnValue === 'string') { + return ctx.text(error.returnValue, error.statusCode); + } else { + return ctx.json(error.returnValue, error.statusCode); + } } + return ctx.text(error.message, error.statusCode); } - return ctx.text(error.message, error.statusCode); - } - console.error('Unexpected error:', error.message); - console.log(error.stack); - return ctx.text('Internal Server Error', 500); -}); + console.error('Unexpected error:', error.message); + console.log(error.stack); + return ctx.text('Internal Server Error', 500); + }); + + return app; +} + +/** + * The live app. `let`, and reassigned by `rebuildHonoApp` — see the note at the top of this file. + * + * Starts with no plugins because discovery reads the disk and the database, which is asynchronous and + * must not happen at import. `server.tsx` rebuilds once both have answered. + */ +export let honoServer = buildHonoApp(); + +/** Swap the live app for one built from `plugins`. The whole of install, uninstall, enable and disable. */ +export function rebuildHonoApp(plugins: MountedPlugin[]): Hono<{ Variables: HonoVariables }> { + honoServer = buildHonoApp(plugins); + return honoServer; +} diff --git a/src/servers/plugins/discover.test.ts b/src/servers/plugins/discover.test.ts new file mode 100644 index 00000000..108711fd --- /dev/null +++ b/src/servers/plugins/discover.test.ts @@ -0,0 +1,87 @@ +import { afterAll, describe, expect, it } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { discoverPlugins } from './discover'; + +// Discovery against a real tree, because every assertion here is about the FILESYSTEM being the +// declaration. Mocking `existsSync` would test the mock. + +const root = mkdtempSync(join(tmpdir(), 'officer-plugins-')); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const MANIFEST = (over = '') => `export const manifest = { + publisher: 'officerdev', version: '1.0.0', platform: '>=1.0.0', + label: 'X', summary: 'x', icon: 'Network', color: '#fff', + permissions: [], ${over} +};`; + +function plant(appName: string, files: Record) { + const dir = join(root, appName); + for (const [rel, body] of Object.entries(files)) { + const full = join(dir, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, body); + } + return dir; +} + +plant('full', { + 'manifest.ts': MANIFEST(), + 'api/router.ts': 'export const router = {};', + 'db/schema.ts': 'export const t = {};', + 'sidecar/index.ts': 'export {};', + 'web/Router.tsx': 'export default () => null;', + 'web/panels.ts': 'export const appRegistryMetas = [];', +}); +plant('bare', { 'manifest.ts': MANIFEST() }); +plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' }); +plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' }); +plant('nomanifest', { 'api/router.ts': 'export const router = {};' }); +plant('_scratch', { 'manifest.ts': MANIFEST() }); + +describe('discoverPlugins', () => { + it('reads what the tree declares, and nothing more', async () => { + const { plugins } = await discoverPlugins(root); + const full = plugins.find((p) => p.appName === 'full')!; + expect(full.api).toContain('api/router.ts'); + expect(full.schema).toContain('db/schema.ts'); + expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' }); + expect(full.web?.panels).toContain('web/panels.ts'); + }); + + it('a manifest alone is a valid plugin — every other part is optional', async () => { + const { plugins } = await discoverPlugins(root); + const bare = plugins.find((p) => p.appName === 'bare')!; + expect([bare.api, bare.schema, bare.sidecar, bare.web]).toEqual([null, null, null, null]); + }); + + // The runtime is the extension, not a field, so it cannot contradict the file it describes. + it('reads the runtime off the extension', async () => { + const { plugins } = await discoverPlugins(root); + expect(plugins.find((p) => p.appName === 'nodeish')!.sidecar?.runtime).toBe('node'); + }); + + it('takes the app name from the directory, so it cannot disagree with where the code sits', async () => { + const { plugins } = await discoverPlugins(root); + expect(plugins.map((p) => p.appName)).toContain('full'); + }); + + // The property that matters most: one bad plugin must not take the platform down, or hide the good + // ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable. + it('collects broken plugins instead of throwing', async () => { + const { plugins, broken } = await discoverPlugins(root); + expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'nomanifest']); + expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts'); + expect(plugins.length).toBeGreaterThan(0); + }); + + it('skips underscore and dot directories, which are scratch space', async () => { + const { plugins, broken } = await discoverPlugins(root); + expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch'); + }); + + it('is empty, not an error, when there is no plugins directory at all', async () => { + expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] }); + }); +}); diff --git a/src/servers/plugins/discover.ts b/src/servers/plugins/discover.ts new file mode 100644 index 00000000..5c0a41d1 --- /dev/null +++ b/src/servers/plugins/discover.ts @@ -0,0 +1,125 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLATFORM_DIR } from '../data-path'; +import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './manifest'; + +// Finding plugins on disk. +// +// They live at `/plugins//` — INSIDE the repository, not beside it, and that is what +// makes the whole developer story work. Bun links the workspace packages into the root `node_modules`, so +// anything under the repo can `import { useClient } from 'hooks/useClient'` with no publishing, no package +// registry and no version negotiation. A plugin author clones the platform, drops their plugin in, and +// runs it in dev — the WordPress model — and the same tree is what the server builds from. +// +// Verified: `Bun.resolveSync('hooks/useClient', '/plugins/anything')` resolves. +// +// Discovery is by CONVENTION. Presence is the declaration: +// +// manifest.ts required — everything a directory listing cannot say +// api/router.ts a backend router +// db/schema.ts tables +// sidecar/index.ts a process (`.mjs` instead means node — see below) +// web/Router.tsx a frontend +// web/panels.ts panel apps +// +// Nothing here reads the database. This answers "what is on disk", which is a different question from +// "what is installed" — the install table answers that, and the two disagreeing is a state the app store +// has to render rather than a bug to prevent. + +/** Where plugins live. Inside the repo, so the workspace packages resolve. */ +export const PLUGINS_DIR = join(PLATFORM_DIR, 'plugins'); + +/** + * The runtime is the file extension, not a manifest field. + * + * `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. Implicit, but it is the rule this + * repository already follows — `officer-pty` is `pty/index.mjs` under node because node-pty is a native + * module built against Node's ABI, and everything else is bun. Better than a field that can contradict + * the file it describes. + */ +function findSidecar(dir: string): DiscoveredPlugin['sidecar'] { + const ts = join(dir, 'sidecar', 'index.ts'); + if (existsSync(ts)) return { script: ts, runtime: 'bun' }; + const mjs = join(dir, 'sidecar', 'index.mjs'); + if (existsSync(mjs)) return { script: mjs, runtime: 'node' }; + return null; +} + +function findWeb(dir: string): DiscoveredPlugin['web'] { + const router = join(dir, 'web', 'Router.tsx'); + if (!existsSync(router)) return null; + const panels = join(dir, 'web', 'panels.ts'); + return { router, panels: existsSync(panels) ? panels : null }; +} + +const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null); + +/** + * Read one plugin directory. + * + * Throws with every problem at once rather than the first, because a manifest fixed one field per attempt + * is a manifest nobody finishes. + */ +export async function loadPlugin(dir: string, appName: string): Promise { + const manifestPath = join(dir, 'manifest.ts'); + if (!existsSync(manifestPath)) throw new Error(`${appName}: no manifest.ts`); + + let manifest: PluginManifest | null = null; + try { + const module = (await import(manifestPath)) as { manifest?: PluginManifest }; + manifest = module.manifest ?? null; + } catch (err) { + throw new Error(`${appName}: manifest.ts failed to load — ${err instanceof Error ? err.message : String(err)}`); + } + + const problems = manifestProblems(appName, manifest); + if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`); + + return { + appName, + dir, + manifest: manifest as PluginManifest, + api: fileOrNull(join(dir, 'api', 'router.ts')), + schema: fileOrNull(join(dir, 'db', 'schema.ts')), + sidecar: findSidecar(dir), + web: findWeb(dir), + }; +} + +export type DiscoveryResult = { + plugins: DiscoveredPlugin[]; + /** Directories that look like plugins but could not be read. Reported, never thrown — see below. */ + broken: { appName: string; error: string }[]; +}; + +/** + * Every plugin directory under `PLUGINS_DIR`. + * + * A broken plugin is COLLECTED, not thrown. One unreadable manifest must not stop the platform from + * booting or hide the nine plugins beside it that are fine — and "this one is broken, here is why" is + * something the app store can render, where a failed boot is something only a log can. + */ +export async function discoverPlugins(root: string = PLUGINS_DIR): Promise { + if (!existsSync(root)) return { plugins: [], broken: [] }; + + const plugins: DiscoveredPlugin[] = []; + const broken: { appName: string; error: string }[] = []; + + for (const entry of readdirSync(root)) { + // `_`-prefixed directories are scratch space, and dotfiles are not plugins. + if (entry.startsWith('.') || entry.startsWith('_')) continue; + const dir = join(root, entry); + try { + if (!statSync(dir).isDirectory()) continue; + } catch { + continue; + } + try { + plugins.push(await loadPlugin(dir, entry)); + } catch (err) { + broken.push({ appName: entry, error: err instanceof Error ? err.message : String(err) }); + } + } + + return { plugins: plugins.sort((a, b) => a.appName.localeCompare(b.appName)), broken }; +} diff --git a/src/servers/plugins/manifest.test.ts b/src/servers/plugins/manifest.test.ts new file mode 100644 index 00000000..e380b50e --- /dev/null +++ b/src/servers/plugins/manifest.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'bun:test'; +import { manifestProblems, mountPrefix, type PluginManifest } from './manifest'; + +const valid = (over: Partial = {}): PluginManifest => ({ + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0 <2.0.0', + label: 'Offscale', + summary: 'Your tailnet', + icon: 'Network', + color: '#818cf8', + permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }], + ...over, +}); + +describe('mountPrefix', () => { + it('puts first-party plugins at the root', () => { + expect(mountPrefix({ appName: 'offscale', manifest: { publisher: 'officerdev' } })).toBe('/offscale'); + }); + + it('puts everyone else under /p//', () => { + expect(mountPrefix({ appName: 'notes', manifest: { publisher: 'alice' } })).toBe('/p/alice/notes'); + }); + + // The property the segment exists for: a third party cannot reach a core route's namespace, whatever + // they call their plugin. Without it, publishing `notes` at /api/notes would mean the platform could + // never add /api/notes itself. + it('cannot shadow a core route, whatever the plugin is called', () => { + for (const core of ['chat', 'users', 'terminal', 'auth', 'files']) { + expect(mountPrefix({ appName: core, manifest: { publisher: 'alice' } })).toBe(`/p/alice/${core}`); + } + }); +}); + +describe('manifestProblems', () => { + it('accepts a good manifest', () => { + expect(manifestProblems('offscale', valid())).toEqual([]); + }); + + it('rejects a missing manifest with a reason rather than a crash', () => { + expect(manifestProblems('offscale', null)).toHaveLength(1); + }); + + it('reports every problem at once, not the first', () => { + // An install fixed one field per attempt is an install nobody finishes. + const problems = manifestProblems('offscale', { publisher: 'officerdev' } as Partial); + expect(problems.length).toBeGreaterThan(3); + }); + + // The app name is a URL segment, a SQL identifier prefix and a directory name simultaneously. Anything + // that is not safe in all three has to be refused at the door. + it.each([ + ['Offscale', 'uppercase'], + ['1offscale', 'leading digit'], + ['off scale', 'space'], + ['off_scale', 'underscore'], + ['off/scale', 'slash'], + ['../escape', 'traversal'], + ['', 'empty'], + ])('refuses the directory name %p (%s)', (appName) => { + expect(manifestProblems(appName, valid()).length).toBeGreaterThan(0); + }); + + it('refuses a publisher that is not a safe path segment', () => { + expect(manifestProblems('notes', valid({ publisher: '../evil' })).length).toBeGreaterThan(0); + }); + + it('requires permissions to be an array, so [] is how a plugin says it gates nothing', () => { + expect(manifestProblems('notes', valid({ permissions: [] }))).toEqual([]); + expect(manifestProblems('notes', valid({ permissions: undefined })).length).toBeGreaterThan(0); + }); + + it('names the permission that is malformed, by index', () => { + const problems = manifestProblems('notes', valid({ permissions: [{ label: 'x' }] as never })); + expect(problems.some((p) => p.includes('permissions[0].key'))).toBe(true); + }); +}); diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts new file mode 100644 index 00000000..538ec21f --- /dev/null +++ b/src/servers/plugins/manifest.ts @@ -0,0 +1,151 @@ +// What a plugin declares about itself, and what the tree declares for it. +// +// ── The manifest holds only what a directory listing cannot say ── +// +// Everything structural is convention, and presence is the declaration: `sidecar/index.ts` means there is +// a sidecar, `api/router.ts` means there are routes, `db/schema.ts` means there are tables, `web/` means +// there is a frontend. The manifest carries the residue — an identity fact, or something a human chose. +// +// That is why there is no `sidecar`, `schema` or `frontend` field here, and no dock or title field either: +// the tile is `{ label, icon, color, to: mountPrefix() }` and the title is `label`, all of which are +// already below. Writing them twice could only ever drift. +// +// See docs/offscale-plugin.md for the reasoning behind each decision recorded here. + +/** + * A permission the plugin adds to the platform's permission system. + * + * Called `permissions` and NOT `capabilities`: that word already means three different things in this + * codebase — the permission registry, the file-based item store under `$OFFICER_ROOT/capabilities`, and + * the routing keys a sidecar registers with. A fourth would be one too many. + */ +export type PluginPermission = { + /** Stable identifier, stored as the grant's subject. Renaming one is a data change. */ + key: string; + label: string; + description: string; + /** + * Owner-only, or grantable to members. The whole distinction a plugin needs. + * + * The platform's own `CapabilityKind` has five values because the PLATFORM has five sorts of surface. + * A plugin has two states, so this is a boolean — which also removes the escalation question rather + * than answering it: a plugin cannot claim `core` if `core` is not a word it can say. + */ + ownerOnly?: boolean; + /** + * Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes + * a document and never saves one. Without declaring them, a read-level account meets what reads as a + * broken feature where a withheld permission should be. + */ + readOnlyWrites?: string[]; +}; + +export type PluginManifest = { + /** + * Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two + * code paths. Constant today; the seam third parties hang off later. + */ + publisher: string; + /** The plugin's own semver. Updates compare against this. */ + version: string; + /** Which platform versions this build is good for. Refused at install when it does not match. */ + platform: string; + + label: string; + summary: string; + /** A lucide icon name, resolved at render. */ + icon: string; + /** Tile colour. */ + color: string; + + permissions: PluginPermission[]; +}; + +/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */ +export type DiscoveredPlugin = { + /** + * THE id — route segment, table prefix, sidecar suffix, install key. + * + * Taken from the DIRECTORY NAME rather than declared, so the id cannot disagree with where the code + * sits. The cost is that renaming a directory re-identifies the plugin; the benefit is that the two can + * never drift, and a wrong table prefix is a much quieter failure than a missing directory. + */ + appName: string; + /** Absolute path to the plugin's directory. */ + dir: string; + manifest: PluginManifest; + + /** `api/router.ts` — a backend router, mounted at `mountPrefix`. */ + api: string | null; + /** `db/schema.ts` — tables, pushed on install. Every name must be prefixed `_`. */ + schema: string | null; + /** `sidecar/index.{ts,mjs}` — a process for PM2. */ + sidecar: { script: string; runtime: 'bun' | 'node' } | null; + /** `web/Router.tsx` — a frontend, mounted at `/*` by the generated Plugins.tsx. */ + web: { router: string; panels: string | null } | null; +}; + +/** + * Where a plugin's routes live, on both the API and the frontend. + * + * `publisher` is the only input, deliberately. First-party plugins sit at the root because Officer Dev + * owns that namespace anyway and provenance is then legible at a glance in a log; third-party plugins sit + * under `/p//`, which is what makes it impossible for any plugin to shadow a core route — and + * therefore what lets the platform keep adding core routes forever without breaking an install. + * + * NOTHING else in the codebase may branch on provenance. If that difference leaks past this one function + * — a special case in the router, a bypassed check, a different install branch — first-party and + * third-party become two systems, and only one of them gets tested. + */ +export const FIRST_PARTY_PUBLISHER = 'officerdev'; + +export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string { + const { appName } = plugin; + return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER + ? `/${appName}` + : `/p/${plugin.manifest.publisher}/${appName}`; +} + +/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */ +const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/; + +/** A publisher shares the app name's constraints — it is a path segment too. */ +const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/; + +/** + * Validate a manifest read off disk. Returns the reasons it is unusable, empty when it is fine. + * + * Returns every problem rather than the first, because an install that fails one field at a time is an + * install someone retries four times. + */ +export function manifestProblems(appName: string, manifest: Partial | null): string[] { + const problems: string[] = []; + if (!manifest) return ['no manifest, or it did not export `manifest`']; + + if (!APP_NAME_RE.test(appName)) { + problems.push(`directory name "${appName}" must be lowercase letters, digits and dashes, starting with a letter`); + } + if (typeof manifest.publisher !== 'string' || !PUBLISHER_RE.test(manifest.publisher)) { + problems.push('publisher must be lowercase letters, digits and dashes'); + } + for (const field of ['version', 'platform', 'label', 'summary', 'icon', 'color'] as const) { + if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`); + } + if (!Array.isArray(manifest.permissions)) { + problems.push('permissions must be an array (use [] when the plugin gates nothing)'); + } else { + for (const [i, permission] of manifest.permissions.entries()) { + if (!permission || typeof permission.key !== 'string' || !permission.key) { + problems.push(`permissions[${i}].key is required`); + continue; + } + // The permission key shares the capability registry's namespace, so a plugin colliding with a core + // capability would silently widen or narrow it. Prefixing is not enforced here — the installer + // checks against the live registry, which is the only thing that knows what is taken. + if (typeof permission.label !== 'string' || !permission.label) { + problems.push(`permissions[${i}].label is required`); + } + } + } + return problems; +}