feat(plugins): api.reservePath — claim protocol-pinned paths (#602)#607
Conversation
A plugin could register routes outside its prefix but not WAC-exempt them, so fixed protocol roots (/xrpc, /_matrix, /api) 401'd until the operator hand-passed appPaths, and pinned documents inside pod namespaces (/:user/did.json) couldn't be expressed at all. reservePath(path, opts?): - literal paths behave like an entry prefix — the plugin owns the subtree, every method - parameterized paths (:name = one segment) compile to exact-shape matchers, read-only by default (GET/HEAD/OPTIONS, opts.methods to widen) — they live inside WAC-governed namespaces, where a subtree or write exemption would be a WAC bypass; without the method gate a PUT to the reserved shape would skip WAC and reach the LDP wildcard as an unauthenticated pod write - claims are cross-plugin: a second claimant fails the boot naming both plugins, replacing the loud-failure-or-silent-loser lottery witnessed with webfinger vs remotestorage The shared-document case (both plugins own parts of one JRD) is a different primitive — split to #606. Five tests incl. the write-stays-guarded gate. Full suite 1040/1040.
There was a problem hiding this comment.
Pull request overview
This PR adds a new plugin-loader seam, api.reservePath, so plugins can explicitly claim and WAC-exempt protocol-pinned routes that live outside their configured mount prefix (including fixed roots like /xrpc and parameterized document shapes like /:user/did.json).
Changes:
- Add
api.reservePath()to the plugin API, including cross-plugin reservation tracking and method-gated parameterized reservations. - Extend the server’s WAC skip logic to support compiled “exact-shape” path matchers for parameterized reservations.
- Add targeted tests for the new behavior and document the API in the configuration docs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| test/plugin-reservepath.test.js | Adds test coverage for fixed-root exemptions, parameterized exact-shape exemptions, write-guarding via method gating, and duplicate-claim boot failure. |
| src/server.js | Introduces appPathPatterns and checks them in the WAC skip logic. |
| src/plugins.js | Implements api.reservePath() and compilePathPattern(), plus cross-plugin reservation bookkeeping. |
| docs/configuration.md | Documents how/why to use api.reservePath() for literal and parameterized protocol-pinned paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (typeof p !== 'string' || !p.startsWith('/') || p.length < 2) { | ||
| throw new Error(`plugin ${id}: reservePath needs an absolute path, got ${JSON.stringify(p)}`); | ||
| } | ||
| const key = p.trim().replace(/\/+$/, ''); | ||
| const holder = reservations.get(key); | ||
| if (holder && holder !== id) { | ||
| throw new Error(`plugin ${id}: path '${key}' is already reserved by plugin '${holder}'`); | ||
| } | ||
| reservations.set(key, id); |
There was a problem hiding this comment.
Confirmed — nasty one, thanks. Fixed in 8ff4f9e: validation now runs on the normalized key, so '//' and '/ ' are refused at activate instead of wiping WAC. Regression test boots a '//' claimant and asserts the rejection. 1041/1041.
'//' (or '/ ') passed the pre-check but normalized to '', and an empty string in appPaths startsWith-matches every URL — one bad reservePath call disabled WAC wholesale. Validation now runs on the normalized key. Regression test boots a '//' claimant and asserts the loud refusal. 1041/1041.
| export function compilePathPattern(p) { | ||
| const pattern = p | ||
| .split('/') | ||
| .map((seg) => (seg.startsWith(':') && seg.length > 1 | ||
| ? '[^/]+' | ||
| : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) | ||
| .join('/'); | ||
| return new RegExp(`^${pattern}(?:\\?.*)?$`); | ||
| } |
There was a problem hiding this comment.
Both confirmed and fixed in 25c109f. Collisions now key on the canonicalized shape (param names ignored), so same-shape/different-name claims fail the boot; and only a segment that is entirely ':name' is a param, so '/:user.json' is literal (exempts itself, not every /*.json). Tests for both. 1043/1043.
| 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)}`); | ||
| } | ||
| const holder = reservations.get(key); | ||
| if (holder && holder !== id) { | ||
| throw new Error(`plugin ${id}: path '${key}' is already reserved by plugin '${holder}'`); | ||
| } | ||
| reservations.set(key, id); |
There was a problem hiding this comment.
Both confirmed and fixed in 25c109f. Collisions now key on the canonicalized shape (param names ignored), so same-shape/different-name claims fail the boot; and only a segment that is entirely ':name' is a param, so '/:user.json' is literal (exempts itself, not every /*.json). Tests for both. 1043/1043.
Two WAC-exemption soundness gaps: - collisions were keyed on the raw string, so '/:user/did.json' and '/:acct/did.json' — same compiled matcher, same exempted URLs — didn't collide. Now keyed on the shape (param names canonicalized), so same-shape claims fail the boot like literal ones. - compilePathPattern treated any segment STARTING with ':' as a param, so '/:user.json' compiled to '/[^/]+' and exempted every '/*.json' — far broader than the ':name'-per-segment contract. Only a segment that is entirely ':name' is a param now; '/:user.json' is literal. Both extracted to isParamPath/reservationKey/PARAM_SEGMENT. Two tests: same-shape different-param collides; '/:user.json' exempts only itself while a real sibling stays WAC-guarded. 1043/1043.
| // 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. Canonicalize param segments. | ||
| export function reservationKey(p) { | ||
| return p.split('/').map((seg) => (PARAM_SEGMENT.test(seg) ? ':' : seg)).join('/'); | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in b42c942 — reservationKey now type-tags each segment (param → \x00P, literal → \x00L), so a canonicalized param can't alias a literal ':' segment. Test asserts '/:/x' (literal) and '/:user/x' (param) coexist without a false collision. 1044/1044.
reservationKey mapped param segments to the literal string ':', which could false-collide with a literal ':' segment (a valid, if exotic, '/:/x' reservation) and wrongly reject it as already-claimed. Each segment is now tagged param-vs-literal with a '\x00' frame (never in a real path), so only genuinely identical shapes collide. Test: a literal ':' segment and a param shape coexist without a false collision. 1044/1044.
| (terminalEnabled && (request.url === '/.terminal' || request.url.startsWith('/.terminal?'))) || | ||
| (tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) || | ||
| appPaths.some(p => request.url === p || request.url.startsWith(p + '/') || request.url.startsWith(p + '?')) || | ||
| appPathPatterns.some(m => m.re.test(request.url) && m.methods.has(request.method)) || | ||
| mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { |
There was a problem hiding this comment.
Both fixed in 31c99b4: param class is now [^/?]+ so a query containing '/' no longer breaks shape matching (test pins /alice/did.json?redirect=/a/b), and the WAC-skip check tests methods before the regex so write traffic skips it entirely. 1045/1045.
| export function compilePathPattern(p) { | ||
| const pattern = p | ||
| .split('/') | ||
| .map((seg) => (PARAM_SEGMENT.test(seg) | ||
| ? '[^/]+' | ||
| : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) | ||
| .join('/'); | ||
| return new RegExp(`^${pattern}(?:\\?.*)?$`); | ||
| } |
There was a problem hiding this comment.
Both fixed in 31c99b4: param class is now [^/?]+ so a query containing '/' no longer breaks shape matching (test pins /alice/did.json?redirect=/a/b), and the WAC-skip check tests methods before the regex so write traffic skips it entirely. 1045/1045.
- compilePathPattern param segments were [^/]+, which swallowed the query delimiter: a query containing '/' (e.g. /alice/did.json?r=/a/b) then failed to match a shape the path satisfies — matching depended on query contents. Now [^/?]+, so the trailing (?:\?.*)? handles all queries. Test pins a slash-bearing query. - WAC-skip check now tests m.methods before m.re: the read-only default set skips the regex entirely on write traffic. Pure perf, same result. 1045/1045.
| if (isParamPath(key)) { | ||
| // Parameterized reservations are method-gated, read-only by | ||
| // default: the URL shape lives inside a WAC-governed pod | ||
| // namespace, and exempting PUT/DELETE would hand the LDP | ||
| // fallthrough an unauthenticated write path. | ||
| const methods = new Set((opts.methods ?? ['GET', 'HEAD', 'OPTIONS']) | ||
| .map((m) => String(m).toUpperCase())); | ||
| ctx.appPathPatterns?.push({ re: compilePathPattern(key), methods }); | ||
| } else if (!ctx.appPaths.includes(key)) { |
There was a problem hiding this comment.
Fixed in 8d279b6 — a same-plugin re-claim now returns early before any push, so no duplicate matchers accumulate in either lane. The main fixture double-reserves its shape to pin the no-op. 1045/1045.
reservePath allowed a plugin to re-claim its own path but pushed a fresh regex into the WAC hook each time; repeated calls (shared helper, re-activation paths) accumulated duplicate per-request checks. A same-plugin re-claim now returns early before any push. The main fixture double-reserves its did.json shape to pin the no-op. 1045/1045.
| } else if (!ctx.appPaths.includes(key)) { | ||
| // Literal reservations behave exactly like an entry prefix: | ||
| // the plugin owns the subtree, every method. | ||
| ctx.appPaths.push(key); | ||
| } |
There was a problem hiding this comment.
Confirmed — real, and the same class core guards for /.well-known/*. Fixed in 9a2b41e: literal reservations are now read-only by default and method-gated too (not the all-methods appPaths lane), so PUT /xrpc/x with no handler is WAC-guarded; { methods } widens for genuine write shims. Verified 401/403. The residual you flagged — a widened method at an unhandled path, plus the pre-existing entry-prefix (#582) exposure — both reach the LDP wildcard and want a scoped fallback; that's broader than this seam so I filed #611 for it. 1047/1047.
Literal reservations went into the all-methods appPaths lane, so a
plugin claiming /xrpc also WAC-exempted every method under it — and the
LDP write wildcards (PUT/POST/... '/*') sit beneath, so PUT /xrpc/x with
no plugin handler fell through as an unauthenticated storage write. Same
trap core installs 405 blocks for under /.well-known/*.
Both lanes are now read-only by default and method-gated: literals match
their subtree via compileSubtreePattern, params match exactly, and
{ methods } widens either. A write under a read-only reservation is
WAC-guarded; a shim that needs writes opts in explicitly.
The residual (a widened method at an unhandled path, and the pre-existing
entry-prefix exposure, both reaching the LDP wildcard) is broader than
this seam — filed as #611. Two tests: write-stays-guarded, widen-to-serve.
1047/1047.
| claim them with `api.reservePath(path)`: | ||
|
|
||
| ```js | ||
| api.reservePath('/xrpc'); // fixed root — WAC-exempt subtree, all methods |
There was a problem hiding this comment.
Fixed in 82743b0 — stale 'all methods' corrected in both the doc example (now widens /xrpc with { methods } explicitly) and the loader-header comment; both now say read-only by default. Docs only, 1047/1047.
The prose already said read-only-by-default, but the doc example's inline
comment and the loader-header comment still said literals exempt 'all
methods' — the misleading-into-insecurity case (an author assuming writes
are exempt without registering handlers). Example now widens /xrpc
explicitly with { methods }; both comments corrected. Docs only.
| reservePath(p, opts = {}) { | ||
| // 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)}`); | ||
| } |
There was a problem hiding this comment.
Fixed in 95091a2 — reservePath now rejects '?' and '#' at validation (a reservation is a pathname; a query would escape into the matcher and a fragment never reaches request.url). Test boots a '/xrpc?x=1' claimant and asserts the refusal. 1048/1048.
A query in the reserved string was escaped into the matcher, tying the exemption to that exact query; a fragment never reaches request.url. Both silently produce a reservation that matches nothing real. Rejected at validation now, with a test. 1048/1048.
| const methods = new Set((opts.methods ?? ['GET', 'HEAD', 'OPTIONS']) | ||
| .map((m) => String(m).toUpperCase())); |
There was a problem hiding this comment.
Fixed in 48bdbf2 — a non-array { methods } now fails with a targeted 'must be an array' error instead of an opaque TypeError, and null/garbage opts is tolerated as omitted (read-only default). Tests for both. 1050/1050.
A non-array { methods } (e.g. 'GET') now fails with a targeted error
instead of an opaque TypeError from .map deep in activate; a null/garbage
opts is treated as omitted (read-only default). Two tests. 1050/1050.
Four plugin-API seams land, all surfaced by the out-of-tree plugin
experiment (github.com/JavaScriptSolidServer/plugins), each with a
consumer proving the need:
- api.mountApp(handler, { prefix }) (#583, #590): mount a wrapped
node-style (req, res) app — a reverse proxy or framework adapter —
under the plugin's prefix. Bundles the appPaths WAC exemption, a
scoped pass-through parser that hands the app an undrained body
stream, and reply.hijack(). The raw-body-mode answer to #583.
- api.serverInfo() -> { baseUrl, protocol, host, port, listening }
(#601, #605): a plugin learns the server's own origin instead of
repeating baseUrl/loopbackUrl in config, where a wrong value fails
quietly. A function, not a snapshot — with port 0 the real port
exists only once listening; an explicit idpIssuer wins as the
canonical baseUrl.
- api.reservePath(path, opts?) (#602, #607): claim and WAC-exempt a
route outside the plugin's prefix. Literal paths ('/xrpc') own their
subtree; parameterized paths ('/:user/did.json') match an exact
shape (read-only by default) for pinned documents inside pod
namespaces. Two plugins reserving the same path fail the boot with
both names — loud beats the silent-loser outcome.
- api.plugins -> [{ id, prefix, module }] (#610, #612): a read-only,
frozen boot-time roster of every loaded entry, so a plugin
describing the deployment (a status dashboard, an admin console)
enumerates its co-loaded siblings instead of being hand-fed a
duplicate of the operator's plugins array.
All documented in docs/configuration.md App Plugins. Remaining plugin
seams tracked upstream: api.events.onResourceChange (#603),
api.authorize (#604).
Closes #602 (the reservation primitive; the shared-document/JRD registry half is split to #606). Second of the four seams from the 33-plugin exercise — the most-hit one (7+ consumers).
What
Design
appPathsfrom operator config to plugin declaration — the plugin owns the subtree, all methods, same semantics as its entry prefix.:name= one path segment) compile to exact-shape matchers and are method-gated, read-only by default (GET/HEAD/OPTIONS,{ methods }to widen). This is the security-load-bearing part: the shapes live inside the pod's WAC-governed namespace, and without the gate aPUT /alice/did.jsonwould skip WAC and fall through to the LDP wildcard as an unauthenticated pod write. A test pins this.api.fastify; reservePath only handles exemption + claim bookkeeping.Tests
5 in
test/plugin-reservepath.test.js: fixed root serves unauthenticated, WAC intact elsewhere, pinned document serves, write to the reserved shape stays WAC-guarded, duplicate claim fails boot naming both. Full suite 1040/1040. Documented indocs/configuration.md+ loader header.