Files
platform/src/servers/api/plugins/router.ts
T
pastilhasandClaude Opus 5 e0d3f59b9b name the tables nothing on this machine owns
The marketplace screen now lists tables in the database that no code here claims,
with a row estimate and, where the prefix says so, which absent plugin they look
like they belong to.

This is the information that cost an evening. `bun db:push` saw tables the schema
did not declare, asked an interactive question no non-TTY caller could answer,
applied nothing and exited 0 — and the install reported success while the plugin
was unusable. The cause was two tables from before an extraction, found by reading
`\dt` by hand. Nothing in the platform would ever have mentioned them.

── What counts as an owner ──

The core schema, or a plugin DIRECTORY — not a plugin INSTALL. A plugin that is
present but uninstalled still owns its tables, because that is the promise
uninstall makes: keep every row so a reinstall puts you back where you were. So
this reports only what no code on this machine describes at all, which on a healthy
install is nothing, and the block does not render.

A plugin whose schema will not import has its prefix treated as claimed rather than
having every one of its tables reported as unowned — a broken manifest should not
read as data with no home.

── No delete button, deliberately ──

Dropping a table is not something to offer beside a list of plugins, and the right
action differs per row: `music_favorites` wants the music plugin installed,
`headscale_servers` wants dropping. Naming them is the part that was missing.

Verified by planting `headscale_servers` and `music_playlists_old` and reading them
back through the same call the route makes: the first attributed to nothing, the
second to music, which is not on this machine. Row counts come from
`pg_class.reltuples` — an exact count on a large table is not worth blocking a page
render for a number that only answers "is there anything in here".

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:33:25 +00:00

293 lines
14 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 { iconUrl } from '../../app-store/assets';
import {
installPlugin,
pluginProcessStatus,
setPluginRunning,
purgePlugin,
uninstallPlugin,
type PluginActionResult,
} from '../../plugins/install';
// /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,
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 }, i) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
label: plugin.manifest.label,
summary: plugin.manifest.summary,
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 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,
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.
*/
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);
});
// Destructive, and the only route here that is. Named `purge` rather than being a flag on uninstall so
// nothing can reach it by passing a body field to the verb people already use.
pluginsRouter.post('/:appName/purge', async (ctx) => {
return ctx.json(await purgePlugin(ctx.req.param('appName')));
});
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
const result = await uninstallPlugin(ctx.req.param('appName'));
return ctx.json(result, result.ok ? 200 : 400);
});
pluginsRouter.post('/:appName/enable', async (ctx) => {
const result = await setPluginRunning(ctx.req.param('appName'), true);
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 four 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
// `useCompanionLogStream` already does for the headscale container logs.
// `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),
uninstall: (appName, onStep, _userId, onLog) => uninstallPlugin(appName, onStep, onLog),
enable: (appName, onStep) => setPluginRunning(appName, true, onStep),
disable: (appName, onStep) => setPluginRunning(appName, false, onStep),
purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, 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',
},
});
});