Skip to content

Commit e165bd7

Browse files
webfinger + didweb: consume api.serverInfo (#601) — Stage-3 retrofit
The two silent-failure consumers, per NEXT.md's recommended first move. Both derived every absolute URL from a required config.baseUrl and hard-failed boot without it; now the origin comes from api.serverInfo() (#601, merged JSS 0.0.218), resolved at REQUEST time (with port 0 the port exists only once listening). config.baseUrl stays as an optional override for reverse-proxy edge cases. didweb's is the strongest before/after: a mismatched host silently minted a valid-looking DID whose id didn't match its fetch URL (silent corruption a resolver caches), not just a config chore. Tests now boot WITHOUT config.baseUrl and assert the JRD/DID URLs still resolve to the real origin — the retrofit proof. README findings marked RESOLVED (the origin half; config.podsRoot data-root repetition remains). 5/5 + 6/6; composition intact (only the known notifications inotify flake).
1 parent 84234fc commit e165bd7

6 files changed

Lines changed: 58 additions & 55 deletions

File tree

didweb/README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,17 @@ pod card at `podsRoot` if present, else from a minted server key.
115115
DID under the always-safe `<prefix>/<user>/did.json`, and the test asserts
116116
that mount is byte-identical.
117117

118-
3. **`config.podsRoot` + `config.baseUrl` repetition, again** (see `webfinger/`,
119-
`nip05/`, `notifications/`). A plugin can learn neither the data root nor
120-
its own origin, so both are repeated in config and can be pointed at the
121-
wrong place — here the failure mode is that the resolver *confidently mints
122-
a valid-looking DID* off a stale/empty pod or a mismatched host, which a
123-
resolver then caches. `api.storage.serverRoot` (read-only) and
124-
`api.serverInfo` remain the candidate seams. Subdomain-mode did:web
118+
3. **`config.baseUrl` repetition — RESOLVED by `api.serverInfo()` (#601,
119+
merged JSS 0.0.218).** The did:web `id` (`did:web:<host>`) and every
120+
service URL now derive from `api.serverInfo().baseUrl`, resolved at
121+
request time; `config.baseUrl` is an *optional* override and the boot no
122+
longer hard-fails without it. This mattered most here of all the
123+
consumers — a mismatched host silently mints a valid-looking DID whose
124+
`id` doesn't match the URL it was fetched from, which a resolver then
125+
caches — so landing the seam fixes a *silent-corruption* failure, not just
126+
a config chore. **`config.podsRoot` is still a repetition** (see
127+
`nip05/`): a plugin can't learn the *data root* — the remaining
128+
`api.storage.serverRoot` candidate. Subdomain-mode did:web
125129
(`did:web:alice.pod.example``https://alice.pod.example/.well-known/did.json`)
126130
is out of reach for the same reason `nip05/` couldn't do per-host filtering:
127131
a plugin can read `request.headers.host` but has no way to learn the base

didweb/plugin.js

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -204,24 +204,21 @@ async function hasCard(dir) {
204204
export async function activate(api) {
205205
const prefix = api.prefix || '/didweb';
206206
const podsRoot = api.config.podsRoot ?? null;
207-
const baseUrl = (api.config.baseUrl || '').replace(/\/$/, '');
207+
// Origin from api.serverInfo() (#601), resolved at REQUEST time (with port 0
208+
// the real port exists only once listening); config.baseUrl is an optional
209+
// override for reverse-proxy edge cases. The did:web id (did:web:<host>) and
210+
// every service URL derive from it — this used to require config.baseUrl and
211+
// hard-fail boot; that finding is the serverInfo seam, merged in JSS 0.0.218.
212+
const resolveBaseUrl = () => (api.config.baseUrl
213+
? String(api.config.baseUrl).replace(/\/$/, '')
214+
: api.serverInfo().baseUrl.replace(/\/$/, ''));
215+
// did:web host = the baseUrl authority with the port colon percent-encoded.
216+
const resolveEncHost = () => new URL(resolveBaseUrl()).host.replace(/:/g, '%3A');
208217
// Optional: an AP-actor path template (as webfinger/ uses) → a service entry.
209218
const actorPathTemplate = api.config.actorPathTemplate || null;
210219
// Optional: arbitrary extra service entries appended verbatim.
211220
const extraServices = Array.isArray(api.config.serviceEndpoints)
212221
? api.config.serviceEndpoints : [];
213-
214-
if (!baseUrl) {
215-
// A DID document is nothing but absolute URLs and a did:web id derived
216-
// from this server's own host, and the plugin api exposes no server
217-
// origin (same finding as webfinger/, activitypub/, mastodon/). Without
218-
// it there is no truthful `id` to mint — hard boot failure.
219-
throw new Error(
220-
'didweb plugin requires config.baseUrl — the plugin api exposes no server '
221-
+ 'origin, and the did:web id (did:web:<host>) plus every service URL is '
222-
+ 'derived from it (same finding as webfinger/activitypub/mastodon).',
223-
);
224-
}
225222
if (!podsRoot) {
226223
// Still activate: the root/server DID (`did:web:<host>`) is servable from
227224
// a minted key alone, and a purely additive resolver shouldn't fail the
@@ -231,11 +228,6 @@ export async function activate(api) {
231228
+ '(the plugin api cannot learn the data root itself)');
232229
}
233230

234-
// did:web host = the baseUrl authority with the port colon percent-encoded.
235-
const host = new URL(baseUrl).host; // e.g. "pod.example" or "127.0.0.1:3000"
236-
const encHost = host.replace(/:/g, '%3A'); // did:web percent-encodes the port colon
237-
const didFor = (user) => (user ? `did:web:${encHost}:${user}` : `did:web:${encHost}`);
238-
239231
// Minted Ed25519 keys persist here, one JSON per scope (root → `_root`).
240232
const keysDir = path.join(api.storage.pluginDir(), 'keys');
241233
fsSync.mkdirSync(keysDir, { recursive: true });
@@ -276,6 +268,9 @@ export async function activate(api) {
276268
*/
277269
async function buildDidDoc(user) {
278270
if (user !== null && !(await podExists(user))) return null;
271+
const baseUrl = resolveBaseUrl();
272+
const encHost = resolveEncHost();
273+
const didFor = (u) => (u ? `did:web:${encHost}:${u}` : `did:web:${encHost}`);
279274
const did = didFor(user);
280275
const podPath = user ? `/${user}/` : '/';
281276
const webid = `${baseUrl}${podPath}profile/card#me`;
@@ -349,7 +344,7 @@ export async function activate(api) {
349344
const user = request.params.user;
350345
if (!LOCAL_PART.test(user) || user.startsWith('.')) return notFound(reply, 'no such DID');
351346
const doc = await buildDidDoc(user);
352-
if (!doc) return notFound(reply, `no such DID: did:web:${encHost}:${user}`);
347+
if (!doc) return notFound(reply, `no such DID: did:web:${resolveEncHost()}:${user}`);
353348
return sendDoc(reply, doc);
354349
}
355350

@@ -386,7 +381,9 @@ export async function activate(api) {
386381
+ `serving pathed DIDs under ${prefix}/<user>/did.json only`);
387382
}
388383

389-
api.log.info(`didweb: root DID did:web:${encHost} at `
384+
// Host isn't logged here: the did:web host comes from api.serverInfo() at
385+
// request time, and at activate the ephemeral port may not be resolved yet.
386+
api.log.info('didweb: root DID at '
390387
+ `${wellKnown ? '/.well-known/did.json and ' : ''}${prefix}/did.json; `
391388
+ `pathed DIDs at ${pathed ? '/<user>/did.json and ' : ''}${prefix}/<user>/did.json`
392389
+ (podsRoot ? ` from ${podsRoot}` : ' (no podsRoot: pathed DIDs 404)'));

didweb/test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ describe('didweb plugin', () => {
9191
plugins: [{
9292
module: PLUGIN,
9393
prefix: '/didweb',
94-
config: { podsRoot: root, baseUrl: base, actorPathTemplate: '/ap/<user>/actor' },
94+
// No baseUrl: the did:web host + service URLs now come from
95+
// api.serverInfo() (#601). The encHost assertions below (derived from
96+
// the real probed origin) must still hold — that's the retrofit proof.
97+
config: { podsRoot: root, actorPathTemplate: '/ap/<user>/actor' },
9598
}],
9699
});
97100
});

webfinger/README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,14 @@ lifted out of `--activitypub` into an always-on, multi-protocol plugin.
130130
let the endpoint actually be the shared, extensible surface the issue
131131
describes.
132132

133-
3. **`config.podsRoot` / `baseUrl` repetition (as in nip05, notifications,
134-
mastodon, webdav).** A plugin cannot learn the data root or the server's
135-
own origin, so the operator repeats both in config. Here `baseUrl` is a
136-
hard requirement — every URL in a JRD (WebID, profile page, actor,
137-
issuer) is absolute against it — so unlike nip05 the plugin *hard-fails*
138-
boot without it. `podsRoot` is the same repetition nip05 documents, with
139-
the same failure mode: point it at the wrong directory and every lookup
140-
confidently 404s while the rest of the server works.
141-
`api.storage.serverRoot` and `api.serverInfo` (both read-only) remain the
142-
candidate seams.
133+
3. **`baseUrl` repetition — RESOLVED by `api.serverInfo()` (#601, merged JSS
134+
0.0.218).** A plugin now learns its own origin: every JRD URL (WebID,
135+
profile page, actor, issuer) is built from `api.serverInfo().baseUrl`,
136+
resolved at request time. `config.baseUrl` remains an *optional* override
137+
for reverse-proxy edge cases, and the boot no longer hard-fails without
138+
it. This was the origin half of the old finding; `api.serverInfo` was one
139+
of the top-demanded seams (~23 consumers) and landing it retired that
140+
demand here. **`config.podsRoot` is still a repetition** (as in nip05):
141+
a plugin cannot learn the *data root*, and pointing it at the wrong
142+
directory confidently 404s every lookup while the rest of the server
143+
works — the remaining `api.storage.serverRoot` candidate.

webfinger/plugin.js

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -96,25 +96,20 @@ export function parseResource(resource) {
9696
export async function activate(api) {
9797
const prefix = api.prefix || '/webfinger';
9898
const podsRoot = api.config.podsRoot ?? null;
99-
const baseUrl = (api.config.baseUrl || '').replace(/\/$/, '');
99+
// The server's own origin comes from api.serverInfo() (#601), resolved at
100+
// REQUEST time (with port 0 the real port exists only once listening).
101+
// config.baseUrl stays as an optional override for reverse-proxy edge
102+
// cases. Every URL in a JRD (WebID, profile page, actor, issuer) is
103+
// absolute against it — this used to require config.baseUrl and hard-fail
104+
// boot; that whole finding is the serverInfo seam, merged in JSS 0.0.218.
105+
const resolveBaseUrl = () => (api.config.baseUrl
106+
? String(api.config.baseUrl).replace(/\/$/, '')
107+
: api.serverInfo().baseUrl.replace(/\/$/, ''));
100108
// `null`/`''` disables the `self` (ActivityPub actor) link — "IF
101109
// applicable" from the issue: a deployment without AP actors omits it.
102110
const actorPathTemplate = 'actorPathTemplate' in api.config
103111
? api.config.actorPathTemplate
104112
: '/<user>/actor';
105-
106-
if (!baseUrl) {
107-
// A JRD is nothing but absolute URLs of this server's own origin, and
108-
// the plugin api exposes no server origin (same finding as mastodon/,
109-
// notifications/, webdav/). Without it there is nothing truthful to
110-
// serve, so unlike nip05 (whose document carries no self-origin URLs)
111-
// this is a hard boot failure.
112-
throw new Error(
113-
'webfinger plugin requires config.baseUrl — the plugin api exposes no '
114-
+ 'server origin, and every URL in a JRD (WebID, profile page, actor, '
115-
+ 'issuer) is absolute against it (same finding as mastodon/notifications).',
116-
);
117-
}
118113
if (!podsRoot) {
119114
// Without a data root the plugin cannot resolve any user, so every
120115
// query 404s. Still activate (a purely additive discovery endpoint
@@ -138,7 +133,7 @@ export async function activate(api) {
138133
}
139134

140135
/** Build the JRD for a resolved pod. `subject` echoes the query verbatim. */
141-
function buildJrd(user, subject) {
136+
function buildJrd(user, subject, baseUrl) {
142137
const podPath = user ? `/${user}/` : '/';
143138
const webid = `${baseUrl}${podPath}profile/card#me`;
144139
const profilePage = `${baseUrl}${podPath}profile/card`;
@@ -186,7 +181,7 @@ export async function activate(api) {
186181
}
187182
return reply
188183
.header('content-type', 'application/jrd+json; charset=utf-8')
189-
.send(buildJrd(parsed.user, resource));
184+
.send(buildJrd(parsed.user, resource, resolveBaseUrl()));
190185
}
191186

192187
// Contract-safe mount: under the plugin's own prefix (WAC-exempt via the

webfinger/test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ describe('webfinger plugin', () => {
5454
plugins: [{
5555
module: PLUGIN,
5656
prefix: '/webfinger',
57-
config: { podsRoot: root, baseUrl: base },
57+
// No baseUrl: the origin now comes from api.serverInfo() (#601).
58+
// The JRD's absolute URLs (issuer, actor, WebID) below must still
59+
// resolve to the server's real origin — that's the retrofit proof.
60+
config: { podsRoot: root },
5861
}],
5962
});
6063
});

0 commit comments

Comments
 (0)