Files
platform/src/servers/api/plugins/router.ts
T
pastilhasandClaude Opus 5 3d936309a5 the list refreshes before the message, and the tab follows the result
reported as "the list does not update without a refresh". the counts were in fact
correct, which is the more interesting half: fofos-admin was ALREADY INSTALLED on
that host, so the fetch put it in Installed while the confirmation said "click it
to install" and pointed at an empty Available tab. right numbers, wrong story.

three fixes, and only one of them is the refresh.

AWAIT THE REFETCH. `invalidateQueries` is fire-and-forget, so the confirmation
appeared while the grid was still rendering the previous answer and the plugin
turned up a moment later. that reads as "nothing happened" and sends somebody
looking for a refresh button — which is exactly what happened.

SAY WHICH THING HAPPENED. a directory and an install row are separate facts:
deleting a plugin's source leaves the row, so a re-fetch can land on something the
platform still considers installed. the route now reports it, and the message is
either "added — click it to install" or "added — already installed".

FOLLOW IT TO ITS TAB. if it came back installed, the overview switches to
Installed rather than leaving somebody staring at the tab it is not in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 00:01:48 +01:00

452 lines
21 KiB
TypeScript

import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { mountPrefix } from '../../plugins/manifest';
import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
import { findOrphanTables } from '../../plugins/orphans';
import { fetchPlugin } from '../../plugins/fetch';
import { discoverPlugins } from '../../plugins/discover';
import { listPluginInstalls } from 'officerdb';
import { iconUrl } from '../../app-store/assets';
import {
installPlugin,
pluginProcessStatus,
setPluginRunning,
purgePlugin,
type PluginActionResult,
} from '../../plugins/install';
import { updatePlugin, enablePlugin, syncCapabilities } from '../../plugins/update';
// /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 permission 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.
*/
// GET /plugins/catalogue/:appName/readme — the store's own description of a plugin, for the detail
// pane of something not installed yet.
//
// Under `/plugins` so the existing `plugins` permission covers it: reading the store is the same
// privilege as installing from it, and a second key would be a second thing to grant for no separate
// decision.
pluginsRouter.get('/catalogue/:appName/readme', async (ctx) => {
const readme = await fetchReadme(ctx.req.param('appName'));
return ctx.json({ readme });
});
pluginsRouter.get('/', async (ctx) => {
const { states, broken } = await snapshotPlugins();
// Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
// rendering differently — it is the difference between "off" and "broken".
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
// Synchronous and cheap — `Bun.which` per declared binary, and most plugins declare none. The one
// genuinely slow call in here is the `sudo -n` probe, which only runs when something is missing.
const dependencyReports = states.map(({ plugin }) => {
const report = reportDependencies(plugin);
return {
manager: report.manager,
items: report.dependencies,
satisfied: report.missing.length === 0,
canInstall: report.canInstall,
blockedReason: report.blockedReason,
packagesToInstall: report.packagesToInstall,
manualHint: manualInstallHint(report.manager, report.packagesToInstall),
};
});
// What the marketplace offers that this machine does not have.
//
// Merged here rather than in `snapshotPlugins`, which answers "what is on disk joined to what is
// installed" and should keep answering exactly that — the catalogue is a third, remote source and
// giving discovery a network dependency would make the plugins page fail when the store is down.
//
// A catalogue entry whose plugin is already on disk is DROPPED: the directory is the truth once it
// exists, and showing both would offer to install something already installed.
const onDisk = new Set(states.map((s) => s.plugin.appName));
const catalogue = (await fetchCatalogue().catch(() => [])).filter((e) => !onDisk.has(e.appName));
// Tables nothing on this machine claims. Cheap — one pg_class query and one import per plugin — and
// computed here rather than behind its own route so the screen cannot show a plugin list without it.
// Never fatal: a database that will not answer this must not take the plugins page down with it.
const orphans = await findOrphanTables(catalogue.map((e) => e.appName)).catch((err) => {
console.error('[plugins] orphan scan failed —', err instanceof Error ? err.message : err);
return [];
});
return ctx.json({
orphans,
available: catalogue.map((e) => ({
appName: e.appName,
prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`,
label: e.label,
summary: e.summary,
// What it is FOR, which is how the store is browsed. From the catalogue for a plugin that is not
// here yet, from the manifest below for one that is — the same field either way.
tags: e.tags ?? [],
image: e.iconUrl,
// Same precedence the installed list uses: artwork wins, the glyph name is the fallback.
...(e.iconUrl ? {} : { icon: e.icon }),
tile: e.tile ?? 'badge',
color: e.color,
publisher: e.publisher,
version: e.version,
platform: e.platform,
permissions: e.permissions,
has: e.has,
source: e.source,
// The same live PATH probe a local plugin gets, so ffmpeg/ffprobe are visible BEFORE the clone
// rather than after. `reportDependencies` takes a DiscoveredPlugin, so the entry is adapted to
// that shape — the only field it reads is `manifest.osDependencies`.
dependencies: (() => {
const r = reportDependencies({ manifest: { osDependencies: e.osDependencies } } as never);
return {
manager: r.manager,
items: r.dependencies,
satisfied: r.missing.length === 0,
canInstall: r.canInstall,
blockedReason: r.blockedReason,
packagesToInstall: r.packagesToInstall,
manualHint: manualInstallHint(r.manager, r.packagesToInstall),
};
})(),
})),
plugins: states.map(({ plugin, install, outdated, release }, i) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
label: plugin.manifest.label,
summary: plugin.manifest.summary,
tags: plugin.manifest.tags ?? [],
icon: plugin.manifest.icon,
// The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies
// `assets/` into `public/plugins/<appName>/` at install and `unpublishAssets` removes it, so a
// plugin that has never been installed has nothing at that URL and an <img> would 404. The list
// falls back to a generic glyph, which is what it drew for everything until now.
//
// `[open]` That means you cannot see a plugin's icon BEFORE installing it, which is the one place
// an app store most wants to. Fixing it means either publishing on discovery rather than install —
// which breaks "assets are a property of the install" — or an authenticated icon route, which an
// <img> cannot use because it sends no Authorization header.
...(plugin.icon && install ? { image: iconUrl(plugin.appName) } : {}),
color: plugin.manifest.color,
publisher: plugin.manifest.publisher,
version: plugin.manifest.version,
platform: plugin.manifest.platform,
permissions: plugin.manifest.permissions,
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
settings: plugin.manifest.settings ?? [],
// 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,
// The tag rule — whether the REMOTE has something this checkout does not. See plugins/release.ts.
updateable: release.updateable,
releaseReason: release.reason,
tagAtHead: release.tagAtHead,
newestTag: release.newestTag,
processStatus: statuses[i] ?? null,
// Probed live, against this machine's PATH, on every read.
//
// Deliberately here rather than only inside the installer: the owner decides whether to let the
// server run `apt-get install` as root, and that decision needs the answer BEFORE the button, not
// in the log afterwards. It also lets them install the packages by hand first and watch this flip
// to present, which is the escape hatch for a machine with no passwordless sudo.
dependencies: dependencyReports[i]!,
})),
broken,
});
});
/**
* The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` —
* "installed" and "installed but the sidecar would not start" are different outcomes and the second is
* the one worth reading. See `plugins/install.ts` for why the order inside each is what it is.
*/
/**
* Update every installed plugin, in sequence.
*
* SEQUENTIAL, not parallel: each update runs a schema push, republishes assets, rewrites the ecosystem
* file and rebuilds the frontend. Two of those doing it at once is a race over one file each.
*
* One plugin failing does NOT stop the rest — a dirty checkout is the expected failure here (every
* plugin directory on a developer's machine is a live one), and it should not prevent the other eleven
* from moving. The per-plugin result says which, and why.
*/
pluginsRouter.post('/update-all', async (ctx) => {
const userId = ctx.get('user').id;
const { states } = await snapshotPlugins();
const installed = states.filter((st) => st.install).map((st) => st.plugin.appName);
const results: PluginActionResult[] = [];
for (const appName of installed) {
results.push(await updatePlugin(appName, undefined, userId));
}
// The capabilities repository rides along. It is the same question the button asks — "get me what has
// been published since I installed" — and a second, different gesture for it is one nobody remembers.
// On a machine installed before affordances existed there is no checkout at all, so this is also how it
// first arrives.
const capabilities = await syncCapabilities();
return ctx.json({
results,
capabilities,
updated: results.filter((r) => r.ok).length,
failed: [
...results.filter((r) => !r.ok).map((r) => ({ appName: r.appName, error: r.error })),
...(capabilities.ok ? [] : [{ appName: 'capabilities', error: capabilities.error }]),
],
});
});
/**
* `https://host/owner/repo` or `…/repo.git` → `repo`.
*
* Derived rather than asked for, so the name and the address cannot disagree. `appName` IS the directory
* a plugin sits in — never declared in a manifest — so this string becomes the route segment, the table
* prefix and the sidecar suffix. Checked before anything is cloned: a half-clone under an unusable name
* reads as a BROKEN plugin rather than an absent one, which is much harder to get out of.
*/
function pluginNameFromUrl(url: string, override?: string): { appName: string } | { error: string } {
const derived = url
.replace(/\.git$/, '')
.replace(/\/+$/, '')
.split('/')
.pop();
const appName = override?.trim() || derived;
if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) {
return {
error:
`"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` +
'it becomes the directory, the route and the table prefix.',
};
}
return { appName };
}
/*
POST /plugins/fetch-from-url — put a plugin on the machine, and stop there.
The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend"
had no route. The first real case is one that must never be listed: fofos-admin is a single website's
admin panel.
── Why this FETCHES and does not install ──
It was an install first, and that was wrong in a way only using it showed: the button lives on the
grid, the progress log lives in the detail panel, so a URL install ran to completion with nothing on
screen and the plugin simply appeared, installed. Streaming into a view that cannot render it is worse
than not streaming.
Cloning alone is enough to make a plugin real — `/api/plugins` lists what is ON DISK with
`installed: false`, so it shows up as Available like any catalogue entry. From there the existing card,
the existing Install button and the existing streamed progress do the rest. One flow, not two.
── The name still comes from the URL ──
`appName` IS the directory, so the repository name decides the route segment, the table prefix and the
process name. Checked before cloning: a half-clone under an unusable name reads as a BROKEN plugin
rather than an absent one.
Owner-only like everything else here, and worth saying plainly — this puts somebody's code on the
machine. It does not run it, which is exactly why installing stays a separate, deliberate click.
*/
pluginsRouter.post('/fetch-from-url', async (ctx) => {
type FromUrl = { url?: string; appName?: string };
const body = await ctx.req.json<FromUrl>().catch(() => ({}) as FromUrl);
const url = body.url?.trim();
if (!url) throw errors.BAD_REQUEST('url is required');
const named = pluginNameFromUrl(url, body.appName);
if ('error' in named) throw errors.BAD_REQUEST(named.error);
const { appName } = named;
const fetched = await fetchPlugin(appName, undefined, url);
if (!fetched.ok) throw errors.BAD_REQUEST(fetched.error ?? `could not fetch ${appName}`);
/*
Re-read from disk rather than trusting the clone.
`fetchPlugin` checks that `manifest.ts` EXISTS; discovery checks that it PARSES and says what a
plugin needs to say. A directory that fails the second test still renders in the grid — as broken,
which is the honest state — but saying so here means the person hears it while they are still
looking at the field they pasted into.
*/
const { plugins, broken } = await discoverPlugins();
const found = plugins.find((entry) => entry.appName === appName);
const bad = broken.find((entry) => entry.appName === appName);
if (!found) {
return ctx.json(
{ ok: false, appName, error: bad?.error ?? `cloned "${appName}", but it does not read as a plugin` },
400,
);
}
/*
Whether it is ALREADY installed, so the UI can say the true thing and show the right tab.
A directory and an install row are separate facts — deleting a plugin's source leaves the row, so a
re-fetch can land on something the platform still considers installed. Reported here because the
caller has just been told "added" and needs to know where to look; "click it to install" pointing at
an empty tab is worse than no message.
*/
const installs = await listPluginInstalls();
const installed = installs.some((entry) => entry.appName === appName && entry.enabled);
return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName, installed });
});
pluginsRouter.post('/:appName/install', async (ctx) => {
const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
return ctx.json(result, result.ok ? 200 : 400);
});
// ── Two doors, because "delete" means two things ──
//
// `delete` takes the plugin off the machine and leaves its tables. `purge` takes the data as well. Two
// routes rather than one route and a `dropData` body field: a flag can be added to a request by anything
// that already knows how to call the safe verb, and the difference here is somebody's rows.
//
// The UI's checkbox chooses which one it calls, so the confirmation the owner reads and the door that
// opens are the same decision.
pluginsRouter.post('/:appName/delete', async (ctx) => {
return ctx.json(await purgePlugin(ctx.req.param('appName'), { dropData: false }));
});
pluginsRouter.post('/:appName/purge', async (ctx) => {
return ctx.json(await purgePlugin(ctx.req.param('appName'), { dropData: true }));
});
// Enabling refreshes the checkout first — see `enablePlugin`. A disabled plugin's directory is what the
// next start runs, so turning one back on used to run whatever was current the day it was switched off.
pluginsRouter.post('/:appName/enable', async (ctx) => {
const result = await enablePlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
return ctx.json(result, result.ok ? 200 : 400);
});
pluginsRouter.post('/:appName/disable', async (ctx) => {
const result = await setPluginRunning(ctx.req.param('appName'), false);
return ctx.json(result, result.ok ? 200 : 400);
});
// ── The same verbs, streamed ──
//
// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that
// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could
// not". Streaming each step as it completes turns the same work into something you can watch, and — more
// usefully — leaves the log on screen naming the step that failed.
//
// POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these
// routes are owner-only. The client reads the body and parses frames itself, which is exactly what
// a plugin's own log-stream hooks already do.
// `userId` is the caller, threaded through so install can pin the new tile to THEIR dock rather than
// guessing at the owner. Only install uses it; the others take it and ignore it so the map stays one shape.
type VerbFn = (
appName: string,
onStep: (s: string) => Promise<void>,
userId: number,
onLog: (line: string) => Promise<void>,
) => Promise<PluginActionResult>;
const VERBS = {
install: (appName, onStep, userId, onLog) => installPlugin(appName, onStep, userId, onLog),
enable: (appName, onStep, userId, onLog) => enablePlugin(appName, onStep, userId, onLog),
disable: (appName, onStep) => setPluginRunning(appName, false, onStep),
// Same function, and the verb IS the flag — see the two routes above.
delete: (appName, onStep, _userId, onLog) => purgePlugin(appName, { dropData: false }, onStep, onLog),
purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, { dropData: true }, onStep, onLog),
// A pull and then the ordinary install path — see plugins/update.ts. It is a verb rather than its own
// route so it inherits the streaming door and the UI that already drives install.
update: (appName, onStep, userId, onLog) => updatePlugin(appName, onStep, userId, onLog),
// `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns
// `isVerb` into a guard that proves nothing and lets an unknown verb index the map.
} satisfies Record<string, VerbFn>;
type Verb = keyof typeof VERBS;
const isVerb = (v: string): v is Verb => v in VERBS;
pluginsRouter.post('/:appName/:verb/stream', async (ctx) => {
const appName = ctx.req.param('appName');
const verb = ctx.req.param('verb');
if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`);
// Read before the stream opens: once we are inside ReadableStream.start the request context is gone.
const userId = ctx.get('user').id;
const encoder = new TextEncoder();
const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
// The client may have navigated away mid-install. The work continues — it is the server's job
// now — but writing to a closed stream would throw and abort it halfway.
try {
controller.enqueue(frame(event, data));
} catch {
/* client gone */
}
};
let result: PluginActionResult;
try {
result = await VERBS[verb](
appName,
async (step) => send('step', { step }),
userId,
// Raw output from whatever the step is running — `git clone`, `db:push`, a package manager.
// A separate event so the client can render it as what it is: the command talking, not the
// platform reporting.
async (line) => send('log', { line }),
);
} catch (err) {
result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) };
}
send('done', result);
try {
controller.close();
} catch {
/* already closed */
}
},
});
return new Response(stream, {
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
// Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until
// the response ends, which is precisely the behaviour this endpoint exists to avoid.
'x-accel-buffering': 'no',
},
});
});