Files
platform/src/servers/plugins/mount.ts
T
pastilhasandClaude Opus 5 52f209c69f caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.

what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.

three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

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

335 lines
16 KiB
TypeScript

import { listPluginInstalls, type PluginInstall } from 'officerdb';
import type { MountedPlugin } from '../hono';
import { rebuildHonoApp } from '../hono';
import { setPluginPermissions, type Permission } from '../permissions/registry';
import {
resolvePluginPublicPaths,
resolvePluginRootPaths,
setPluginProtocolHeaders,
setPluginPublicPaths,
setPluginRootClaims,
type PublicPath,
type RootClaim,
} from './public-paths';
import { discoverPlugins } from './discover';
import { mountPrefix, type DiscoveredPlugin } from './manifest';
import { generatePluginsModule, rebuildFrontend } from './generate';
import { iconUrl, screenArtUrl } from '../app-store/assets';
import { publishAssets } from '../app-store/assets';
import { importFresh } from './import-fresh';
// 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;
// Re-read when the file has changed, so remounting after an update serves the new routes rather than
// the ones this process happened to import first. Only this file — see import-fresh.ts.
const module = await importFresh<{ router?: unknown; publicRouter?: unknown }>(plugin.api);
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)`);
}
// Optional, and only meaningful alongside a manifest that declares where it is served. A plugin that
// exports one without declaring anything gets it loaded and mounted nowhere, which is the safe way
// round: the manifest is what the owner consented to, not the export.
const publicRouter =
module.publicRouter && typeof (module.publicRouter as { fetch?: unknown }).fetch === 'function'
? (module.publicRouter as MountedPlugin['router'])
: undefined;
return {
prefix: mountPrefix(plugin),
router: router as MountedPlugin['router'],
...(publicRouter ? { publicRouter } : {}),
rootSegments: [],
protocolHeaders: plugin.manifest.protocolHeaders ?? [],
};
}
/** 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;
}
/**
* Turn a plugin's declared permissions into the entries the platform's own registry understands.
*
* ALWAYS `app`, because a plugin permission is always grantable: the same read/write/none per role the
* platform's own permissions get, on the same page. The restricted levels stay the platform's to assign
* and a plugin cannot name any of them — its manifest has no field for one, so the escalation question is
* removed rather than answered.
*
* The FIRST permission gates the plugin's whole surface. Later ones are registered as grantable keys that
* claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and
* that can be added when something needs it rather than guessed at now.
*/
function pluginPermissions(state: PluginState): Permission[] {
const { plugin } = state;
const prefix = mountPrefix(plugin);
return plugin.manifest.permissions.map((permission, index) => ({
key: permission.key,
label: permission.label,
description: permission.description,
kind: 'app' as const,
// Only the first claims the routes; see above.
api: index === 0 ? [prefix] : [],
routes: index === 0 ? [prefix] : [],
...(permission.readOnlyWrites ? { readOnlyWrites: permission.readOnlyWrites } : {}),
// Rows only the caller can see, so a read grant may still write them — a member minting the app
// password their own phone syncs with. Threaded like the two below, and meaningful for the same
// reason: only the entry that claims the paths can match anything.
...(permission.personal ? { personal: permission.personal } : {}),
// Only meaningful on the entry that claims the paths — a later permission claims no `api`, so an
// `ownerOnly` on it would match nothing. Threaded anyway rather than dropped: silently ignoring a
// declaration is how a plugin author concludes the field does not work.
...(permission.ownerOnly ? { ownerOnly: permission.ownerOnly } : {}),
}));
}
/**
* 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 type RefreshResult = {
mounted: string[];
broken: string[];
rejected: string[];
/** Absent when the frontend did not need rebuilding, or when `skipFrontend` was asked for. */
frontend?: { ok: boolean; ms: number; error?: string };
};
export async function refreshPluginMounts(options: { skipFrontend?: boolean } = {}): Promise<RefreshResult> {
const snapshot = await snapshotPlugins();
// Permissions BEFORE routes. The permission gate runs ahead of every router, so a route mounted without
// its permission registered is a route the gate cannot resolve — and an unresolvable path is denied,
// which would make a freshly installed plugin 403 until something else happened to refresh.
const enabled = snapshot.states.filter((s) => s.install?.enabled);
const { rejected } = setPluginPermissions(enabled.flatMap(pluginPermissions));
if (rejected.length) {
console.error(`[plugins] permission keys refused (already taken by the platform): ${rejected.join(', ')}`);
}
// Unauthenticated paths, recomputed on the same cycle and for the same reason as permissions above:
// install, uninstall, enable and disable are one recomputation rather than a diff. Logged at INFO
// whenever there are any — a route that answers without a session should never be something you have to
// read a manifest to discover.
const publicPaths: PublicPath[] = [];
for (const { plugin } of enabled) {
const { paths, refused } = resolvePluginPublicPaths(plugin);
for (const reason of refused) console.error(`[plugins] unauthenticated path refused — ${reason}`);
for (const pattern of paths) publicPaths.push({ pattern, appName: plugin.appName });
}
setPluginPublicPaths(publicPaths);
if (publicPaths.length) {
const shown = publicPaths.map((p) => `${p.pattern} (${p.appName})`).join(', ');
console.log(`[plugins] served WITHOUT a session: ${shown}`);
}
// Top-level claims, resolved against ONE accumulating set so two plugins cannot both own `/identity`.
// First come wins and the loser is named, rather than the last mount silently shadowing the first.
const taken = new Set<string>();
const rootClaims: RootClaim[] = [];
const headerClaims: { header: string; appName: string }[] = [];
for (const { plugin } of enabled) {
const { segments, refused } = resolvePluginRootPaths(plugin, taken);
for (const reason of refused) console.error(`[plugins] root path refused — ${reason}`);
for (const segment of segments) rootClaims.push({ segment, appName: plugin.appName });
for (const header of plugin.manifest.protocolHeaders ?? []) {
headerClaims.push({ header: header.toLowerCase(), appName: plugin.appName });
}
}
setPluginRootClaims(rootClaims);
setPluginProtocolHeaders(headerClaims);
if (rootClaims.length) {
console.log(`[plugins] top-level paths: ${rootClaims.map((c) => `/${c.segment} (${c.appName})`).join(', ')}`);
}
if (headerClaims.length) {
console.log(
`[plugins] /api diverted by header: ${headerClaims.map((c) => `${c.header} -> ${c.appName}`).join(', ')}`,
);
}
const mounted = await mountablePlugins(snapshot);
// The claims were resolved above from the manifests; attach them to the entries hono actually mounts,
// so the router set and the claim set can never describe different plugins.
for (const entry of mounted) {
const appName = entry.prefix.split('/').filter(Boolean).pop();
entry.rootSegments = rootClaims.filter((c) => c.appName === appName).map((c) => c.segment);
entry.protocolHeaders = headerClaims.filter((c) => c.appName === appName).map((c) => c.header);
}
rebuildHonoApp(mounted);
// Republish every enabled plugin's `assets/` on the way past.
//
// Publishing used to happen ONLY at install, and `offscale` proved why that is not enough: it was
// installed before it shipped an icon, so `public/plugins/offscale/` never existed and the tile drew a
// 404 forever. Nothing short of uninstalling and reinstalling would have fixed it, and "reinstall the
// plugin to see its new icon" is the same bad answer as "restart the server to see it".
//
// A copy of files that already exist in the plugin's own source, so re-running it is free and cannot
// lose anything — the same property that makes `unpublishAssets` the one thing uninstall may delete.
// Failures are logged, never thrown: an icon is not worth refusing to mount a plugin over.
await Promise.all(
enabled.map(async ({ plugin }) => {
try {
await publishAssets(plugin.appName, plugin.dir);
} catch (err) {
console.error(`[plugins] ${plugin.appName}: assets not published —`, err instanceof Error ? err.message : err);
}
}),
);
// The frontend half. `Plugins.gen.tsx` is what the bundler reads, so it is written first and the SPA is
// rebuilt from it — ~1s for a 25MB bundle, which is why an install can do this rather than ask for a
// restart. Skipped at boot, where the build already happened and repeating it would delay `serve()`.
let frontend: RefreshResult['frontend'];
if (!options.skipFrontend) {
generatePluginsModule(snapshot.states);
const built = await rebuildFrontend();
frontend = { ok: built.ok, ms: built.ms, ...(built.error ? { error: built.error } : {}) };
if (!built.ok) console.error('[plugins] frontend rebuild failed:', built.error);
}
return {
mounted: mounted.map((m) => m.prefix),
broken: snapshot.broken.map((b) => b.appName),
rejected,
...(frontend ? { frontend } : {}),
};
}
/**
* Dock tiles for installed, enabled plugins, in the shape the shell already renders
* (`dockItemsFromPlugins`).
*
* Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
* and nothing to keep in step. `permission` is the plugin's first permission when it has one, which is
* what lets the self endpoint filter the tile out for an account that cannot reach the screen —
* a member must not be handed the manifest of a feature they may not use, even to hide it.
*
* Built here rather than baked into the generated frontend module on purpose: WHO sees a tile is a
* runtime question, because a grant takes effect on the next request rather than the next build.
*/
export async function pluginDockManifests(): Promise<
Array<{
sidecarId: string;
permission: string | null;
name: string;
icon?: string;
image?: string;
tile?: 'badge' | 'bare';
color: string;
rootRoute: string;
routes: string[];
}>
> {
const { states } = await snapshotPlugins();
// One tile per SCREEN. A plugin with two screens gets two tiles behind ONE permission — caldav is
// Calendar and Contacts, which are two places to go and one thing to be allowed to reach. Filtering
// stays per-plugin for that reason: the permission is the plugin's, and a screen is not a smaller
// grant, it is another door into the same room.
return states
.filter((s) => s.install?.enabled && s.plugin.web)
.flatMap(({ plugin }) =>
plugin.web!.screens.map((screen) => {
const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin);
return {
sidecarId: plugin.appName,
permission: plugin.manifest.permissions[0]?.key ?? null,
name: screen.label,
// A shipped `assets/icon.png` wins; the lucide NAME is the fallback for a plugin with no
// artwork of its own. The dock renders `image` as an <img> and `icon` through `resolveIcon`,
// and it already preferred the image — this is the first thing to give it one.
//
// A plugin with several screens is the one case where the shared artwork is WRONG: two
// identical tiles side by side say nothing about which is which. So a screen naming its own
// glyph overrides the image, and one that does not falls back to the plugin's.
// Order: this screen's own artwork, then the plugin's, then a glyph. The middle one is skipped
// for a multi-screen plugin that gave this screen no art of its own — two identical tiles say
// nothing about which is which, and a distinct glyph beats a repeated picture.
...(screen.art
? { image: screenArtUrl(plugin.appName, screen.segment!) }
: plugin.icon && plugin.web!.screens.length === 1
? { image: iconUrl(plugin.appName) }
: { icon: screen.icon ?? plugin.manifest.icon }),
// Artwork defaults to `bare`, a glyph to `badge`; the manifest overrides either way.
tile:
plugin.manifest.tile ??
(screen.art || (plugin.icon && plugin.web!.screens.length === 1) ? 'bare' : 'badge'),
color: plugin.manifest.color,
rootRoute: route,
routes: [route],
};
}),
);
}