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 { 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; // 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 { 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 { 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(); 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 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], }; }), ); }