From 10ec550a764b0485c5e6617a833c1b4061faa4a9 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 10 Jul 2026 22:32:31 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugins):=20createServer({=20plugins?= =?UTF-8?q?=20})=20=E2=80=94=20the=20#206=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads app plugins from config: each entry's module is imported and its activate(api) run at startup. The api assembles the seams the plugin-zero exercise shipped or specced — appPaths WAC exemption for the entry's prefix (#582), auth.getAgent (#584), ws.route for WebSocket endpoints through @fastify/websocket so plugins never own an 'upgrade' listener (#588) — plus a private storage dir under the data root's dot-guard and a logger that speaks both pino and console dialects. A plugin that fails to load fails listen() loudly: the operator wrote the config, and a server silently missing an app is worse than one that refuses to start. activate() may return { deactivate } for teardown on close. Validated against both real consumers (Tideholm and bridge composed from pure config in one server: shared pod identity across both games, live WebSocket play, WAC intact on sibling paths). --- src/plugins.js | 168 +++++++++++++++++++++++++++++++++++ src/server.js | 21 +++++ test/plugins.test.js | 203 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 src/plugins.js create mode 100644 test/plugins.test.js diff --git a/src/plugins.js b/src/plugins.js new file mode 100644 index 0000000..503939c --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,168 @@ +/** + * Plugin loader — the #206 seam, assembled from its shipped parts. + * + * createServer({ + * plugins: [ + * { module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm', + * config: { bots: 8 } }, + * { module: './my-app/plugin.js', prefix: '/myapp' }, + * ], + * }) + * + * Each entry's module is imported and its exported `activate(api)` called + * during server startup (before listen completes). The api wires the seams + * every plugin consumer so far has needed: + * + * api.fastify scoped Fastify instance to register routes on + * api.prefix the entry's mount prefix ('' when none) + * api.config the entry's config object, verbatim + * api.log server logger + * api.auth.getAgent(req) -> agent id string | null (#584) + * api.storage.pluginDir() -> private server-side data dir for this plugin + * api.ws.route(path, (socket, request) => {}) (#588) + * + * The entry's `prefix` is added to appPaths automatically (#582), so the + * plugin owns authentication and authorization under its mount — the same + * deal the bundled pseudo-plugins (idp, /db, /storage/…) already have. + * + * ws.route registers WebSocket endpoints through @fastify/websocket — the + * same single upgrade path the bundled realtime features (nostr relay, + * tunnel, notifications…) use — so plugins never attach their own 'upgrade' + * listener. That matters: node only auto-destroys stray upgrade attempts + * while the server has NO 'upgrade' listener, so a plugin attaching one + * would become responsible for every unclaimed socket on the host (#588). + * The handler receives the raw ws socket; a plugin with its own + * WebSocketServer({ noServer: true }) can feed it straight in: + * api.ws.route('/myapp/ws', (socket, req) => wss.emit('connection', socket, req)); + * + * activate() may return { deactivate() {} }; deactivate runs on server + * close (world saves, timer teardown). A plugin that fails to load fails + * the boot loudly — the operator wrote the config, and a server silently + * missing an app is worse than one that refuses to start. + */ + +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; +import websocket from '@fastify/websocket'; +import { getAgent } from '../auth.js'; + +/** + * api.log speaks both dialects: pino-style (info/warn/error/debug, what + * fastify.log is) and console-style (log/error, what plain node apps + * expect) — plugins shouldn't need to know which logger the host runs. + */ +export function makePluginLog(base) { + const call = (level) => (...args) => { + const fn = base?.[level] ?? base?.info ?? base?.log; + if (typeof fn === 'function') fn.call(base, ...args); + }; + return { log: call('info'), info: call('info'), warn: call('warn'), error: call('error'), debug: call('debug') }; +} + +/** Same normalization appPaths applies: no trailing slash, must be '/x…'. */ +export function normalizePrefix(p) { + if (typeof p !== 'string') return ''; + const trimmed = p.trim().replace(/\/+$/, ''); + return trimmed.startsWith('/') && trimmed.length > 1 ? trimmed : ''; +} + +/** Directory-safe plugin id: from entry.id or derived from the module spec. */ +export function pluginId(spec) { + const raw = typeof spec.id === 'string' && spec.id + ? spec.id + : path.basename(String(spec.module)).replace(/\.[cm]?js$/, ''); + const id = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); + if (!id) throw new Error(`plugins: cannot derive an id from ${JSON.stringify(spec.module)}; set entry.id`); + return id; +} + +/** + * Load and activate every plugin entry. Called from createServer inside a + * fastify.register scope, so `fastify` here is that scope; routes and hooks + * plugins add land on the running server. + * + * @param {object} fastify scoped instance the plugins register on + * @param {Array} entries options.plugins, verbatim + * @param {object} ctx { appPaths, root, log } + */ +export async function loadPlugins(fastify, entries, ctx) { + const log = makePluginLog(ctx.log); + for (const entry of entries) { + const spec = typeof entry === 'string' ? { module: entry } : entry; + if (!spec || typeof spec.module !== 'string' || !spec.module) { + throw new Error('plugins: each entry needs a module (import specifier or path)'); + } + const id = pluginId(spec); + + // Paths resolve from the operator's cwd; bare specifiers stay package + // imports resolved from JSS's own module graph. + const href = spec.module.startsWith('.') || path.isAbsolute(spec.module) + ? pathToFileURL(path.resolve(spec.module)).href + : spec.module; + let mod; + try { + mod = await import(href); + } catch (err) { + throw new Error(`plugin ${id}: cannot import ${spec.module}: ${err.message}`); + } + const activate = mod.activate ?? mod.default; + if (typeof activate !== 'function') { + throw new Error(`plugin ${id}: module exports no activate(api) function`); + } + + const prefix = normalizePrefix(spec.prefix); + if (spec.prefix && !prefix) { + throw new Error(`plugin ${id}: invalid prefix ${JSON.stringify(spec.prefix)} (must start with '/')`); + } + if (prefix) ctx.appPaths.push(prefix); // WAC exemption under the mount (#582) + + const api = { + fastify, + prefix, + config: spec.config ?? {}, + log, + auth: { getAgent }, + storage: { + // Under the data root's dot-guard (like .idp): never served over LDP. + pluginDir() { + const dir = path.join(ctx.root, '.plugins', id); + fs.mkdirSync(dir, { recursive: true }); + return dir; + }, + }, + ws: { + async route(wsPath, handler) { + if (typeof wsPath !== 'string' || !wsPath.startsWith('/')) { + throw new Error(`plugin ${id}: ws.route path must start with '/'`); + } + if (!fastify.websocketServer) { + await fastify.register(websocket); + } + fastify.get(wsPath, { websocket: true }, (connection, request) => { + // @fastify/websocket v8 hands a SocketStream; the ws socket is + // .socket. Later majors hand the socket directly — accept both. + handler(connection.socket ?? connection, request); + }); + }, + }, + }; + + let result; + try { + result = await activate(api); + } catch (err) { + throw new Error(`plugin ${id}: activate() failed: ${err.message}`); + } + if (result && typeof result.deactivate === 'function') { + fastify.addHook('onClose', async () => { + try { + await result.deactivate(); + } catch (err) { + log.warn(`plugin ${id}: deactivate() failed: ${err.message}`); + } + }); + } + log.info(`plugin ${id} active${prefix ? ` at ${prefix}` : ''}`); + } +} diff --git a/src/server.js b/src/server.js index 773fcfa..3af231b 100644 --- a/src/server.js +++ b/src/server.js @@ -60,6 +60,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false) * @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false) + * @param {Array} options.plugins - App plugins to load (#206): [{ module, prefix, config, id }]. + * Each module's activate(api) runs at startup; prefix is WAC-exempted via appPaths. + * See src/plugins.js for the api surface. * @param {number} options.payCost - Cost per request in satoshis (default 1) * @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4) * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers @@ -117,6 +120,10 @@ export function createServer(options = {}) { .map((p) => p.trim().replace(/\/+$/, '')) // '/myapp/' matches like '/myapp' .filter((p) => p.startsWith('/') && p.length > 1) : []; + // App plugins (#206): loaded at startup, each entry's prefix joins + // appPaths. The WAC hook reads the array per request, so pushes made + // during plugin activation are honored. + const pluginEntries = Array.isArray(options.plugins) ? options.plugins : []; // ActivityPub federation is OFF by default const activitypubEnabled = options.activitypub ?? false; const apUsername = options.apUsername ?? 'me'; @@ -411,6 +418,20 @@ export function createServer(options = {}) { }); } + // Load app plugins (#206). Deferred into a register scope so the dynamic + // imports and async activation run during fastify's startup; a failing + // plugin fails listen() rather than leaving a half-configured server. + if (pluginEntries.length) { + fastify.register(async (instance) => { + const { loadPlugins } = await import('./plugins.js'); + await loadPlugins(instance, pluginEntries, { + appPaths, + root: options.root || process.env.DATA_ROOT || './data', + log: fastify.log, + }); + }); + } + // Register Nostr relay if enabled if (nostrEnabled) { fastify.register(async (instance) => { diff --git a/test/plugins.test.js b/test/plugins.test.js new file mode 100644 index 0000000..b36ff23 --- /dev/null +++ b/test/plugins.test.js @@ -0,0 +1,203 @@ +/** + * Plugin loader (#206) — createServer({ plugins }) end to end. + * + * A fixture plugin (written to disk per test run, in the #206 activate(api) + * shape both real consumers — Tideholm and bridge — already export) is + * loaded from config and verified against the whole api surface: HTTP + * routes under a WAC-exempt prefix (#582), auth.getAgent (#584), a + * WebSocket endpoint through ws.route (#588), private storage under the + * data root's dot-guard, config pass-through, and deactivate on close. + * Failure paths (missing module, no activate export, bad prefix) must fail + * listen() loudly rather than boot a server silently missing an app. + */ + +import { describe, it, before, after, afterEach } from 'node:test'; +import assert from 'node:assert'; +import path from 'path'; +import { WebSocket } from 'ws'; +import fs from 'fs-extra'; +import { createServer } from '../src/server.js'; + +const TEST_DATA_DIR = './test-data-plugins'; +const FIXTURE_DIR = './test-fixtures-plugins'; + +let server; +let baseUrl; +let originalDataRoot; + +// The fixture records activation evidence into this file so tests can +// assert on what the plugin saw (config, prefix, storage dir, deactivate). +const EVIDENCE = path.resolve(FIXTURE_DIR, 'evidence.json'); + +const FIXTURE_PLUGIN = ` +import fs from 'fs'; + +export async function activate(api) { + const dir = api.storage.pluginDir(); + const evidence = { + prefix: api.prefix, + config: api.config, + pluginDir: dir, + hasGetAgent: typeof api.auth.getAgent === 'function', + deactivated: false, + }; + const record = () => + fs.writeFileSync(${JSON.stringify(EVIDENCE)}, JSON.stringify(evidence)); + record(); + + api.fastify.all(api.prefix + '/echo', async (request, reply) => { + const agent = await api.auth.getAgent(request); + reply.code(200).send({ app: true, method: request.method, agent }); + }); + + await api.ws.route(api.prefix + '/ws', (socket) => { + socket.on('message', (data) => socket.send('pong:' + String(data))); + }); + + return { + deactivate() { + evidence.deactivated = true; + record(); + }, + }; +} +`; + +async function startWith(plugins) { + await fs.emptyDir(TEST_DATA_DIR); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: TEST_DATA_DIR, + plugins, + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + const address = server.server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +} + +function evidence() { + return JSON.parse(fs.readFileSync(EVIDENCE, 'utf8')); +} + +describe('plugin loader (#206)', () => { + before(async () => { + originalDataRoot = process.env.DATA_ROOT; + await fs.emptyDir(FIXTURE_DIR); + await fs.writeFile(path.join(FIXTURE_DIR, 'fixture-plugin.js'), FIXTURE_PLUGIN); + }); + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + await fs.remove(TEST_DATA_DIR); + }); + + after(async () => { + await fs.remove(FIXTURE_DIR); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + }); + + it('loads a plugin from config and serves its routes under a WAC-exempt prefix', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game', config: { bots: 3 } }, + ]); + // Unauthenticated POST reaches the app: the prefix joined appPaths. + const res = await fetch(`${baseUrl}/game/echo`, { method: 'POST' }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.app, true); + assert.strictEqual(body.method, 'POST'); + assert.strictEqual(body.agent, null); // getAgent callable, anon -> null + }); + + it('passes prefix and config through to activate()', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game/', config: { bots: 3 } }, + ]); + const seen = evidence(); + assert.strictEqual(seen.prefix, '/game'); // trailing slash normalized + assert.deepStrictEqual(seen.config, { bots: 3 }); + assert.strictEqual(seen.hasGetAgent, true); + }); + + it('ws.route serves a WebSocket endpoint under the prefix', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game' }, + ]); + const ws = new WebSocket(`${baseUrl.replace('http', 'ws')}/game/ws`); + await new Promise((resolve, reject) => { + ws.on('open', resolve); + ws.on('error', reject); + }); + const reply = await new Promise((resolve, reject) => { + ws.on('message', (data) => resolve(String(data))); + ws.on('error', reject); + ws.send('hello'); + }); + assert.strictEqual(reply, 'pong:hello'); + ws.close(); + }); + + it('pluginDir is created under the data root and shielded from LDP', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game' }, + ]); + const seen = evidence(); + assert.ok(seen.pluginDir.includes(path.join('.plugins', 'fixture-plugin'))); + assert.ok(fs.existsSync(seen.pluginDir)); + // Write a secret; the dot-guard must keep it unreachable over HTTP. + await fs.writeFile(path.join(seen.pluginDir, 'secret.txt'), 'hush'); + const res = await fetch(`${baseUrl}/.plugins/fixture-plugin/secret.txt`); + assert.notStrictEqual(res.status, 200); + }); + + it('deactivate() runs on server close', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game' }, + ]); + assert.strictEqual(evidence().deactivated, false); + await server.close(); + server = null; + assert.strictEqual(evidence().deactivated, true); + }); + + it('sibling LDP paths keep full WAC enforcement', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game' }, + ]); + const res = await fetch(`${baseUrl}/somepod/private/thing`, { method: 'PUT', body: 'x' }); + assert.ok([401, 403].includes(res.status), `expected WAC rejection, got ${res.status}`); + }); + + it('a plugin that cannot be imported fails listen() loudly', async () => { + await fs.emptyDir(TEST_DATA_DIR); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: TEST_DATA_DIR, + plugins: [{ module: `${FIXTURE_DIR}/no-such-plugin.js`, prefix: '/x' }], + }); + await assert.rejects( + server.listen({ port: 0, host: '127.0.0.1' }), + /cannot import/, + ); + }); + + it('an invalid prefix fails listen() loudly', async () => { + await fs.emptyDir(TEST_DATA_DIR); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: TEST_DATA_DIR, + plugins: [{ module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: 'game' }], + }); + await assert.rejects( + server.listen({ port: 0, host: '127.0.0.1' }), + /invalid prefix/, + ); + }); +}); From b334fbd58f172dfb9849f5ce1c6a6ca6a68093c6 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 10 Jul 2026 23:19:31 +0200 Subject: [PATCH 2/3] review: plugins docs section, collision-resistant ids, guarded ws handlers - docs/configuration.md gains an App Plugins section beside appPaths and getAgent: entry shape, the automatic appPaths exemption, the activate api, and the fail-loudly contract - pluginId derives from the full specifier for bare package imports (@scope1/pkg and @scope2/pkg no longer collide) but keeps the basename for file paths, where a machine-specific prefix must not name the data dir; duplicate ids across entries now fail the boot instead of sharing storage - ws.route wraps plugin handlers: a sync throw or rejected promise logs and terminates the one affected socket instead of surfacing as an unhandled rejection in the host --- docs/configuration.md | 44 +++++++++++++++++++++++++++++++++++++++++++ src/plugins.js | 35 +++++++++++++++++++++++++++++++--- test/plugins.test.js | 42 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e031832..8928b91 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -389,6 +389,50 @@ for the design discussion and for a complete example (a multiplayer game where pod WebIDs are the player accounts). +## App Plugins (plugins) + +The plugin loader +([#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206)) +does the appPaths wiring for you: declare the apps in config and the server +imports, mounts, and tears them down itself. + +```js +const fastify = createServer({ + root: './data', + idp: true, + plugins: [ + { module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm', + config: { bots: 8 } }, + { module: './my-app/plugin.js', prefix: '/myapp' }, + ], +}); +``` + +Each entry: + +- `module` — import specifier: a package path (resolved from JSS's module + graph) or a file path (`./…` or absolute, resolved from the process cwd). + The module exports `activate(api)`, called during startup. +- `prefix` — the app's mount point. Added to `appPaths` automatically, so + the app owns authentication below it (see the section above). Must start + with `/`; invalid prefixes fail startup. +- `config` — passed to the plugin verbatim as `api.config`. +- `id` — optional stable identifier (defaults to a name derived from + `module`); names the plugin's private data dir, so set it explicitly if + you load two plugins whose specifiers reduce to the same name. + +`activate(api)` receives: `api.fastify` (register routes here), +`api.prefix`, `api.config`, `api.log`, `api.auth.getAgent(request)` +(identity, as above), `api.storage.pluginDir()` (a private server-side +directory under the data root, never served over HTTP), and +`api.ws.route(path, (socket, request) => {})` for WebSocket endpoints — +routed through the same upgrade path as the built-in realtime features, so +plugins never attach their own `'upgrade'` listener. Return +`{ deactivate }` to run teardown (state saves, timers) on server close. + +A plugin that fails to import or activate fails `listen()` loudly rather +than booting a server silently missing an app. + ## Storage Quotas Limit storage per pod to prevent abuse and manage resources: diff --git a/src/plugins.js b/src/plugins.js index 503939c..9d86f8f 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -67,11 +67,24 @@ export function normalizePrefix(p) { return trimmed.startsWith('/') && trimmed.length > 1 ? trimmed : ''; } -/** Directory-safe plugin id: from entry.id or derived from the module spec. */ +/** + * Directory-safe plugin id: entry.id, or derived from the module spec. + * Bare package specifiers keep their full path ('@scope/pkg/plugin.js' -> + * 'scope-pkg-plugin') so same-named files in different packages don't + * collide; file paths use the basename, because a machine-specific + * directory prefix must not name the plugin's data dir (the id — and with + * it pluginDir — would change whenever the deployment moves). The loader + * additionally rejects duplicate ids, so any residual collision fails the + * boot instead of silently sharing storage. + */ export function pluginId(spec) { + const module = String(spec.module); const raw = typeof spec.id === 'string' && spec.id ? spec.id - : path.basename(String(spec.module)).replace(/\.[cm]?js$/, ''); + : (module.startsWith('.') || path.isAbsolute(module) + ? path.basename(module) + : module + ).replace(/\.[cm]?js$/, ''); const id = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); if (!id) throw new Error(`plugins: cannot derive an id from ${JSON.stringify(spec.module)}; set entry.id`); return id; @@ -88,12 +101,17 @@ export function pluginId(spec) { */ export async function loadPlugins(fastify, entries, ctx) { const log = makePluginLog(ctx.log); + const seenIds = new Set(); for (const entry of entries) { const spec = typeof entry === 'string' ? { module: entry } : entry; if (!spec || typeof spec.module !== 'string' || !spec.module) { throw new Error('plugins: each entry needs a module (import specifier or path)'); } const id = pluginId(spec); + if (seenIds.has(id)) { + throw new Error(`plugins: duplicate id '${id}' — set entry.id to keep the plugins' data dirs apart`); + } + seenIds.add(id); // Paths resolve from the operator's cwd; bare specifiers stay package // imports resolved from JSS's own module graph. @@ -142,7 +160,18 @@ export async function loadPlugins(fastify, entries, ctx) { fastify.get(wsPath, { websocket: true }, (connection, request) => { // @fastify/websocket v8 hands a SocketStream; the ws socket is // .socket. Later majors hand the socket directly — accept both. - handler(connection.socket ?? connection, request); + const socket = connection.socket ?? connection; + // A plugin bug here must not become an unhandled rejection that + // takes the host down: log it and close the one affected socket. + try { + Promise.resolve(handler(socket, request)).catch((err) => { + log.error(`plugin ${id}: ws handler failed: ${err.message}`); + socket.terminate?.(); + }); + } catch (err) { + log.error(`plugin ${id}: ws handler failed: ${err.message}`); + socket.terminate?.(); + } }); }, }, diff --git a/test/plugins.test.js b/test/plugins.test.js index b36ff23..9089ac6 100644 --- a/test/plugins.test.js +++ b/test/plugins.test.js @@ -17,6 +17,7 @@ import path from 'path'; import { WebSocket } from 'ws'; import fs from 'fs-extra'; import { createServer } from '../src/server.js'; +import { pluginId } from '../src/plugins.js'; const TEST_DATA_DIR = './test-data-plugins'; const FIXTURE_DIR = './test-fixtures-plugins'; @@ -53,6 +54,9 @@ export async function activate(api) { await api.ws.route(api.prefix + '/ws', (socket) => { socket.on('message', (data) => socket.send('pong:' + String(data))); }); + await api.ws.route(api.prefix + '/ws-throw', () => { + throw new Error('plugin bug'); + }); return { deactivate() { @@ -187,6 +191,44 @@ describe('plugin loader (#206)', () => { ); }); + it('a throwing ws handler closes that socket but not the server', async () => { + await startWith([ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/game' }, + ]); + const ws = new WebSocket(`${baseUrl.replace('http', 'ws')}/game/ws-throw`); + await new Promise((resolve) => { + ws.on('close', resolve); + ws.on('error', resolve); + }); + // The host survives its plugin's bug. + const res = await fetch(`${baseUrl}/game/echo`); + assert.strictEqual(res.status, 200); + }); + + it('derives collision-resistant ids and rejects duplicates', async () => { + // Bare specifiers keep their full path; file paths use the basename. + assert.strictEqual(pluginId({ module: '@scope1/pkg/plugin.js' }), 'scope1-pkg-plugin'); + assert.strictEqual(pluginId({ module: '@scope2/pkg/plugin.js' }), 'scope2-pkg-plugin'); + assert.strictEqual(pluginId({ module: '/some/machine/path/foo.js' }), 'foo'); + assert.strictEqual(pluginId({ module: './x.js', id: 'Custom Id!' }), 'custom-id'); + + // Two entries reducing to the same id fail the boot, not share a dir. + await fs.emptyDir(TEST_DATA_DIR); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: TEST_DATA_DIR, + plugins: [ + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/a' }, + { module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: '/b' }, + ], + }); + await assert.rejects( + server.listen({ port: 0, host: '127.0.0.1' }), + /duplicate id/, + ); + }); + it('an invalid prefix fails listen() loudly', async () => { await fs.emptyDir(TEST_DATA_DIR); server = createServer({ From 30d6ce59a8a4119d9383d203ad9a1ce125e17d0e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 00:09:49 +0200 Subject: [PATCH 3/3] review: any provided prefix must validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prefix: '' (or another falsy value) previously skipped validation and mounted the plugin without its appPaths exemption — behind WAC, contra the documented contract. Only an omitted prefix now means 'none'; everything else must normalize to a valid mount point. --- src/plugins.js | 7 +++++-- test/plugins.test.js | 29 ++++++++++++++++++----------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/plugins.js b/src/plugins.js index 9d86f8f..04e16b8 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -129,9 +129,12 @@ export async function loadPlugins(fastify, entries, ctx) { throw new Error(`plugin ${id}: module exports no activate(api) function`); } + // Any provided prefix must validate — a falsy one ('', 0) silently + // skipping the appPaths exemption would mount the app behind WAC. + // Omit the property entirely for a plugin with no mount prefix. const prefix = normalizePrefix(spec.prefix); - if (spec.prefix && !prefix) { - throw new Error(`plugin ${id}: invalid prefix ${JSON.stringify(spec.prefix)} (must start with '/')`); + if (spec.prefix !== undefined && !prefix) { + throw new Error(`plugin ${id}: invalid prefix ${JSON.stringify(spec.prefix)} (must start with '/'; omit for none)`); } if (prefix) ctx.appPaths.push(prefix); // WAC exemption under the mount (#582) diff --git a/test/plugins.test.js b/test/plugins.test.js index 9089ac6..cfe42d3 100644 --- a/test/plugins.test.js +++ b/test/plugins.test.js @@ -230,16 +230,23 @@ describe('plugin loader (#206)', () => { }); it('an invalid prefix fails listen() loudly', async () => { - await fs.emptyDir(TEST_DATA_DIR); - server = createServer({ - logger: false, - forceCloseConnections: true, - root: TEST_DATA_DIR, - plugins: [{ module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix: 'game' }], - }); - await assert.rejects( - server.listen({ port: 0, host: '127.0.0.1' }), - /invalid prefix/, - ); + // Any provided prefix must validate — including falsy ones, which would + // otherwise mount the app without its WAC exemption. + for (const prefix of ['game', '', '/', 0, null]) { + await fs.emptyDir(TEST_DATA_DIR); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: TEST_DATA_DIR, + plugins: [{ module: `${FIXTURE_DIR}/fixture-plugin.js`, prefix }], + }); + await assert.rejects( + server.listen({ port: 0, host: '127.0.0.1' }), + /invalid prefix/, + `prefix ${JSON.stringify(prefix)} should be rejected`, + ); + await server.close(); + server = null; + } }); });