Skip to content

feat(plugins): api.reservePath — claim protocol-pinned paths (#602)#607

Merged
melvincarvalho merged 10 commits into
gh-pagesfrom
feat/602-reservepath
Jul 11, 2026
Merged

feat(plugins): api.reservePath — claim protocol-pinned paths (#602)#607
melvincarvalho merged 10 commits into
gh-pagesfrom
feat/602-reservepath

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

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

export async function activate(api) {
  api.reservePath('/xrpc');            // bluesky shim: fixed root, WAC-exempt subtree
  api.fastify.get('/xrpc/ping', ...);

  api.reservePath('/:user/did.json');  // did:web: pinned document, exact shape, read-only
  api.fastify.get('/:user/did.json', ...);
}

Design

  • Literal reservations generalize appPaths from operator config to plugin declaration — the plugin owns the subtree, all methods, same semantics as its entry prefix.
  • Parameterized reservations (: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 a PUT /alice/did.json would skip WAC and fall through to the LDP wildcard as an unauthenticated pod write. A test pins this.
  • Claims are cross-plugin and loud: a second plugin reserving the same path fails the boot naming both claimants — replacing the load-order lottery (boot error one way, silent link loss the other) witnessed with webfinger vs remotestorage.
  • Route registration stays the plugin's job via 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 in docs/configuration.md + loader header.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/plugins.js Outdated
Comment on lines +198 to +206
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/plugins.js
Comment on lines +73 to +81
export function compilePathPattern(p) {
const pattern = p
.split('/')
.map((seg) => (seg.startsWith(':') && seg.length > 1
? '[^/]+'
: seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
.join('/');
return new RegExp(`^${pattern}(?:\\?.*)?$`);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/plugins.js Outdated
Comment on lines +201 to +209
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/plugins.js
Comment on lines +93 to +98
// 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('/');
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/server.js
Comment on lines 764 to 768
(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 + '.'))) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/plugins.js
Comment on lines +83 to +91
export function compilePathPattern(p) {
const pattern = p
.split('/')
.map((seg) => (PARAM_SEGMENT.test(seg)
? '[^/]+'
: seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
.join('/');
return new RegExp(`^${pattern}(?:\\?.*)?$`);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/plugins.js Outdated
Comment on lines +240 to +248
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)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/plugins.js Outdated
Comment on lines +251 to +255
} 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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread docs/configuration.md Outdated
claim them with `api.reservePath(path)`:

```js
api.reservePath('/xrpc'); // fixed root — WAC-exempt subtree, all methods

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/plugins.js
Comment on lines +233 to +240
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)}`);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/plugins.js Outdated
Comment on lines +268 to +269
const methods = new Set((opts.methods ?? ['GET', 'HEAD', 'OPTIONS'])
.map((m) => String(m).toUpperCase()));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@melvincarvalho
melvincarvalho merged commit 9dadbc9 into gh-pages Jul 11, 2026
1 check passed
@melvincarvalho
melvincarvalho deleted the feat/602-reservepath branch July 11, 2026 13:45
melvincarvalho added a commit that referenced this pull request Jul 11, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin API seam: api.reservePath — an API-shim owns fixed routes outside its prefix and can't self-exempt them from WAC

2 participants