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>
This commit is contained in:
+7
-2
@@ -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<string, (req: Request) => 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: {
|
||||
|
||||
@@ -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/<app-name>/`), 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/
|
||||
|
||||
+160
-104
@@ -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<typeof createRouter> };
|
||||
|
||||
// ── 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<typeof createRouter>
|
||||
// ['/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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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 `<platform>/plugins/<app-name>/` — 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', '<platform>/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<DiscoveredPlugin> {
|
||||
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<DiscoveryResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { manifestProblems, mountPrefix, type PluginManifest } from './manifest';
|
||||
|
||||
const valid = (over: Partial<PluginManifest> = {}): 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/<publisher>/', () => {
|
||||
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<PluginManifest>);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<appName>_`. */
|
||||
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 `<mountPrefix>/*` 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/<publisher>/`, 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<PluginManifest> | 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;
|
||||
}
|
||||
Reference in New Issue
Block a user