plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:
/api/example/ping BEFORE install 404
AFTER install 200 {"plugin":"example","ok":true}
AFTER disable 404
AFTER enable 200
AFTER uninstall 404
core route throughout 200
plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing 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, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.
the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.
mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.
/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.
plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.
not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.
full suite: 719 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }));
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -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<PluginInstall[]> {
|
||||
return db.select().from(pluginInstalls).orderBy(pluginInstalls.appName);
|
||||
}
|
||||
|
||||
export async function getPluginInstall(appName: string): Promise<PluginInstall | null> {
|
||||
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<PluginInstall> {
|
||||
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<PluginInstall | null> {
|
||||
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<boolean> {
|
||||
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -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/<app-name>/`), 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),
|
||||
],
|
||||
);
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof createRouter>
|
||||
// ['/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
|
||||
|
||||
@@ -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<PluginsSnapshot> {
|
||||
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<MountedPlugin | null> {
|
||||
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<MountedPlugin[]> {
|
||||
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) };
|
||||
}
|
||||
Reference in New Issue
Block a user