diff --git a/plugins/example/api/router.ts b/plugins/example/api/router.ts new file mode 100644 index 00000000..9a7dfded --- /dev/null +++ b/plugins/example/api/router.ts @@ -0,0 +1,10 @@ +import { createRouter } from '@@/create-router'; + +// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's +// `publisher`. Nothing here knows or cares whether this plugin is first-party. +// +// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so +// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes. +export const router = createRouter(); + +router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true })); diff --git a/plugins/example/manifest.ts b/plugins/example/manifest.ts new file mode 100644 index 00000000..06318de9 --- /dev/null +++ b/plugins/example/manifest.ts @@ -0,0 +1,29 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately +// the smallest thing that is still a real one: a manifest and one route. +// +// Everything structural is convention, so this directory IS the documentation: +// +// manifest.ts you are here — only what a directory listing cannot say +// api/router.ts exports `router`; mounted at /api/example +// db/schema.ts tables, if it had any (every name prefixed `example_`) +// sidecar/index.ts a process, if it needed one (.mjs instead means node) +// web/Router.tsx a frontend, if it had one +// +// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the +// code sits. +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'Example', + summary: 'The reference plugin — one route, nothing else', + icon: 'Puzzle', + color: '#94a3b8', + + // Empty is meaningful: this plugin gates nothing of its own and is reachable by anyone who can reach + // the platform. A plugin with a surface worth protecting declares a permission here instead. + permissions: [], +}; diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index c64823b4..4253c236 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -29,6 +29,7 @@ export * as schema from './schema'; export * from './agent-panels'; export * from './api-keys'; export * from './app-store'; +export * from './plugins'; export * from './auth'; export * from './capabilities'; export * from './chat-events'; diff --git a/src/databases/officer_db/src/plugins/index.ts b/src/databases/officer_db/src/plugins/index.ts new file mode 100644 index 00000000..eb271635 --- /dev/null +++ b/src/databases/officer_db/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './queries'; diff --git a/src/databases/officer_db/src/plugins/queries.ts b/src/databases/officer_db/src/plugins/queries.ts new file mode 100644 index 00000000..e0db2b3c --- /dev/null +++ b/src/databases/officer_db/src/plugins/queries.ts @@ -0,0 +1,49 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { pluginInstalls } from './schema'; + +export type PluginInstall = typeof pluginInstalls.$inferSelect; + +/** Every installed plugin, oldest first so the list is stable across renders. */ +export async function listPluginInstalls(): Promise { + return db.select().from(pluginInstalls).orderBy(pluginInstalls.appName); +} + +export async function getPluginInstall(appName: string): Promise { + const [row] = await db.select().from(pluginInstalls).where(eq(pluginInstalls.appName, appName)).limit(1); + return row ?? null; +} + +/** + * Record an install, or update the version of one already there. + * + * Upsert rather than insert, because re-installing is how a plugin is upgraded: the code on disk moved, + * and the row should follow it rather than refuse. `enabled` is deliberately NOT touched on the update + * path — re-installing a plugin the owner had disabled must not silently switch it back on. + */ +export async function recordPluginInstall(appName: string, version: string): Promise { + const [row] = await db + .insert(pluginInstalls) + .values({ appName, version }) + .onConflictDoUpdate({ + target: pluginInstalls.appName, + set: { version, updatedAt: new Date() }, + }) + .returning(); + return row!; +} + +export async function setPluginEnabled(appName: string, enabled: boolean): Promise { + const [row] = await db + .update(pluginInstalls) + .set({ enabled, updatedAt: new Date() }) + .where(eq(pluginInstalls.appName, appName)) + .returning(); + return row ?? null; +} + +/** Forget the install. Drops no tables and deletes no data — see the note in schema.ts. */ +export async function removePluginInstall(appName: string): Promise { + const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning(); + return rows.length > 0; +} diff --git a/src/databases/officer_db/src/plugins/schema.ts b/src/databases/officer_db/src/plugins/schema.ts new file mode 100644 index 00000000..e013dcd0 --- /dev/null +++ b/src/databases/officer_db/src/plugins/schema.ts @@ -0,0 +1,58 @@ +import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; + +// Which plugins are installed on this machine, and whether they should be mounted. +// +// ── Why a row is needed at all, when the code is already on disk ── +// +// Plugins live in the repository (`platform/plugins//`), so PRESENCE is not installation. A +// developer working on a plugin has the directory there and has not installed anything; a plugin that +// ships in a checkout should not mount itself because someone cloned it. The directory answers "what +// could run here", this table answers "what does". +// +// ── Separate from `sidecar_installs`, deliberately ── +// +// That table belongs to the app store's model, where an install means provisioning a container or +// pointing at a remote instance, and it carries `mode`, `compose_dir` and `completed_steps` to say so. +// A plugin install has none of those: put the code there, push its schema, start its sidecar, mount its +// routes. Reusing the table would have meant a `mode` that lies about every plugin. The two models +// coexist until the app store is rebuilt on this one. +// +// ── No userId, for the same reason as `sidecar_installs` ── +// +// installed server-level, owner-only — this row +// permitted per role — role_capabilities +// configured per user — the plugin's own tables +// +// ── `enabled` is not `installed` ── +// +// Installed means the schema is pushed and the code is ready. Enabled means it should be mounted and its +// sidecar running. Disabling is the reversible middle: routes come down, the process stops, and every +// table and row it owns survives untouched. Uninstalling drops the row and unmounts, and still does not +// delete data — dropping a plugin's tables is a separate, deliberate act with the cost shown. +export const pluginInstalls = pgTable( + 'plugin_installs', + { + id: serial('id').primaryKey(), + /** + * The plugin's app name — its directory, its route segment and its table prefix, all the same string. + * Text rather than an enum: adding a plugin must never be a schema change. + */ + appName: text('app_name').notNull(), + /** + * The manifest version at the moment it was installed. + * + * Kept so an upgrade has something to compare against, and so "installed" can be told from "installed, + * then the code on disk moved underneath it" — which is the normal state on a developer's machine and + * a thing worth being able to see rather than infer. + */ + version: text('version').notNull(), + enabled: boolean('enabled').notNull().default(true), + installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // One row per plugin, machine-wide. uniqueIndex rather than unique() — see databases/CLAUDE.md on + // drizzle-kit re-creating named composite constraints on every push. + uniqueIndex('uq_plugin_installs_app_name').on(t.appName), + ], +); diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 25022f5c..68a57738 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -38,6 +38,7 @@ export * from './headscale/schema'; // headscale_servers // The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts` // reads service_connections, so this is core however few plugins are installed. export * from './app-store/schema'; // sidecar_installs +export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own export * from './service-connections/schema'; // service_connections // ── Plugins — uncomment when the plugin is installed ───────────────────────────────────────────── diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts new file mode 100644 index 00000000..4098ba70 --- /dev/null +++ b/src/servers/api/plugins/router.ts @@ -0,0 +1,119 @@ +import { createRouter } from '../../create-router'; +import * as errors from '../../custom-errors'; +import { isSuperAdmin } from '../../super-admin'; +import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb'; +import { mountPrefix } from '../../plugins/manifest'; +import { refreshPluginMounts, snapshotPlugins } from '../../plugins/mount'; + +// /api/plugins — what is on this machine, what is installed, and the four verbs that change it. +// +// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is +// an administrative act however many members share the server. The capability layer covers it too; this +// is the belt to that braces, the same shape `/api/app-store` uses. +// +// ── This is not the app store ── +// +// The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the +// user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the +// schema, mount the routes. The two coexist until the app store is rebuilt on this. + +export const pluginsRouter = createRouter(); + +pluginsRouter.use(async (ctx, next) => { + if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only'); + return next(); +}); + +/** + * GET /api/plugins — every plugin in the tree, with what the database knows about each. + * + * Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to + * show the owner, and refusing the whole list because one plugin is malformed would hide the nine that + * are fine. + */ +pluginsRouter.get('/', async (ctx) => { + const { states, broken } = await snapshotPlugins(); + return ctx.json({ + plugins: states.map(({ plugin, install, outdated }) => ({ + appName: plugin.appName, + prefix: mountPrefix(plugin), + label: plugin.manifest.label, + summary: plugin.manifest.summary, + icon: plugin.manifest.icon, + color: plugin.manifest.color, + publisher: plugin.manifest.publisher, + version: plugin.manifest.version, + platform: plugin.manifest.platform, + permissions: plugin.manifest.permissions, + // What the tree declared. The UI shows these so "installed but does nothing" is legible. + has: { + api: !!plugin.api, + schema: !!plugin.schema, + sidecar: !!plugin.sidecar, + web: !!plugin.web, + }, + installed: !!install, + enabled: install?.enabled ?? false, + installedVersion: install?.version ?? null, + outdated, + })), + broken, + }); +}); + +/** The plugin by name, or a 404 naming it. Shared by every verb below. */ +async function findPlugin(appName: string) { + const { states } = await snapshotPlugins(); + const state = states.find((s) => s.plugin.appName === appName); + if (!state) throw errors.NOT_FOUND(`No plugin directory named "${appName}"`); + return state; +} + +/** + * POST /api/plugins/:appName/install + * + * Idempotent, and re-installing is how a plugin is upgraded: the row follows the version on disk. It does + * not touch `enabled`, so re-installing something the owner had switched off does not switch it back on. + * + * `[open]` The schema push and the sidecar's PM2 entry are not wired yet — this records the install and + * mounts the routes. A plugin with `db/schema.ts` or `sidecar/` will need both before it works end to end. + */ +pluginsRouter.post('/:appName/install', async (ctx) => { + const { plugin } = await findPlugin(ctx.req.param('appName')); + await recordPluginInstall(plugin.appName, plugin.manifest.version); + const mounts = await refreshPluginMounts(); + return ctx.json({ ok: true, appName: plugin.appName, ...mounts }); +}); + +/** + * POST /api/plugins/:appName/uninstall + * + * Drops the row and unmounts. Deletes nothing the plugin owns — its tables and every row in them survive, + * so reinstalling is a restore rather than a fresh start. Dropping a plugin's data is a separate and + * deliberate act, not a side effect of an unrelated one. + */ +pluginsRouter.post('/:appName/uninstall', async (ctx) => { + const appName = ctx.req.param('appName'); + const removed = await removePluginInstall(appName); + if (!removed) throw errors.NOT_FOUND(`"${appName}" is not installed`); + const mounts = await refreshPluginMounts(); + return ctx.json({ ok: true, appName, ...mounts }); +}); + +/** POST /api/plugins/:appName/enable — mount its routes again. Nothing else changes. */ +pluginsRouter.post('/:appName/enable', async (ctx) => { + const appName = ctx.req.param('appName'); + const row = await setPluginEnabled(appName, true); + if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`); + const mounts = await refreshPluginMounts(); + return ctx.json({ ok: true, appName, enabled: true, ...mounts }); +}); + +/** POST /api/plugins/:appName/disable — unmount, keep everything. The reversible middle ground. */ +pluginsRouter.post('/:appName/disable', async (ctx) => { + const appName = ctx.req.param('appName'); + const row = await setPluginEnabled(appName, false); + if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`); + const mounts = await refreshPluginMounts(); + return ctx.json({ ok: true, appName, enabled: false, ...mounts }); +}); diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 1f7b07bd..ec6eef47 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -368,6 +368,20 @@ export const CAPABILITIES: Capability[] = [ api: ['/app-store'], routes: ['/app-store'], }, + { + key: 'plugins', + label: 'Plugins', + description: 'Install, enable and remove the plugins this server runs', + // Admin for the same reason as the app store above: installing a plugin mounts routes and starts a + // process, which is process control rather than a feature to grant a read of. + // + // Note this capability guards the MANAGEMENT surface, not the plugins themselves. A plugin declares + // its own permissions in its manifest, and those are what gate its routes — so a member can hold + // `offscale` at read without being able to install or remove anything. + kind: 'admin', + api: ['/plugins'], + routes: ['/plugins'], + }, { key: 'server-admin', label: 'Server settings', diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 0c854b8f..1c95eb2c 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -19,6 +19,7 @@ import { uploadRouter } from './api/upload/upload'; import { settingsRouter } from './api/settings/settings'; import { dashboardsRouter } from './api/dashboards'; import { router as fileBrowserRouter } from './api/file-browser/router'; +import { pluginsRouter } from './api/plugins/router'; // import { musicRouter } from './api/music/router'; // import { vaultRouter } from './api/vault/router'; // import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; @@ -139,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/memos', memosRouter], // plugin — switched off 2026-08-13 // ['/gitea', giteaRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], + ['/plugins', pluginsRouter], // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/plugins/mount.ts b/src/servers/plugins/mount.ts new file mode 100644 index 00000000..74ae30e2 --- /dev/null +++ b/src/servers/plugins/mount.ts @@ -0,0 +1,100 @@ +import { listPluginInstalls, type PluginInstall } from 'officerdb'; +import type { MountedPlugin } from '../hono'; +import { rebuildHonoApp } from '../hono'; +import { discoverPlugins } from './discover'; +import { mountPrefix, type DiscoveredPlugin } from './manifest'; + +// Turning what is on disk plus what is in the database into a mounted application. +// +// Three states, and they are genuinely different questions: +// +// on disk the directory exists — `discoverPlugins` +// installed a `plugin_installs` row — the owner asked for it +// enabled that row says so — and has not since turned it off +// +// Only the third mounts. A plugin a developer is writing sits in the tree unmounted; a disabled plugin +// keeps every table and row it owns and simply stops answering. + +/** A plugin, with whatever the database knows about it. `install` is null when nobody has installed it. */ +export type PluginState = { + plugin: DiscoveredPlugin; + install: PluginInstall | null; + /** The manifest on disk moved after it was installed — normal while developing, worth being able to see. */ + outdated: boolean; +}; + +export type PluginsSnapshot = { + states: PluginState[]; + /** Directories that look like plugins and could not be read. Rendered, never thrown — see `discover.ts`. */ + broken: { appName: string; error: string }[]; +}; + +/** + * What is on disk, joined to what is installed. + * + * An install row with no directory is DROPPED rather than reported: it means the code was removed from + * the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the + * database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss. + */ +export async function snapshotPlugins(): Promise { + const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]); + const byName = new Map(installs.map((row) => [row.appName, row])); + + const states = plugins.map((plugin) => { + const install = byName.get(plugin.appName) ?? null; + return { plugin, install, outdated: !!install && install.version !== plugin.manifest.version }; + }); + + return { states, broken }; +} + +/** + * Load a plugin's backend router. + * + * `api/router.ts` must export `router`. Anything else — a default export, a factory, a bare Hono — is + * refused by name rather than mounted wrong: a plugin whose routes silently do not exist is far harder to + * diagnose than one that refuses to install. + */ +export async function loadPluginRouter(plugin: DiscoveredPlugin): Promise { + if (!plugin.api) return null; + + const module = (await import(plugin.api)) as { router?: unknown }; + const router = module.router; + if (!router || typeof (router as { fetch?: unknown }).fetch !== 'function') { + throw new Error(`${plugin.appName}: api/router.ts must export \`router\` (a Hono router)`); + } + + return { prefix: mountPrefix(plugin), router: router as MountedPlugin['router'] }; +} + +/** The plugins that should be mounted right now: installed, enabled, and carrying an `api/router.ts`. */ +export async function mountablePlugins(snapshot: PluginsSnapshot): Promise { + const mounted: MountedPlugin[] = []; + for (const { plugin, install } of snapshot.states) { + if (!install?.enabled || !plugin.api) continue; + try { + const entry = await loadPluginRouter(plugin); + if (entry) mounted.push(entry); + } catch (err) { + // One plugin that will not load must not take the other nine down with it, and must not stop the + // platform booting. It stays unmounted and says why. + console.error(`[plugins] ${plugin.appName} not mounted:`, err instanceof Error ? err.message : err); + } + } + return mounted; +} + +/** + * Rebuild the application from the current state of disk and database. + * + * This is the whole of install, uninstall, enable and disable as far as ROUTING is concerned — each of + * those writes a row and then calls this. Hono cannot add a route to a live app and cannot remove one at + * all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it + * through a closure, which is what makes the reassignment take effect. + */ +export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[] }> { + const snapshot = await snapshotPlugins(); + const mounted = await mountablePlugins(snapshot); + rebuildHonoApp(mounted); + return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName) }; +}