Skip to content

Commit 75bfdb3

Browse files
didweb: parameterized reservePath — the previously-impossible case, closed
api.reservePath('/:user/did.json') serves the spec-pinned DID document from INSIDE the pod's WAC-governed namespace — the case appPaths' literal prefixes structurally could not express and the README recorded as a wall. Read-only default, no widening; a new test proves the exemption doesn't leak (sibling resource still 401s, same-basename deeper path still 401s, anonymous PUT still 401s). /.well-known/did.json is now a deliberate, collision-guarded claim rather than blanket- exemption luck. New edge documented: the reservation matcher is length-unbounded while the :user route caps at maxParamLength=100, so a >100-char pod name falls through to LDP GET /* (harmless — method- gated read-only — but a real seam edge).
1 parent 719fe5d commit 75bfdb3

3 files changed

Lines changed: 122 additions & 86 deletions

File tree

didweb/README.md

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The port, if present, is percent-encoded into the host: `localhost:3000` →
1818
plugins: [{ module: 'didweb/plugin.js', prefix: '/didweb',
1919
config: {
2020
podsRoot: './data', // pod dirs to scan (finding 3)
21-
baseUrl: 'https://pod.example', // host for the did:web id (required)
21+
baseUrl: 'https://pod.example', // optional override; api.serverInfo() default (finding 3)
2222
actorPathTemplate: '/ap/<user>/actor', // optional → ActivityPubActor service
2323
serviceEndpoints: [ /* extra service entries, appended verbatim */ ],
2424
} }]
@@ -69,7 +69,8 @@ pod card at `podsRoot` if present, else from a minted server key.
6969

7070
- Served at **both** `/.well-known/did.json` (attempted; finding 1) and the
7171
contract-safe `<prefix>/did.json`; pathed DIDs at **both** `/<user>/did.json`
72-
(attempted, WAC-governed; finding 2) and `<prefix>/<user>/did.json`.
72+
(reserved via `api.reservePath('/:user/did.json')`, anonymous GET; finding 2
73+
— closed) and `<prefix>/<user>/did.json`.
7374
- `Access-Control-Allow-Origin: *` so any resolver/browser can fetch. Content
7475
type `application/did+json`. Pods scanned fresh per request. Unknown user →
7576
404 (on the WAC-exempt prefix mount).
@@ -87,33 +88,46 @@ pod card at `podsRoot` if present, else from a minted server key.
8788
hands over the real scoped Fastify instance and doesn't confine routes to
8889
`prefix`; an exact path outranks core's LDP `GET /*` wildcard; and core's
8990
auth preHandler blanket-exempts `/.well-known/*`. The registration is
90-
wrapped in try/catch and degrades to the prefix mount. Same candidate seam
91-
the other two name: a declared `wellKnown: [...]` / reserved-path api so the
92-
loader reserves the route, reports conflicts, and extends the WAC exemption
93-
deliberately instead of by coincidence.
91+
wrapped in try/catch and degrades to the prefix mount. **Partially closed
92+
by `api.reservePath` (#602, JSS 0.0.219):** the plugin now reserves
93+
`/.well-known/did.json`, so the *claim* is deliberate and cross-plugin —
94+
two plugins pinning the same document fail the boot naming each other
95+
instead of one silently losing (the webfinger-vs-remotestorage outcome).
96+
The WAC exemption the reservation carries is redundant here (the blanket
97+
`/.well-known/*` skip already covers it), and route registration is still
98+
the plugin's job via `api.fastify` per the seam's own contract — so the
99+
exact-path-outranks-wildcard footing remains loader behavior, now at least
100+
sanctioned by that contract rather than pure coincidence.
94101

95-
2. **The pathed DID re-hits activitypub's namespace-interleaving wall.**
96-
did:web's pathed form `GET /<user>/did.json` lands *inside* the pod's own
97-
`/<user>/` LDP namespace — the exact collision `activitypub/` hit putting
98-
`/<user>/inbox` and `/<user>/outbox` on top of the pod. Unlike the
99-
well-known root, this path is **WAC-governed**: core's auth preHandler skips
100-
only `/.well-known/*`, `appPaths` prefixes, and the plugin's own `prefix`
101-
(`server.js`), so an anonymous `GET /alice/did.json` is denied by default.
102-
The root `/.acl` JSS seeds is public-read on the container **with no
103-
`acl:default`**, so a child `did.json` is not inherited-public either — the
104-
test must write an explicit public-read `/alice/did.json.acl` for the
105-
absolute did:web location to resolve. **Consequence:** did:web pathed
106-
resolution works only where the pod owner grants public Read at that path;
107-
the operator cannot fix it globally the way an API shim does with
108-
`appPaths`, because the path is *parameterized* (`/:user/did.json`) and
109-
`appPaths` matches only fixed prefixes. This strengthens the case for
110-
`api.reservePath()` (#582) to cover *parameterized* public routes that
111-
interleave with pod namespaces, not just fixed app roots — a requirement
112-
`activitypub/`, `mastodon/`, and `bluesky/` only approximated by inventing a
113-
single fake root (`/ap`, `/api`, `/xrpc`). did:web has no such escape: its
114-
URLs are fixed by the method spec. The plugin therefore also serves every
115-
DID under the always-safe `<prefix>/<user>/did.json`, and the test asserts
116-
that mount is byte-identical.
102+
2. **The pathed DID's namespace-interleaving wall — CLOSED by parameterized
103+
`api.reservePath` (#602, JSS 0.0.219).** did:web's pathed form
104+
`GET /<user>/did.json` lands *inside* the pod's own `/<user>/` LDP
105+
namespace — the exact collision `activitypub/` hit putting `/<user>/inbox`
106+
on top of the pod. This used to be **impossible**, not just inconvenient:
107+
the path is *parameterized* and `appPaths` matches only fixed prefixes, so
108+
no operator config could carve the exemption; `activitypub/`, `mastodon/`,
109+
and `bluesky/` escaped by inventing a single fake root (`/ap`, `/api`,
110+
`/xrpc`), but did:web's URLs are fixed by the method spec — no escape. The
111+
test had to write an explicit public-read `/alice/did.json.acl` to make
112+
resolution work at all. **Now:** `api.reservePath('/:user/did.json')`
113+
compiles an *exact-shape* matcher core's WAC hook consults per request, so
114+
the anonymous GET answers with no ACL grant, no `appPaths`, no pod-owner
115+
action — and every other `/<user>/` path stays WAC-governed. The test
116+
proves both halves: the DID document resolves anonymously with nothing but
117+
the reservation, while a sibling resource, a deeper `…/sub/did.json`, and
118+
an anonymous PUT to `did.json` itself all stay denied (reservations are
119+
read-only by default — exactly did.json's GET-only surface, so the default
120+
needed no widening). A second plugin reserving the same shape fails the
121+
boot naming both claimants. **What remains:** route registration is still
122+
the plugin's job via `api.fastify` (by the seam's contract); and fastify's
123+
default `maxParamLength` (100) caps the `:user` route while the
124+
reservation's matcher is length-unbounded, so a >100-char pod name would
125+
miss the route and fall through to LDP's `GET /*` with the read-only
126+
exemption applied — the wildcard escape `capability/` used is unavailable
127+
(`GET /*` is core's), harmless for `LOCAL_PART` pod names in practice but
128+
a real shape/route mismatch at the margin. The always-safe
129+
`<prefix>/<user>/did.json` mount stays, and the test still asserts it is
130+
byte-identical.
117131

118132
3. **`config.baseUrl` repetition — RESOLVED by `api.serverInfo()` (#601,
119133
merged JSS 0.0.218).** The did:web `id` (`did:web:<host>`) and every

didweb/plugin.js

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@
4141
// wildcard, and core's auth preHandler blanket-exempts `/.well-known/*`.
4242
// Guarded (try/catch) and mirrored at the contract-safe `<prefix>/did.json`.
4343
// * `/<user>/did.json` — the pathed per-pod DID. This is NOT a well-known
44-
// path: it lands INSIDE the pod's own `/<user>/` LDP namespace and is
45-
// therefore WAC-governed (server.js only skips auth for `/.well-known/*`,
46-
// `appPaths`, and the plugin's own prefix). did:web pathed resolution thus
47-
// works ONLY if the pod owner grants public Read at that path — the exact
48-
// namespace-interleaving wall activitypub/ hit with `/<user>/inbox`. Also
49-
// mirrored at the always-safe `<prefix>/<user>/did.json`.
44+
// path: it lands INSIDE the pod's own `/<user>/` LDP namespace, where
45+
// literal `appPaths` prefixes structurally could not carve an exemption —
46+
// the namespace-interleaving wall activitypub/ hit with `/<user>/inbox`.
47+
// CLOSED since JSS 0.0.219: `api.reservePath('/:user/did.json')` (#602)
48+
// compiles an exact-shape matcher that WAC-exempts GET/HEAD/OPTIONS on
49+
// precisely that shape, so the DID document answers anonymously while
50+
// every OTHER `/<user>/` path stays WAC-governed. Also mirrored at the
51+
// always-safe `<prefix>/<user>/did.json`.
5052
//
5153
// Nostr pubkey extraction is vendored (adapted from core's internal
5254
// src/auth/nostr-keys.js, AGPL-3.0-only) — the same wall nip05/ and
@@ -353,11 +355,18 @@ export async function activate(api) {
353355
api.fastify.get(`${prefix}/did.json`, rootHandler);
354356
api.fastify.get(`${prefix}/:user/did.json`, userHandler);
355357

356-
// Real did:web root location. Works today for the reasons nip05/webfinger
357-
// document (loader doesn't confine routes to the prefix; an exact path
358-
// outranks core's LDP wildcard; core blanket-exempts /.well-known/* from
359-
// auth) — nothing in the plugin CONTRACT promises it, so a conflict (core
360-
// someday claiming this GET) degrades to prefix-only, not a boot failure.
358+
// Real did:web root location. The GET itself works for the reasons
359+
// nip05/webfinger document (loader doesn't confine routes to the prefix;
360+
// an exact path outranks core's LDP wildcard; core blanket-exempts
361+
// /.well-known/* from auth) — but the CLAIM on the path is now deliberate:
362+
// api.reservePath (#602) records it in the loader's cross-plugin registry,
363+
// so a second plugin pinning /.well-known/did.json fails the boot naming
364+
// both claimants instead of one silently losing. The WAC exemption the
365+
// reservation carries is redundant here (the blanket /.well-known/* skip
366+
// already covers it); the registry entry is the point. A conflict with
367+
// CORE's own routes (core someday claiming this GET) still degrades to
368+
// prefix-only, not a boot failure.
369+
api.reservePath('/.well-known/did.json');
361370
let wellKnown = false;
362371
try {
363372
api.fastify.get('/.well-known/did.json', rootHandler);
@@ -367,29 +376,30 @@ export async function activate(api) {
367376
+ `serving the root DID under ${prefix}/did.json only`);
368377
}
369378

370-
// Real did:web PATHED location. Unlike the well-known root this lands inside
371-
// the pod's own /<user>/ LDP namespace and is WAC-governed (NOT exempt), so
372-
// it resolves anonymously only where the pod grants public Read — the
373-
// namespace-interleaving wall activitypub hit. Register it anyway (guarded);
374-
// the prefix mount is the always-safe fallback.
375-
let pathed = false;
376-
try {
377-
api.fastify.get('/:user/did.json', userHandler);
378-
pathed = true;
379-
} catch (err) {
380-
api.log.warn(`didweb: could not claim /:user/did.json (${err.message}); `
381-
+ `serving pathed DIDs under ${prefix}/<user>/did.json only`);
382-
}
379+
// Real did:web PATHED location — inside the pod's own /<user>/ LDP
380+
// namespace, where literal appPaths prefixes structurally could not carve
381+
// an exemption (the wall README finding 2 documented). api.reservePath
382+
// (#602, JSS 0.0.219) compiles the parameterized shape into an EXACT-shape
383+
// WAC exemption: anonymous GET /<user>/did.json answers, every other
384+
// /<user>/ path stays WAC-governed. Reservations are read-only by default
385+
// (GET/HEAD/OPTIONS) — exactly did.json's method surface, so no { methods }
386+
// widening: exempting a write verb with no route would fall through to
387+
// LDP's write wildcards as an unauthenticated storage write. No try/catch
388+
// either: a collision means two plugins pinning the same spec-fixed URL,
389+
// and degrading would leave a WAC exemption in place with no route — loud
390+
// boot failure is the seam's designed outcome. (fastify's default
391+
// maxParamLength (100) caps :user; a longer pod name misses this route and
392+
// falls through to LDP's GET /* with the read-only exemption applied. The
393+
// wildcard escape capability/ used is unavailable — GET /* is core's — but
394+
// LOCAL_PART pod names never approach 100 chars in practice.)
395+
api.reservePath('/:user/did.json');
396+
api.fastify.get('/:user/did.json', userHandler);
383397

384398
// Host isn't logged here: the did:web host comes from api.serverInfo() at
385399
// request time, and at activate the ephemeral port may not be resolved yet.
386400
api.log.info('didweb: root DID at '
387401
+ `${wellKnown ? '/.well-known/did.json and ' : ''}${prefix}/did.json; `
388-
+ `pathed DIDs at ${pathed ? '/<user>/did.json and ' : ''}${prefix}/<user>/did.json`
402+
+ `pathed DIDs at /<user>/did.json (reserved via api.reservePath, anonymous GET) `
403+
+ `and ${prefix}/<user>/did.json`
389404
+ (podsRoot ? ` from ${podsRoot}` : ' (no podsRoot: pathed DIDs 404)'));
390-
if (pathed) {
391-
api.log.warn('didweb: /<user>/did.json is WAC-governed (inside the pod LDP '
392-
+ 'namespace), so did:web pathed resolution needs the pod to grant public '
393-
+ 'Read there — same reserved-path/namespace seam as activitypub (see README).');
394-
}
395405
}

didweb/test.js

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
// nip05/ and webfinger/ lean on). Default server is NOT public-read, so
77
// the anonymous 200 demonstrates the exemption, not a permissive default.
88
// * The pathed per-pod DID at `/<user>/did.json` — which lands INSIDE the
9-
// pod's WAC-governed /<user>/ namespace. The root /.acl JSS seeds is
10-
// public-read on the container ONLY (no acl:default), so a child did.json
11-
// is NOT inherited-public: the test writes an explicit public-read
12-
// /<user>/did.json.acl to make did:web pathed resolution work, proving the
13-
// finding that the pod owner must grant public Read there. The
14-
// contract-safe `<prefix>/<user>/did.json` mount is asserted to be
15-
// identical and needs no such grant.
9+
// pod's WAC-governed /<user>/ namespace. Since JSS 0.0.219 the plugin
10+
// reserves the parameterized shape via api.reservePath('/:user/did.json')
11+
// (#602), so the anonymous GET answers with NO ACL grant and NO appPaths
12+
// in the config (this config passes neither). The exact-shape guarantee
13+
// is asserted from both sides: the DID document resolves anonymously,
14+
// while a sibling pod resource, a deeper same-basename path, and a write
15+
// to did.json itself all stay WAC-denied. The contract-safe
16+
// `<prefix>/<user>/did.json` mount is asserted to be identical.
1617

1718
import { describe, it, before, after } from 'node:test';
1819
import assert from 'node:assert';
@@ -26,8 +27,6 @@ import { probePort, startJss } from '../helpers.js';
2627
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)));
2728
const PLUGIN = path.join(__dirname, 'plugin.js');
2829

29-
const ACL_CTX = { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' };
30-
3130
/** Write a pod WebID card, optionally carrying a secp256k1 verificationMethod. */
3231
function writeCard(podDir, webId, secpMultibase) {
3332
fs.mkdirSync(path.join(podDir, 'profile'), { recursive: true });
@@ -43,20 +42,6 @@ function writeCard(podDir, webId, secpMultibase) {
4342
fs.writeFileSync(path.join(podDir, 'profile', 'card.jsonld'), JSON.stringify(card, null, 2));
4443
}
4544

46-
/** Grant anonymous (foaf:Agent) Read on a single resource URL via its .acl. */
47-
function writePublicReadAcl(aclFilePath, resourceUrl) {
48-
fs.writeFileSync(aclFilePath, JSON.stringify({
49-
'@context': ACL_CTX,
50-
'@graph': [{
51-
'@id': '#public',
52-
'@type': 'acl:Authorization',
53-
'acl:agentClass': { '@id': 'foaf:Agent' },
54-
'acl:accessTo': { '@id': resourceUrl },
55-
'acl:mode': [{ '@id': 'acl:Read' }],
56-
}],
57-
}, null, 2));
58-
}
59-
6045
/** A fresh, valid, on-curve secp256k1 pubkey as an f-form Multikey. */
6146
function freshSecpFForm() {
6247
const pub = secp256k1.getPublicKey(secp256k1.utils.randomPrivateKey(), true); // compressed
@@ -76,10 +61,10 @@ describe('didweb plugin', () => {
7661
encHost = new URL(base).host.replace(/:/g, '%3A'); // 127.0.0.1%3A<port>
7762

7863
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-didweb-'));
79-
// alice: pod with a provisioned secp256k1 key + a public-read did.json ACL
80-
// so the ABSOLUTE pathed did:web location resolves anonymously.
64+
// alice: pod with a provisioned secp256k1 key. NO did.json ACL: the
65+
// ABSOLUTE pathed did:web location must resolve anonymously on the
66+
// strength of the parameterized reservation alone (#602).
8167
writeCard(path.join(root, 'alice'), `${base}/alice/profile/card#me`, freshSecpFForm());
82-
writePublicReadAcl(path.join(root, 'alice', 'did.json.acl'), `${base}/alice/did.json`);
8368
// carol: pod with NO key → the plugin mints + persists an Ed25519 VM.
8469
writeCard(path.join(root, 'carol'), `${base}/carol/profile/card#me`);
8570
// a dot-guarded tree that must never be treated as a pod.
@@ -94,6 +79,8 @@ describe('didweb plugin', () => {
9479
// No baseUrl: the did:web host + service URLs now come from
9580
// api.serverInfo() (#601). The encHost assertions below (derived from
9681
// the real probed origin) must still hold — that's the retrofit proof.
82+
// No appPaths either (there is nowhere to pass one): the absolute
83+
// pathed mount is WAC-exempted by api.reservePath alone (#602).
9784
config: { podsRoot: root, actorPathTemplate: '/ap/<user>/actor' },
9885
}],
9986
});
@@ -142,16 +129,41 @@ describe('didweb plugin', () => {
142129
assert.strictEqual(ap.serviceEndpoint, `${base}/ap/alice/actor`, 'AP actor service from actorPathTemplate');
143130
});
144131

145-
it('resolves the ABSOLUTE pathed did:web location once the pod grants public Read', async () => {
132+
it('resolves the ABSOLUTE pathed did:web location anonymously via the parameterized reservation', async () => {
133+
// No ACL grant, no appPaths: api.reservePath('/:user/did.json') (#602)
134+
// alone makes the spec-pinned URL answer inside the WAC-governed pod
135+
// namespace — the wall README finding 2 documented, closed in JSS 0.0.219.
146136
const res = await fetch(`${base}/alice/did.json`);
147-
assert.strictEqual(res.status, 200, 'anonymous GET works because /alice/did.json.acl grants public Read');
137+
assert.strictEqual(res.status, 200, 'anonymous GET answers with no ACL and no appPaths');
138+
assert.match(res.headers.get('content-type') || '', /application\/did\+json/);
148139
const doc = await res.json();
149140
assert.strictEqual(doc.id, `did:web:${encHost}:alice`);
150141
// Identical to the contract-safe prefix mount.
151142
const viaPrefix = await (await fetch(`${base}/didweb/alice/did.json`)).json();
152143
assert.deepStrictEqual(doc, viaPrefix, 'absolute and prefix mounts serve the identical DID document');
153144
});
154145

146+
it('does not leak the exemption beyond the exact /<user>/did.json shape', async () => {
147+
// Sibling resource in the same pod namespace: still WAC-denied anonymously.
148+
const sibling = await fetch(`${base}/alice/profile/card.jsonld`);
149+
assert.ok([401, 403].includes(sibling.status),
150+
`sibling pod resource stays WAC-governed (got ${sibling.status})`);
151+
// Deeper path with the same basename: /:user/did.json is an EXACT-shape
152+
// match (two segments), not a subtree claim.
153+
const deeper = await fetch(`${base}/alice/sub/did.json`);
154+
assert.ok([401, 403].includes(deeper.status),
155+
`deeper did.json path stays WAC-governed (got ${deeper.status})`);
156+
// Reservations are read-only by default: a write to the reserved shape
157+
// itself must still hit WAC, not fall through as an anonymous LDP PUT.
158+
const put = await fetch(`${base}/alice/did.json`, {
159+
method: 'PUT',
160+
headers: { 'content-type': 'application/json' },
161+
body: '{"id":"spoofed"}',
162+
});
163+
assert.ok([401, 403].includes(put.status),
164+
`anonymous PUT on the reserved path is WAC-denied (got ${put.status})`);
165+
});
166+
155167
it('mints + persists an Ed25519 VM for a pod with no key in its card', async () => {
156168
const res = await fetch(`${base}/didweb/carol/did.json`);
157169
assert.strictEqual(res.status, 200);

0 commit comments

Comments
 (0)