diff --git a/src/servers/permissions/core-collision.test.ts b/src/servers/permissions/core-collision.test.ts new file mode 100644 index 00000000..f8442e9d --- /dev/null +++ b/src/servers/permissions/core-collision.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import { corePermissionCollisions } from './registry'; + +// The failure this exists to prevent, in one sentence: a plugin whose permission key the platform still +// claims installs "successfully", shows a dock tile, rebuilds the frontend with its route — and 404s on +// every request, because `setPluginPermissions` refused the key at mount time and said so only on stderr. +// +// It caught photos, soulseek and jellyfin on the same machine inside a day. Each had been a CORE +// permission until its extraction commit, so any server whose platform predates that commit still owns +// the key. The remedy is always `git pull`, and nothing in the product ever said so. + +describe('core permission collisions', () => { + test('names a key the platform still claims', () => { + // 'chat' is core and not deniable — the clearest case of something a plugin must never redefine. + expect(corePermissionCollisions(['chat'])).toEqual(['chat']); + }); + + test('an ordinary plugin key collides with nothing', () => { + expect(corePermissionCollisions(['soulseek', 'jellyfin', 'photos'])).toEqual([]); + }); + + test('reports every collision, not just the first — an install should fail once, naming all of them', () => { + const found = corePermissionCollisions(['chat', 'soulseek', 'terminal']); + expect(found.sort()).toEqual(['chat', 'terminal']); + }); + + test('nothing declared collides with nothing', () => { + expect(corePermissionCollisions([])).toEqual([]); + }); +}); diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 62e51f27..d89e42d0 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -460,6 +460,20 @@ export const CORE_PERMISSIONS = CORE_REGISTRY.filter((c) => c.kind === 'core'); * question. A key colliding with a core one is REFUSED here rather than silently overriding — a plugin * that could redefine `chat` or `terminal` could widen it. */ +/** + * Which of these keys the PLATFORM already claims. + * + * Exported so install can refuse before it starts, rather than leaving `setPluginPermissions` to reject + * the key afterwards — that rejection is real but it happens at mount time and only reaches stderr, so + * the install reports success, the dock tile appears, and the route 404s. That exact sequence caught + * photos, soulseek and jellyfin on the same machine within a day: each had been a CORE permission until + * the extraction commit, so any server whose platform predates it still claims the key. + */ +export function corePermissionCollisions(keys: string[]): string[] { + const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key)); + return keys.filter((k) => coreKeys.has(k)); +} + export function setPluginPermissions(pluginPermissions: Permission[]): { rejected: string[] } { const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key)); const seen = new Set(); diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 93fd2f61..9fcb2ca5 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -7,6 +7,7 @@ import { setDockPaths, setPluginEnabled, } from 'officerdb'; +import { corePermissionCollisions } from '../permissions/registry'; import { rm } from 'node:fs/promises'; import { PLATFORM_DIR } from '../data-path'; import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2'; @@ -169,6 +170,30 @@ export async function installPlugin( } } + // PERMISSION KEYS BEFORE ANYTHING IS WRITTEN. + // + // A key the platform still claims is refused by `setPluginPermissions` at mount time — correctly, but + // too late and too quietly: the install completes, the dock tile appears, the frontend rebuilds with + // the route in it, and every request 404s because the plugin never mounted. The only trace is one line + // on stderr. + // + // It is not a hypothetical. photos, soulseek and jellyfin each hit it within a day of being extracted, + // always the same way: the plugin's key was a CORE permission until the extraction commit, so any + // server whose platform predates that commit still claims it. The fix is always `git pull`, and the + // symptom never says so. + const collisions = corePermissionCollisions(plugin.manifest.permissions.map((p) => p.key)); + if (collisions.length) { + return { + ok: false, + appName, + steps, + error: + `the platform still defines ${collisions.map((c: string) => `"${c}"`).join(', ')} as a core permission, ` + + `so "${appName}" cannot claim it. This server's platform predates the commit that extracted ` + + `${appName} — pull the platform and restart officer, then install again.`, + }; + } + try { // HOST BINARIES FIRST, before a table is created or a row is written. //