javascript-solid-server 0.0.181 → 0.0.183
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +2 -1
- package/package.json +1 -1
- package/src/idp/well-known-did-nostr.js +127 -29
- package/src/rdf/turtle.js +75 -18
- package/test/turtle.test.js +68 -0
- package/test/well-known-did-nostr.test.js +223 -1
|
@@ -410,7 +410,8 @@
|
|
|
410
410
|
"Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=480,800 --screenshot=/tmp/consent-preview.png file:///tmp/consent-preview.html)",
|
|
411
411
|
"Bash(cp /tmp/consent-preview.html ~/consent-preview.html)",
|
|
412
412
|
"Read(//home/melvin/**)",
|
|
413
|
-
"Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=520,900 --screenshot=consent.png file:///home/melvin/consent-preview.html)"
|
|
413
|
+
"Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=520,900 --screenshot=consent.png file:///home/melvin/consent-preview.html)",
|
|
414
|
+
"Bash(sed -i '0,/\"version\": \"0.0.181\"/{s/\"version\": \"0.0.181\"/\"version\": \"0.0.182\"/}' package-lock.json)"
|
|
414
415
|
]
|
|
415
416
|
}
|
|
416
417
|
}
|
package/package.json
CHANGED
|
@@ -138,45 +138,85 @@ async function rebuildPubkeyIndex() {
|
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
140
|
if (!account?.webId) continue;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
// Probe candidate paths in order until one ALSO passes the @id
|
|
142
|
+
// check. Multiple candidates can exist on disk simultaneously
|
|
143
|
+
// (e.g. root pod and named pods coexisting under the same
|
|
144
|
+
// dataRoot, or a subdomain pod with a coincidentally-named
|
|
145
|
+
// path-mode dir). The path-mode candidate may exist but belong
|
|
146
|
+
// to a different account — keep going until we find one whose
|
|
147
|
+
// declared `@id` matches account.webId.
|
|
148
|
+
const { paths: candidates, skipped: containmentSkipped } =
|
|
149
|
+
profilePathCandidates(dataRoot, account.webId, account.podName);
|
|
150
|
+
// Track per-candidate failure reasons so operators get a precise
|
|
151
|
+
// diagnostic when nothing matches — distinguishing
|
|
152
|
+
// "profile genuinely missing" from "@id mismatch" from "oversized"
|
|
153
|
+
// from "containment rejected".
|
|
154
|
+
const reasons = containmentSkipped.map(s => `${s.path}: ${s.reason}`);
|
|
155
|
+
let profile = null;
|
|
144
156
|
let mtimeMs = 0;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
let stat;
|
|
159
|
+
try {
|
|
160
|
+
stat = await fs.stat(candidate);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
reasons.push(`${candidate}: ${err.code || 'stat-error'}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!stat.isFile()) {
|
|
166
|
+
reasons.push(`${candidate}: not-a-regular-file`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
153
169
|
if (stat.size > MAX_PROFILE_BYTES) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
170
|
+
// Funnel through the rate-limited per-account logger
|
|
171
|
+
// (when no candidate matches at all). Direct console.error
|
|
172
|
+
// would spam logs every TTL rebuild for any account whose
|
|
173
|
+
// profile is oversized at one candidate but matches at
|
|
174
|
+
// another — and on every rebuild for genuinely-oversized
|
|
175
|
+
// accounts.
|
|
176
|
+
reasons.push(`${candidate}: oversized (${stat.size} > ${MAX_PROFILE_BYTES})`);
|
|
158
177
|
continue;
|
|
159
178
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(await fs.readFile(candidate, 'utf8'));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reasons.push(`${candidate}: parse-error (${err.message})`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const declaredSubject = absolutize(parsed?.['@id'] || parsed?.id, stripHashIfAny(account.webId));
|
|
187
|
+
if (declaredSubject !== account.webId) {
|
|
188
|
+
reasons.push(`${candidate}: @id-mismatch (declared=${declaredSubject || '(none)'})`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
profile = parsed;
|
|
192
|
+
mtimeMs = stat.mtimeMs;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (!profile) {
|
|
196
|
+
// Nothing on disk matched. Surface the precise per-candidate
|
|
197
|
+
// failure reasons so operators can distinguish ENOENT (profile
|
|
198
|
+
// genuinely missing) from @id mismatch (wrong subdomain config?)
|
|
199
|
+
// from containment rejection (malformed webId path) without
|
|
200
|
+
// having to grep the file system.
|
|
201
|
+
//
|
|
202
|
+
// Keep the `path` argument to logProfileFailure path-shaped so
|
|
203
|
+
// downstream log readers don't get a giant summary string in
|
|
204
|
+
// the `profile=...` field. Per-candidate detail goes into
|
|
205
|
+
// `err.message` as a single line.
|
|
206
|
+
const summary = reasons.length ? reasons.join(' | ') : '(no candidates)';
|
|
207
|
+
logProfileFailure(accountId, '(multiple candidates)', {
|
|
208
|
+
code: 'NO_CANDIDATE_MATCHED',
|
|
209
|
+
message: `no candidate profile matched ${account.webId} — tried: ${summary}`,
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
168
212
|
}
|
|
169
|
-
// CID semantics — match the resource-side checks:
|
|
170
|
-
// (1) profile's @id MUST match the account's webId (no fragment-
|
|
171
|
-
// swapping attack via a stored profile that claims to be
|
|
172
|
-
// someone else)
|
|
213
|
+
// CID semantics (continued) — match the resource-side checks:
|
|
173
214
|
// (2) VM's controller MUST be in the profile's expected controller
|
|
174
215
|
// set (declared `controller`, with @id fallback)
|
|
175
216
|
// (3) VM MUST be referenced from `authentication` — a key in
|
|
176
217
|
// verificationMethod alone (no auth membership) shouldn't be
|
|
177
218
|
// published as authentic
|
|
178
|
-
const profileSubject =
|
|
179
|
-
if (!profileSubject || profileSubject !== account.webId) continue;
|
|
219
|
+
const profileSubject = account.webId; // already validated above
|
|
180
220
|
const expectedControllers = collectControllerIds(profile, profileSubject);
|
|
181
221
|
if (expectedControllers.size === 0) continue;
|
|
182
222
|
// Pass the already-validated absolute subject as the base. Without
|
|
@@ -260,6 +300,64 @@ export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') {
|
|
|
260
300
|
return resolved;
|
|
261
301
|
}
|
|
262
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Build the candidate filesystem paths to probe for a given WebID,
|
|
305
|
+
* covering the deployment shapes JSS supports:
|
|
306
|
+
*
|
|
307
|
+
* 1. Path-mode named pod (host=`example.com`, path=`/alice/profile/card.jsonld`)
|
|
308
|
+
* → `<dataRoot>/alice/profile/card.jsonld`
|
|
309
|
+
* 2. Root pod (single-user) (host=`example.com`, path=`/profile/card.jsonld`)
|
|
310
|
+
* → `<dataRoot>/profile/card.jsonld`
|
|
311
|
+
* 3. Subdomain-mode pod (host=`alice.example.com`, path=`/profile/card.jsonld`)
|
|
312
|
+
* → `<dataRoot>/alice/profile/card.jsonld`
|
|
313
|
+
*
|
|
314
|
+
* The subdomain candidate (3) is gated on `podName` matching the
|
|
315
|
+
* WebID host's first DNS label — without that gate, a root-pod
|
|
316
|
+
* WebID (`example.com`) would also emit `<dataRoot>/example/...`,
|
|
317
|
+
* which could be a different account's pod dir.
|
|
318
|
+
*
|
|
319
|
+
* Returns `{ paths, skipped }`:
|
|
320
|
+
* - `paths` — absolute, containment-passed candidates to probe
|
|
321
|
+
* in order
|
|
322
|
+
* - `skipped` — diagnostic entries for paths rejected at this
|
|
323
|
+
* stage (today only "outside-dataRoot"). Surfaced through the
|
|
324
|
+
* rebuild loop's failure log so operators can distinguish
|
|
325
|
+
* traversal / misconfig from "profile not on disk."
|
|
326
|
+
*
|
|
327
|
+
* @internal exported for tests
|
|
328
|
+
*/
|
|
329
|
+
export function profilePathCandidates(dataRoot, webId, podName = null) {
|
|
330
|
+
if (typeof webId !== 'string') return { paths: [], skipped: [] };
|
|
331
|
+
let url;
|
|
332
|
+
try { url = new URL(webId); } catch { return { paths: [], skipped: [] }; }
|
|
333
|
+
const pathnameRel = url.pathname.replace(/^\/+/, '');
|
|
334
|
+
const dataRootAbs = path.resolve(dataRoot);
|
|
335
|
+
const insideRoot = (p) => p === dataRootAbs || p.startsWith(dataRootAbs + path.sep);
|
|
336
|
+
const paths = [];
|
|
337
|
+
const skipped = [];
|
|
338
|
+
const consider = (...parts) => {
|
|
339
|
+
const r = path.resolve(dataRootAbs, ...parts);
|
|
340
|
+
if (!insideRoot(r)) {
|
|
341
|
+
skipped.push({ path: r, reason: 'outside-dataRoot' });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!paths.includes(r)) paths.push(r);
|
|
345
|
+
};
|
|
346
|
+
// Path-mode named pod OR root pod.
|
|
347
|
+
consider(pathnameRel);
|
|
348
|
+
// Subdomain mode: only when the WebID host's first DNS label
|
|
349
|
+
// matches the account's podName (case-insensitive — DNS is).
|
|
350
|
+
if (typeof podName === 'string' && podName.length > 0) {
|
|
351
|
+
const host = url.hostname.toLowerCase();
|
|
352
|
+
const expected = podName.toLowerCase() + '.';
|
|
353
|
+
if (host.startsWith(expected)) {
|
|
354
|
+
consider(podName, pathnameRel);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { paths, skipped };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
263
361
|
function collectControllerIds(source, baseUrl) {
|
|
264
362
|
const out = new Set();
|
|
265
363
|
const c = source?.controller;
|
package/src/rdf/turtle.js
CHANGED
|
@@ -164,6 +164,47 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
|
|
|
164
164
|
return nodes.map((node, i) => i === 0 ? { '@context': context, ...node } : node);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Read a JSON-LD node's identifier, accepting both the explicit
|
|
169
|
+
* `@id` form AND the unprefixed `id` alias that JSON-LD 1.1 treats
|
|
170
|
+
* as equivalent (and that Solid profiles in the wild use). Same
|
|
171
|
+
* fallback for `@type` / `type`.
|
|
172
|
+
*
|
|
173
|
+
* Without this aliasing, nested objects authored with `id`/`type`
|
|
174
|
+
* (e.g. a CID v1 verificationMethod entry) get silently dropped:
|
|
175
|
+
* - the predicate-→-IRI quad isn't emitted (valueToTerm sees
|
|
176
|
+
* no `@id` and returns null)
|
|
177
|
+
* - the BFS enqueue check (`v['@id']`) is false, so the nested
|
|
178
|
+
* object's own triples are never written either
|
|
179
|
+
* - net result: the entire `cid:verificationMethod` predicate
|
|
180
|
+
* and the `#nostr-key-1` resource block disappear from Turtle.
|
|
181
|
+
*
|
|
182
|
+
* #415.
|
|
183
|
+
*/
|
|
184
|
+
function getNodeId(n) {
|
|
185
|
+
if (!n || typeof n !== 'object') return undefined;
|
|
186
|
+
const v = n['@id'] !== undefined ? n['@id'] : n.id;
|
|
187
|
+
// Strict string-only — downstream resolveUri/`.startsWith` would
|
|
188
|
+
// throw on a number, null, or object. Malformed user content
|
|
189
|
+
// (a profile that authored `id: 42`) shouldn't crash conneg;
|
|
190
|
+
// treat non-string identifiers as absent.
|
|
191
|
+
return typeof v === 'string' ? v : undefined;
|
|
192
|
+
}
|
|
193
|
+
function getNodeType(n) {
|
|
194
|
+
if (!n || typeof n !== 'object') return undefined;
|
|
195
|
+
const v = n['@type'] !== undefined ? n['@type'] : n.type;
|
|
196
|
+
// Accept string OR array — expandUri/`.includes` would throw on
|
|
197
|
+
// anything else. For arrays, filter to string entries downstream
|
|
198
|
+
// (handled by Array.isArray + the per-entry expandUri call which
|
|
199
|
+
// assumes string; we filter here to be safe).
|
|
200
|
+
if (typeof v === 'string') return v;
|
|
201
|
+
if (Array.isArray(v)) {
|
|
202
|
+
const strs = v.filter(t => typeof t === 'string');
|
|
203
|
+
return strs.length > 0 ? strs : undefined;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
167
208
|
/**
|
|
168
209
|
* Convert JSON-LD to N3.js quads
|
|
169
210
|
*/
|
|
@@ -181,8 +222,8 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
181
222
|
if (doc['@context']) {
|
|
182
223
|
mergedContext = { ...mergedContext, ...doc['@context'] };
|
|
183
224
|
}
|
|
184
|
-
// Each document with @id is a node (no @graph needed)
|
|
185
|
-
if (doc
|
|
225
|
+
// Each document with @id (or `id` alias) is a node (no @graph needed)
|
|
226
|
+
if (getNodeId(doc) !== undefined) {
|
|
186
227
|
nodes.push(doc);
|
|
187
228
|
}
|
|
188
229
|
}
|
|
@@ -206,16 +247,18 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
206
247
|
const queue = [...nodes];
|
|
207
248
|
for (let i = 0; i < queue.length; i++) {
|
|
208
249
|
const node = queue[i];
|
|
209
|
-
|
|
210
|
-
|
|
250
|
+
const nodeId = getNodeId(node);
|
|
251
|
+
if (nodeId === undefined) continue;
|
|
252
|
+
const subjectUri = resolveUri(nodeId, baseUri);
|
|
211
253
|
|
|
212
254
|
const subject = subjectUri.startsWith('_:')
|
|
213
255
|
? blankNode(subjectUri.slice(2))
|
|
214
256
|
: namedNode(subjectUri);
|
|
215
257
|
|
|
216
|
-
// Handle @type
|
|
217
|
-
|
|
218
|
-
|
|
258
|
+
// Handle @type (or `type` alias).
|
|
259
|
+
const nodeType = getNodeType(node);
|
|
260
|
+
if (nodeType !== undefined) {
|
|
261
|
+
const types = Array.isArray(nodeType) ? nodeType : [nodeType];
|
|
219
262
|
for (const type of types) {
|
|
220
263
|
const typeUri = expandUri(type, context);
|
|
221
264
|
quads.push(quad(
|
|
@@ -226,9 +269,13 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
226
269
|
}
|
|
227
270
|
}
|
|
228
271
|
|
|
229
|
-
// Handle other properties
|
|
272
|
+
// Handle other properties. Skip `@`-prefixed keys AND the `id`/
|
|
273
|
+
// `type` aliases (handled above as @id/@type) — emitting them as
|
|
274
|
+
// predicates would produce malformed triples like `<id>` and
|
|
275
|
+
// `<type>` since the names don't expand to URIs via context.
|
|
230
276
|
for (const [key, value] of Object.entries(node)) {
|
|
231
277
|
if (key.startsWith('@')) continue;
|
|
278
|
+
if (key === 'id' || key === 'type') continue;
|
|
232
279
|
|
|
233
280
|
const predicateUri = expandUri(key, context);
|
|
234
281
|
const predicate = namedNode(predicateUri);
|
|
@@ -243,15 +290,16 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
243
290
|
if (object) {
|
|
244
291
|
quads.push(quad(subject, predicate, object));
|
|
245
292
|
}
|
|
246
|
-
// If v is a nested node (object with @id and at least one
|
|
247
|
-
// own property beyond
|
|
248
|
-
// emitted. Object-identity tracking
|
|
249
|
-
// nested object from being
|
|
250
|
-
//
|
|
293
|
+
// If v is a nested node (object with @id/id and at least one
|
|
294
|
+
// own property beyond the identifier), enqueue it so its
|
|
295
|
+
// triples are also emitted. Object-identity tracking
|
|
296
|
+
// (WeakSet) prevents the same nested object from being
|
|
297
|
+
// enqueued twice, which would otherwise loop for graphs
|
|
298
|
+
// that reuse an object reference (cycles).
|
|
251
299
|
if (v && typeof v === 'object' && !Array.isArray(v) &&
|
|
252
|
-
v
|
|
300
|
+
getNodeId(v) !== undefined && v['@value'] === undefined &&
|
|
253
301
|
!enqueuedNested.has(v)) {
|
|
254
|
-
const hasOwnClaims = Object.keys(v).some(k => k !== '@id');
|
|
302
|
+
const hasOwnClaims = Object.keys(v).some(k => k !== '@id' && k !== 'id');
|
|
255
303
|
if (hasOwnClaims) {
|
|
256
304
|
enqueuedNested.add(v);
|
|
257
305
|
queue.push(v);
|
|
@@ -348,9 +396,18 @@ function valueToTerm(value, baseUri, context, isIdType = false) {
|
|
|
348
396
|
|
|
349
397
|
// Object values
|
|
350
398
|
if (typeof value === 'object') {
|
|
351
|
-
// @id reference
|
|
352
|
-
|
|
353
|
-
|
|
399
|
+
// @id reference (or `id` alias — same JSON-LD 1.1 convention).
|
|
400
|
+
// This is what makes the predicate-→-IRI quad get emitted for
|
|
401
|
+
// nested objects authored with `id` instead of `@id`. Without
|
|
402
|
+
// it, an inline verificationMethod with `id`/`type` returned
|
|
403
|
+
// null here and the parent predicate triple was lost.
|
|
404
|
+
//
|
|
405
|
+
// String-only — a numeric or null `@id`/`id` would crash
|
|
406
|
+
// resolveUri's `.startsWith`. Treat as absent and fall through
|
|
407
|
+
// to the @value/@language branches below.
|
|
408
|
+
const rawObjId = value['@id'] !== undefined ? value['@id'] : value.id;
|
|
409
|
+
if (typeof rawObjId === 'string') {
|
|
410
|
+
const uri = resolveUri(rawObjId, baseUri);
|
|
354
411
|
return uri.startsWith('_:')
|
|
355
412
|
? blankNode(uri.slice(2))
|
|
356
413
|
: namedNode(uri);
|
package/test/turtle.test.js
CHANGED
|
@@ -85,6 +85,74 @@ describe('turtle converter — unit (#320 follow-ups)', () => {
|
|
|
85
85
|
`Turtle output must not contain object-stringification, got:\n${content}`);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it('nested object with `id`/`type` aliases survives the conversion (#415)', async () => {
|
|
89
|
+
// Solid profiles use the JSON-LD 1.1 `id`/`type` aliases for
|
|
90
|
+
// nested resources (no `@`). The converter must accept both
|
|
91
|
+
// forms — without this, a CID v1 verificationMethod object
|
|
92
|
+
// gets silently dropped:
|
|
93
|
+
// - the `cid:verificationMethod` predicate isn't emitted
|
|
94
|
+
// - the nested `#nostr-key-1` resource (Multikey, controller,
|
|
95
|
+
// publicKeyMultibase) isn't emitted either
|
|
96
|
+
// Net: third-party Turtle consumers see `cid:authentication
|
|
97
|
+
// <#nostr-key-1>` with no description of `#nostr-key-1`.
|
|
98
|
+
const doc = {
|
|
99
|
+
'@context': {
|
|
100
|
+
cid: 'https://www.w3.org/ns/cid/v1#',
|
|
101
|
+
verificationMethod: { '@id': 'cid:verificationMethod', '@container': '@set' },
|
|
102
|
+
authentication: { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' },
|
|
103
|
+
controller: { '@id': 'cid:controller', '@type': '@id' },
|
|
104
|
+
publicKeyMultibase: { '@id': 'cid:publicKeyMultibase' },
|
|
105
|
+
},
|
|
106
|
+
'@id': 'https://example.test/profile/card.jsonld#me',
|
|
107
|
+
verificationMethod: [{
|
|
108
|
+
// Aliases — `id`/`type`, not `@id`/`@type`.
|
|
109
|
+
id: 'https://example.test/profile/card.jsonld#k',
|
|
110
|
+
type: 'Multikey',
|
|
111
|
+
controller: 'https://example.test/profile/card.jsonld#me',
|
|
112
|
+
publicKeyMultibase: 'fe70102de7ec',
|
|
113
|
+
}],
|
|
114
|
+
authentication: ['https://example.test/profile/card.jsonld#k'],
|
|
115
|
+
};
|
|
116
|
+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
|
|
117
|
+
|
|
118
|
+
// The cid:verificationMethod predicate must connect #me to the VM.
|
|
119
|
+
assert.match(content, /cid:verificationMethod|<https:\/\/www\.w3\.org\/ns\/cid\/v1#verificationMethod>/,
|
|
120
|
+
`cid:verificationMethod predicate missing from Turtle:\n${content}`);
|
|
121
|
+
// The VM resource must be described — its type, controller, key.
|
|
122
|
+
assert.ok(content.includes('https://example.test/profile/card.jsonld#k'),
|
|
123
|
+
`VM #k must appear in Turtle:\n${content}`);
|
|
124
|
+
assert.match(content, /Multikey|<https:\/\/www\.w3\.org\/ns\/cid\/v1#Multikey>/,
|
|
125
|
+
`Multikey type missing from Turtle:\n${content}`);
|
|
126
|
+
assert.ok(content.includes('fe70102de7ec'),
|
|
127
|
+
`publicKeyMultibase value missing from Turtle:\n${content}`);
|
|
128
|
+
assert.match(content, /cid:controller|<https:\/\/www\.w3\.org\/ns\/cid\/v1#controller>/,
|
|
129
|
+
`cid:controller predicate missing on the VM:\n${content}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('malformed `id`/`type` values are silently dropped, not crashed on (#415 review)', async () => {
|
|
133
|
+
// Profiles in the wild can have malformed user-authored content
|
|
134
|
+
// — e.g. `id: 42` or `type: null`. The converter must NOT throw
|
|
135
|
+
// (downstream `resolveUri.startsWith` and `expandUri.includes`
|
|
136
|
+
// assume strings); it should treat the malformed value as absent
|
|
137
|
+
// and skip the affected resource cleanly.
|
|
138
|
+
const doc = {
|
|
139
|
+
'@context': { 'cid': 'https://www.w3.org/ns/cid/v1#' },
|
|
140
|
+
'@id': 'https://example.test/s',
|
|
141
|
+
// Nested object with a non-string `id` — must not crash.
|
|
142
|
+
'cid:bad1': { id: 42, 'cid:foo': 'x' },
|
|
143
|
+
// Nested object with a null `type` — must not crash.
|
|
144
|
+
'cid:bad2': { id: 'https://example.test/n2', type: null, 'cid:foo': 'x' },
|
|
145
|
+
// Array `type` with mixed string/non-string entries — string
|
|
146
|
+
// entries should still emit.
|
|
147
|
+
'cid:mixed': { id: 'https://example.test/n3', type: ['Multikey', 42, null], 'cid:foo': 'x' },
|
|
148
|
+
};
|
|
149
|
+
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
|
|
150
|
+
assert.ok(typeof content === 'string', 'must produce a string output, not throw');
|
|
151
|
+
// The valid string type entry should survive in the mixed-type case.
|
|
152
|
+
assert.ok(content.includes('https://example.test/n3'),
|
|
153
|
+
`node n3 should appear:\n${content}`);
|
|
154
|
+
});
|
|
155
|
+
|
|
88
156
|
it('cyclical nested node reference does not hang', async () => {
|
|
89
157
|
// Two nested nodes reference each other. BFS must not loop.
|
|
90
158
|
const a = { '@id': 'https://example.test/a', 'ex:knows': null };
|
|
@@ -14,7 +14,11 @@ import fs from 'fs-extra';
|
|
|
14
14
|
import { createServer as createNetServer } from 'net';
|
|
15
15
|
import { generateSecretKey, getPublicKey } from '../src/nostr/event.js';
|
|
16
16
|
import { createServer } from '../src/server.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
_resetIndexForTests,
|
|
19
|
+
profilePathFromWebId,
|
|
20
|
+
profilePathCandidates,
|
|
21
|
+
} from '../src/idp/well-known-did-nostr.js';
|
|
18
22
|
import { extractNostrPubkeysFromProfile } from '../src/auth/nostr.js';
|
|
19
23
|
|
|
20
24
|
const TEST_HOST = '127.0.0.1';
|
|
@@ -301,6 +305,123 @@ describe('GET /.well-known/did/nostr/:pubkey (#407)', () => {
|
|
|
301
305
|
assert.strictEqual(doc.alsoKnownAs[0], rootWebId);
|
|
302
306
|
});
|
|
303
307
|
|
|
308
|
+
it('indexes subdomain-mode pods (#411): /<host-first-label>/profile/...', async () => {
|
|
309
|
+
// Subdomain layout: WebID host = `<podname>.<basedomain>` and
|
|
310
|
+
// the profile lives at `<DATA_ROOT>/<podname>/profile/card.jsonld`
|
|
311
|
+
// (NOT at `<DATA_ROOT>/profile/card.jsonld`). Pre-#411 the
|
|
312
|
+
// indexer derived the path from `webIdUrl.pathname` only,
|
|
313
|
+
// dropped the subdomain, and ENOENT-skipped every subdomain
|
|
314
|
+
// pod silently — so the entire `Sign in with Schnorr` zero-
|
|
315
|
+
// typing UX fell through to the typed-username fallback on
|
|
316
|
+
// every subdomain-mode deployment (e.g. solid.social).
|
|
317
|
+
//
|
|
318
|
+
// This test ALSO exercises the "first candidate exists but
|
|
319
|
+
// belongs to a different account" path: we write a coexisting
|
|
320
|
+
// root-pod profile at `<TEST_DATA_DIR>/profile/card.jsonld`
|
|
321
|
+
// for an UNRELATED account (different webId, different VM).
|
|
322
|
+
// The subdomain account's path-mode candidate hits that file,
|
|
323
|
+
// fails the @id check, and the loop must fall through to the
|
|
324
|
+
// subdomain candidate. Self-contained — doesn't depend on
|
|
325
|
+
// earlier tests' fixtures or test-execution order.
|
|
326
|
+
//
|
|
327
|
+
// Snapshot any pre-existing file at the decoy path so the
|
|
328
|
+
// earlier "indexes root-level pods" test's fixture (or any
|
|
329
|
+
// future fixture sharing that path) can be restored after
|
|
330
|
+
// this test runs. Without this snapshot, deleting the decoy
|
|
331
|
+
// unconditionally would silently wipe legitimate state.
|
|
332
|
+
const decoyPk = getPublicKey(generateSecretKey()); // unrelated key
|
|
333
|
+
const decoyProfilePath = path.join(TEST_DATA_DIR, 'profile', 'card.jsonld');
|
|
334
|
+
const decoyWebId = `${baseUrl}/profile/card.jsonld#decoy`;
|
|
335
|
+
const sk = generateSecretKey();
|
|
336
|
+
const subPk = getPublicKey(sk);
|
|
337
|
+
const subWebId = 'http://sub.example.test/profile/card.jsonld#me';
|
|
338
|
+
const subProfilePath = path.join(TEST_DATA_DIR, 'sub', 'profile', 'card.jsonld');
|
|
339
|
+
const VM_ID = 'http://sub.example.test/profile/card.jsonld#k';
|
|
340
|
+
const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts');
|
|
341
|
+
const indexPath = path.join(accountsDir, '_webid_index.json');
|
|
342
|
+
const accountId = 'subdomain-pod-test-account';
|
|
343
|
+
|
|
344
|
+
// Snapshot any pre-existing file at the decoy path so a prior
|
|
345
|
+
// fixture (e.g. the "indexes root-level pods" test's profile
|
|
346
|
+
// at the same location) can be restored at cleanup. ENOENT
|
|
347
|
+
// means "didn't exist; remove on cleanup."
|
|
348
|
+
let decoySnapshot = null;
|
|
349
|
+
try {
|
|
350
|
+
decoySnapshot = await fs.readFile(decoyProfilePath, 'utf8');
|
|
351
|
+
} catch (e) {
|
|
352
|
+
if (e.code !== 'ENOENT') throw e;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Mutate fixtures inside try; cleanup runs regardless of
|
|
356
|
+
// whether assertions throw, so a future regression doesn't
|
|
357
|
+
// leak filesystem state and turn the suite order-dependent.
|
|
358
|
+
try {
|
|
359
|
+
await fs.ensureDir(path.dirname(decoyProfilePath));
|
|
360
|
+
await fs.writeJson(decoyProfilePath, {
|
|
361
|
+
'@context': 'https://www.w3.org/ns/solid/v1',
|
|
362
|
+
'@id': decoyWebId,
|
|
363
|
+
verificationMethod: [{
|
|
364
|
+
id: `${decoyWebId.replace('#decoy', '')}#k`,
|
|
365
|
+
type: 'Multikey',
|
|
366
|
+
controller: decoyWebId,
|
|
367
|
+
publicKeyMultibase: fformMultikey(decoyPk),
|
|
368
|
+
}],
|
|
369
|
+
authentication: [`${decoyWebId.replace('#decoy', '')}#k`],
|
|
370
|
+
}, { spaces: 2 });
|
|
371
|
+
|
|
372
|
+
await fs.ensureDir(path.dirname(subProfilePath));
|
|
373
|
+
await fs.writeJson(subProfilePath, {
|
|
374
|
+
'@context': 'https://www.w3.org/ns/solid/v1',
|
|
375
|
+
'@id': subWebId,
|
|
376
|
+
verificationMethod: [{
|
|
377
|
+
id: VM_ID,
|
|
378
|
+
type: 'Multikey',
|
|
379
|
+
controller: subWebId,
|
|
380
|
+
publicKeyMultibase: fformMultikey(subPk),
|
|
381
|
+
}],
|
|
382
|
+
authentication: [VM_ID],
|
|
383
|
+
}, { spaces: 2 });
|
|
384
|
+
|
|
385
|
+
const idx = await fs.readJson(indexPath);
|
|
386
|
+
idx[subWebId] = accountId;
|
|
387
|
+
await fs.writeJson(indexPath, idx, { spaces: 2 });
|
|
388
|
+
await fs.writeJson(path.join(accountsDir, `${accountId}.json`), {
|
|
389
|
+
id: accountId,
|
|
390
|
+
podName: 'sub',
|
|
391
|
+
webId: subWebId,
|
|
392
|
+
email: 'sub@example.test',
|
|
393
|
+
}, { spaces: 2 });
|
|
394
|
+
|
|
395
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${subPk}.json`);
|
|
396
|
+
assert.strictEqual(r.status, 200, 'subdomain-mode pod must be findable');
|
|
397
|
+
const doc = await r.json();
|
|
398
|
+
assert.strictEqual(doc.id, `did:nostr:${subPk}`);
|
|
399
|
+
assert.strictEqual(doc.alsoKnownAs[0], subWebId);
|
|
400
|
+
} finally {
|
|
401
|
+
// Restore decoy (or delete if it was created by this test).
|
|
402
|
+
if (decoySnapshot !== null) {
|
|
403
|
+
try { await fs.writeFile(decoyProfilePath, decoySnapshot, 'utf8'); }
|
|
404
|
+
catch { /* best effort */ }
|
|
405
|
+
} else {
|
|
406
|
+
try { await fs.remove(decoyProfilePath); } catch { /* best effort */ }
|
|
407
|
+
}
|
|
408
|
+
// Subdomain fixture file + empty parent dirs. fs-extra's
|
|
409
|
+
// `remove` handles both files and (recursively) dirs and
|
|
410
|
+
// is a no-op on missing paths — replaces the deprecated
|
|
411
|
+
// node `fs.rmdir`.
|
|
412
|
+
try { await fs.remove(subProfilePath); } catch { /* best effort */ }
|
|
413
|
+
try { await fs.remove(path.dirname(subProfilePath)); } catch { /* best effort */ }
|
|
414
|
+
try { await fs.remove(path.dirname(path.dirname(subProfilePath))); } catch { /* best effort */ }
|
|
415
|
+
// Index entry + account record.
|
|
416
|
+
try {
|
|
417
|
+
const idx = await fs.readJson(indexPath);
|
|
418
|
+
delete idx[subWebId];
|
|
419
|
+
await fs.writeJson(indexPath, idx, { spaces: 2 });
|
|
420
|
+
} catch { /* best effort */ }
|
|
421
|
+
try { await fs.remove(path.join(accountsDir, `${accountId}.json`)); } catch { /* best effort */ }
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
304
425
|
// No `it()` here — path containment is now exercised directly
|
|
305
426
|
// by unit tests on `profilePathFromWebId` below. The previous
|
|
306
427
|
// integration-style test couldn't actually trigger the
|
|
@@ -629,3 +750,104 @@ describe('profilePathFromWebId — DATA_ROOT containment', () => {
|
|
|
629
750
|
}
|
|
630
751
|
});
|
|
631
752
|
});
|
|
753
|
+
|
|
754
|
+
describe('profilePathCandidates — deployment-shape coverage (#411)', () => {
|
|
755
|
+
// The original `profilePathFromWebId` only emitted ONE candidate
|
|
756
|
+
// (`<dataRoot><pathname>`), which broke subdomain-mode pods on
|
|
757
|
+
// solid.social: account `b0b1707f-...` with WebID
|
|
758
|
+
// `https://test.solid.social/profile/card.jsonld#me` lives on disk
|
|
759
|
+
// at `<dataRoot>/test/profile/card.jsonld`, but the indexer was
|
|
760
|
+
// looking at `<dataRoot>/profile/card.jsonld` and ENOENT-ing.
|
|
761
|
+
//
|
|
762
|
+
// `profilePathCandidates` returns the full ordered list. Tests
|
|
763
|
+
// cover all three deployment shapes JSS supports.
|
|
764
|
+
//
|
|
765
|
+
// DATA_ROOT is path.resolve()d so the assertions below build
|
|
766
|
+
// expected values via path.join — works on Windows (different
|
|
767
|
+
// separator/root) the same as on POSIX.
|
|
768
|
+
const DATA_ROOT = path.resolve('/srv/jss/data');
|
|
769
|
+
|
|
770
|
+
it('path-mode named pod: <dataRoot>/<pod>/profile/card.jsonld', () => {
|
|
771
|
+
const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/alice/profile/card.jsonld#me');
|
|
772
|
+
const expected = path.join(DATA_ROOT, 'alice', 'profile', 'card.jsonld');
|
|
773
|
+
assert.ok(paths.includes(expected),
|
|
774
|
+
`expected ${expected}; got ${paths.join(', ')}`);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('root pod: <dataRoot>/profile/card.jsonld', () => {
|
|
778
|
+
const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/profile/card.jsonld#me');
|
|
779
|
+
const expected = path.join(DATA_ROOT, 'profile', 'card.jsonld');
|
|
780
|
+
assert.ok(paths.includes(expected),
|
|
781
|
+
`expected ${expected}; got ${paths.join(', ')}`);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('subdomain-mode pod: emits <dataRoot>/<podName>/profile/... when host first label matches podName', () => {
|
|
785
|
+
const { paths } = profilePathCandidates(DATA_ROOT, 'https://test.solid.social/profile/card.jsonld#me', 'test');
|
|
786
|
+
const pathMode = path.join(DATA_ROOT, 'profile', 'card.jsonld');
|
|
787
|
+
const subdomain = path.join(DATA_ROOT, 'test', 'profile', 'card.jsonld');
|
|
788
|
+
assert.ok(paths.includes(pathMode),
|
|
789
|
+
`expected path-mode candidate ${pathMode}; got ${paths.join(', ')}`);
|
|
790
|
+
assert.ok(paths.includes(subdomain),
|
|
791
|
+
`expected subdomain candidate ${subdomain}; got ${paths.join(', ')}`);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('does NOT emit a subdomain candidate when podName is omitted', () => {
|
|
795
|
+
const { paths } = profilePathCandidates(DATA_ROOT, 'https://test.solid.social/profile/card.jsonld#me');
|
|
796
|
+
assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('does NOT emit a subdomain candidate when podName does not match the host first label', () => {
|
|
800
|
+
const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/profile/card.jsonld#me', 'me');
|
|
801
|
+
assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('does NOT emit a subdomain candidate for a single-label host', () => {
|
|
805
|
+
const { paths } = profilePathCandidates(DATA_ROOT, 'http://localhost/profile/card.jsonld#me', 'localhost');
|
|
806
|
+
assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('returns empty paths for an unparseable webId', () => {
|
|
810
|
+
assert.deepStrictEqual(profilePathCandidates(DATA_ROOT, 'not a url'), { paths: [], skipped: [] });
|
|
811
|
+
assert.deepStrictEqual(profilePathCandidates(DATA_ROOT, null), { paths: [], skipped: [] });
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('every candidate stays inside dataRootAbs', () => {
|
|
815
|
+
const cases = [
|
|
816
|
+
['https://example.com/alice/profile/card.jsonld#me', 'alice'],
|
|
817
|
+
['https://alice.example.com/profile/card.jsonld#me', 'alice'],
|
|
818
|
+
['https://h/../../../etc/passwd', null],
|
|
819
|
+
['https://h.com/../../../etc/passwd', 'h'],
|
|
820
|
+
];
|
|
821
|
+
for (const [w, podName] of cases) {
|
|
822
|
+
const { paths } = profilePathCandidates(DATA_ROOT, w, podName);
|
|
823
|
+
for (const c of paths) {
|
|
824
|
+
assert.ok(c === DATA_ROOT || c.startsWith(DATA_ROOT + path.sep),
|
|
825
|
+
`${w} (podName=${podName}) → ${c} escaped DATA_ROOT`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('returns the `{ paths, skipped }` shape so the caller can surface diagnostics', () => {
|
|
831
|
+
// Restructure of pass-2: the function returns BOTH the
|
|
832
|
+
// containment-passed paths AND a `skipped` list of rejected
|
|
833
|
+
// candidates with reasons. rebuildPubkeyIndex folds `skipped`
|
|
834
|
+
// into its per-account failure log so operators can
|
|
835
|
+
// distinguish "traversal/misconfig" from "profile not on disk."
|
|
836
|
+
//
|
|
837
|
+
// Through normal URL-parsed input the `skipped` list stays
|
|
838
|
+
// empty (URL normalization prevents traversal in pathname,
|
|
839
|
+
// and the podName-gated subdomain candidate rejects mismatches
|
|
840
|
+
// before path-resolve ever runs). The field exists as
|
|
841
|
+
// defense-in-depth for any future caller that bypasses URL
|
|
842
|
+
// parsing or feeds an externally-derived podName, AND so the
|
|
843
|
+
// rebuild loop's failure log has a hook to surface
|
|
844
|
+
// containment rejections instead of dropping them silently.
|
|
845
|
+
const result = profilePathCandidates(DATA_ROOT,
|
|
846
|
+
'https://alice.example.com/profile/card.jsonld#me', 'alice');
|
|
847
|
+
assert.ok(Array.isArray(result.paths), 'paths must be an array');
|
|
848
|
+
assert.ok(Array.isArray(result.skipped), 'skipped must be an array');
|
|
849
|
+
assert.ok(result.paths.length > 0, 'happy-path must yield paths');
|
|
850
|
+
assert.deepStrictEqual(result.skipped, [],
|
|
851
|
+
'normal URL-parsed input must produce no skipped entries');
|
|
852
|
+
});
|
|
853
|
+
});
|