-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathplugins.js
More file actions
463 lines (445 loc) · 22.3 KB
/
Copy pathplugins.js
File metadata and controls
463 lines (445 loc) · 22.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
/**
* 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.serverInfo() -> { baseUrl, protocol, host, port, listening } (#601)
* api.reservePath(path) claim + WAC-exempt a protocol-pinned path (#602)
* api.plugins -> [{ id, prefix, module }] of every loaded entry (#610)
* 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') };
}
/**
* Compile a parameterized reservation like '/:user/did.json' (#602) to a
* matcher. ':name' segments match one path segment; everything else is
* literal. Parameterized reservations match the exact path shape (plus an
* optional query string) — NOT a subtree — because they exist for pinned
* documents inside otherwise WAC-governed namespaces (did:web), where
* exempting a whole subtree would be a WAC bypass.
*/
// Only a segment that is ENTIRELY ':name' is a parameter. '/:user.json'
// is literal (its own segment mixes a param sigil with literal text) —
// treating it as a wildcard would exempt a far broader set of URLs than
// the ':name'-per-segment contract promises.
const PARAM_SEGMENT = /^:[A-Za-z_][A-Za-z0-9_]*$/;
export function isParamPath(p) {
return p.split('/').some((seg) => PARAM_SEGMENT.test(seg));
}
export function compilePathPattern(p) {
const pattern = p
.split('/')
// Exclude '?' as well as '/': a param must be a single PATH segment,
// so it must not swallow the query delimiter — otherwise a query
// containing '/' (e.g. /alice?x=a/b) would fail to match a shape the
// path part satisfies, making matching depend on query contents.
.map((seg) => (PARAM_SEGMENT.test(seg)
? '[^/?]+'
: seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
.join('/');
return new RegExp(`^${pattern}(?:\\?.*)?$`);
}
/**
* A literal reservation ('/xrpc') claims its whole subtree — the path
* itself, everything under it, and either plus a query string.
*/
export function compileSubtreePattern(p) {
const esc = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${esc}(?:/.*)?(?:\\?.*)?$`);
}
// Collision key: two reservations conflict when they exempt the same
// URLs, so parameter NAMES don't matter — '/:user/did.json' and
// '/:acct/did.json' are the same claim. Each segment is tagged by TYPE
// (param vs literal) so a canonicalized param can't alias a literal
// segment that happens to be the placeholder text (e.g. a literal '/:/…').
// '\x00' never occurs in a real path, so it's a safe framing prefix.
export function reservationKey(p) {
return p.split('/')
.map((seg) => (PARAM_SEGMENT.test(seg) ? '\x00P' : `\x00L${seg}`))
.join('/');
}
/** 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 : '';
}
// Basenames too generic to name a plugin: the near-universal
// '<name>/plugin.js' convention means every such file would derive the same
// id and collide. For these, the id falls back to the parent directory (#596).
const GENERIC_BASENAMES = new Set(['plugin', 'index']);
/**
* The distinguishing name of a plugin FILE: its basename, unless that is
* generic ('plugin.js', 'index.js'), in which case the immediate parent
* directory ('relay/plugin.js' -> 'relay'). The parent directory
* distinguishes '<name>/plugin.js' files from each other WITHOUT pinning the
* machine-specific path prefix (which would move the id — and pluginDir —
* with the deployment). Falls back to the basename when there is no usable
* parent (e.g. './plugin.js' at the cwd root). (#596)
*/
export function fileStem(module) {
const withoutExt = path.basename(module).replace(/\.[cm]?js$/, '');
if (GENERIC_BASENAMES.has(withoutExt.toLowerCase())) {
const parent = path.basename(path.dirname(module));
// The parent directory name is returned AS-IS — a directory legitimately
// named 'foo.js' must stay 'foo.js', not be extension-stripped to 'foo'.
if (parent && parent !== '.' && parent !== '..') return parent;
}
return withoutExt;
}
/**
* 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 — or, for a generic basename, the
* parent directory (see fileStem, #596) — 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);
// The extension is stripped where it applies — the file basename (via
// fileStem) and a bare specifier's tail — but NOT a parent directory name
// that fileStem may return for a generic basename.
let raw;
if (typeof spec.id === 'string' && spec.id) {
raw = spec.id.replace(/\.[cm]?js$/, '');
} else if (module.startsWith('.') || path.isAbsolute(module)) {
raw = fileStem(module);
} else {
raw = 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);
const seenIds = new Set();
// reservePath claims (#602), shared across all entries of this load so
// two plugins reserving the same pinned path fail the boot with both
// names — loud beats the silent-loser outcome witnessed with webfinger
// vs remotestorage.
const reservations = new Map();
// Read-only roster of every loaded entry (#610): { id, prefix, module }
// for each. Computed up front, before any activate() runs, so a plugin
// whose job is describing the deployment (a status dashboard, an admin
// console) sees the FULL set regardless of load order — rather than being
// hand-fed a duplicate of the operator's plugins array that silently
// drifts. Frozen so one plugin can't mutate another's view; it includes
// the plugin itself (consumers filter). Lenient here (never throws) — the
// load loop below owns validation and fails the boot on a bad entry, so
// on any successful boot every id/prefix matches what the loop derives.
// A boot-time snapshot: there is no runtime add/remove yet.
const roster = Object.freeze(entries.map((entry) => {
const spec = typeof entry === 'string' ? { module: entry } : (entry ?? {});
// Only surface a well-formed module — a non-string/empty one makes the
// load loop below fail the boot, so don't fabricate a coerced value
// (e.g. "[object Object]") for the transient pre-failure window. Mirrors
// the loop's own `typeof spec.module === 'string'` gate; id is derived
// only from a valid module.
const module = typeof spec.module === 'string' && spec.module ? spec.module : null;
let id = null;
try { if (module) id = pluginId(spec); } catch { /* the loop reports it */ }
return Object.freeze({ id, prefix: normalizePrefix(spec.prefix), module });
}));
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.
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`);
}
// 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 !== 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)
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;
},
},
// The server's own origin (#601) — for minting absolute URLs and
// loopback calls, so plugins stop repeating baseUrl in config where
// a wrong value fails quietly. A function, not a snapshot: with
// port 0 the real port exists only once the server is listening,
// so call it lazily (per request, or in an onListen hook via
// api.fastify) rather than caching the result during activate.
// An explicit idpIssuer is the deployment's canonical public origin
// and wins over the host:port derivation, mirroring the pod-seeding
// logic in server.js.
// Reserve a path the protocol pins outside the plugin's prefix
// (#602): fixed roots like /xrpc or /_matrix (literal — exempts the
// subtree), or parameterized documents like /:user/did.json
// (exact-shape match only; see compilePathPattern). Both are
// read-only by default and method-gated ({ methods } to widen) —
// see the exemption note below. The claim is deliberate and
// cross-plugin: a second plugin reserving the same path fails the
// boot naming both claimants, instead of one silently losing.
// Registering the routes is still the plugin's job via api.fastify.
reservePath(p, opts = {}) {
// Tolerate a null/garbage opts (treat as omitted), but a provided
// non-array `methods` is a caller mistake worth a targeted error
// rather than an opaque TypeError from .map deep in activate.
const options = opts && typeof opts === 'object' ? opts : {};
if (options.methods !== undefined && !Array.isArray(options.methods)) {
throw new Error(`plugin ${id}: reservePath { methods } must be an array, got ${JSON.stringify(options.methods)}`);
}
// Validate AFTER normalization: '//' or '/ ' normalize to '',
// and an empty string in appPaths would match every URL in the
// WAC hook — a one-call total WAC bypass.
const key = typeof p === 'string' ? p.trim().replace(/\/+$/, '') : '';
if (!key.startsWith('/') || key.length < 2) {
throw new Error(`plugin ${id}: reservePath needs an absolute path, got ${JSON.stringify(p)}`);
}
// A reservation is a pathname, not a URL: '?'/'#' don't belong.
// A query in the string would be escaped into the matcher (tying
// the exemption to that exact query), and fragments never reach
// request.url — both silently produce a reservation that matches
// nothing real.
if (key.includes('?') || key.includes('#')) {
throw new Error(`plugin ${id}: reservePath must be a pathname without '?' or '#', got ${JSON.stringify(p)}`);
}
// Track by the shape, not the raw string: '/:user/did.json' and
// '/:acct/did.json' exempt the same URLs, so they're the same
// claim and must collide loudly like two literal reservations.
const claim = reservationKey(key);
const holder = reservations.get(claim);
if (holder === id) {
return; // idempotent re-claim — the matcher is already installed
}
if (holder) {
throw new Error(`plugin ${id}: path '${key}' is already reserved by plugin '${holder}'`);
}
reservations.set(claim, id);
// Read-only by default for BOTH lanes: a reserved path is
// WAC-exempt, and the LDP write wildcards (PUT/POST/PATCH/DELETE
// '/*') sit underneath it. Exempting a write method the plugin
// has NOT implemented would let that write fall through to the
// LDP handlers as an unauthenticated storage write — the exact
// trap core installs 405 blocks for under /.well-known/*. Widen
// with { methods } when the protocol genuinely needs writes.
const methods = new Set((options.methods ?? ['GET', 'HEAD', 'OPTIONS'])
.map((m) => String(m).toUpperCase()));
// Parameterized shapes match exactly (they live inside a
// WAC-governed pod namespace); literals claim their subtree.
const re = isParamPath(key) ? compilePathPattern(key) : compileSubtreePattern(key);
ctx.appPathPatterns?.push({ re, methods });
log.info(`plugin ${id} reserved ${key} [${[...methods].join(', ')}]`);
},
serverInfo() {
const o = ctx.origin ?? {};
const addr = fastify.server?.listening ? fastify.server.address() : null;
const live = addr && typeof addr === 'object' ? addr : null;
// Once listening, the live bind wins over configured values —
// listen() may be called with a different host/port than
// createServer() was given (tests do exactly this).
const port = live?.port ?? o.port ?? null;
const rawHost = live?.address ?? o.host;
// Unspecified binds aren't callable addresses; report the
// loopback name instead. IPv6 literals need brackets in URLs.
const host = !rawHost || rawHost === '0.0.0.0' || rawHost === '::' ? 'localhost' : rawHost;
const protocol = o.ssl ? 'https' : 'http';
const urlHost = host.includes(':') ? `[${host}]` : host;
const baseUrl = o.baseUrl || `${protocol}://${urlHost}:${port}`;
return { baseUrl, protocol, host, port, listening: !!live };
},
// Every loaded plugin's { id, prefix, module } (#610), read-only — so
// a plugin can enumerate its co-loaded siblings instead of being
// handed a copy of the operator's plugins array. Includes this
// plugin; consumers filter themselves out. A frozen boot-time
// snapshot (see `roster` above).
plugins: roster,
// Mount a node-style (req, res) handler — a wrapped HTTP app, reverse
// proxy, or framework adapter — under the plugin's prefix (#583). This
// bundles the four things every such plugin needs and otherwise
// rediscovers: the appPaths WAC exemption (already applied above), a
// scoped pass-through content parser so the wrapped app receives an
// unconsumed body stream, reply.hijack() so Fastify releases the
// response, and registration on both the bare prefix and its subtree.
// Without the scoped parser, Fastify drains the request stream before
// the handler runs and any body-reading app hangs forever.
async mountApp(handler, opts = {}) {
if (typeof handler !== 'function') {
throw new Error(`plugin ${id}: mountApp(handler) needs a (req, res) function`);
}
// Any provided prefix must validate — same rule as entry.prefix: a
// falsy normalization silently falling back to the entry prefix
// would mount the app (and WAC-exempt it) somewhere unexpected.
const secondary = normalizePrefix(opts.prefix);
if (opts.prefix !== undefined && !secondary) {
throw new Error(`plugin ${id}: mountApp invalid prefix ${JSON.stringify(opts.prefix)} (must start with '/'; omit to use the entry prefix)`);
}
const mountPrefix = secondary || prefix;
if (!mountPrefix) {
throw new Error(`plugin ${id}: mountApp needs a prefix (entry.prefix or opts.prefix)`);
}
if (mountPrefix !== prefix && !ctx.appPaths.includes(mountPrefix)) {
ctx.appPaths.push(mountPrefix); // exempt a secondary mount too
}
await fastify.register(async (scope) => {
scope.removeAllContentTypeParsers();
scope.addContentTypeParser('*', (req, payload, done) => done(null, payload));
// After hijack() Fastify sends nothing, so a handler bug must not
// hang the client or become an unhandled rejection (same contract
// as ws.route below): log, answer 500 if nothing went out yet,
// else drop the one affected socket.
const fail = (res, err) => {
log.error({ err }, `plugin ${id}: mounted app handler failed`);
if (!res.headersSent && !res.writableEnded) {
res.statusCode = 500;
res.end();
} else {
res.destroy();
}
};
const wrapped = (request, reply) => {
reply.hijack();
try {
Promise.resolve(handler(request.raw, reply.raw))
.catch((err) => fail(reply.raw, err));
} catch (err) {
fail(reply.raw, err);
}
};
scope.all(mountPrefix, wrapped);
scope.all(mountPrefix + '/*', wrapped);
});
},
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.
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({ err }, `plugin ${id}: ws handler failed`);
socket.terminate?.();
});
} catch (err) {
log.error({ err }, `plugin ${id}: ws handler failed`);
socket.terminate?.();
}
});
},
},
};
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}` : ''}`);
}
}