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:
@@ -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