javascript-solid-server 0.0.172 → 0.0.174
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 +6 -1
- package/package.json +1 -1
- package/src/idp/index.js +8 -0
- package/src/idp/interactions.js +83 -0
- package/src/idp/views.js +9 -1
- package/src/webid/profile.js +33 -1
- package/test/idp.test.js +117 -0
- package/test/webid.test.js +47 -0
|
@@ -405,7 +405,12 @@
|
|
|
405
405
|
"Bash(ANDROID_HOME=/home/melvin/Android/Sdk ANDROID_SDK_ROOT=/home/melvin/Android/Sdk ./gradlew :app:compileDebugKotlin --no-daemon)",
|
|
406
406
|
"Bash(./scripts/fetch-libnode.sh v18.20.4)",
|
|
407
407
|
"Bash(ANDROID_HOME=/home/melvin/Android/Sdk ANDROID_SDK_ROOT=/home/melvin/Android/Sdk ./gradlew :app:assembleDebug --no-daemon)",
|
|
408
|
-
"Bash(/home/melvin/Android/Sdk/platform-tools/adb devices *)"
|
|
408
|
+
"Bash(/home/melvin/Android/Sdk/platform-tools/adb devices *)",
|
|
409
|
+
"Bash(command -v chromium chromium-browser google-chrome firefox playwright)",
|
|
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
|
+
"Bash(cp /tmp/consent-preview.html ~/consent-preview.html)",
|
|
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)"
|
|
409
414
|
]
|
|
410
415
|
}
|
|
411
416
|
}
|
package/package.json
CHANGED
package/src/idp/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
handleLogin,
|
|
12
12
|
handleConsent,
|
|
13
13
|
handleAbort,
|
|
14
|
+
handleSwitchAccount,
|
|
14
15
|
handleRegisterGet,
|
|
15
16
|
handleRegisterPost,
|
|
16
17
|
handlePasskeyComplete,
|
|
@@ -324,6 +325,13 @@ export async function idpPlugin(fastify, options) {
|
|
|
324
325
|
return handleAbort(request, reply, provider);
|
|
325
326
|
});
|
|
326
327
|
|
|
328
|
+
// POST "Sign in as a different user" (#384) — destroys the OIDC
|
|
329
|
+
// session and bounces back to the login prompt while preserving the
|
|
330
|
+
// in-flight authz request.
|
|
331
|
+
fastify.post('/idp/interaction/:uid/switch', async (request, reply) => {
|
|
332
|
+
return handleSwitchAccount(request, reply, provider);
|
|
333
|
+
});
|
|
334
|
+
|
|
327
335
|
// Registration routes (disabled in single-user mode)
|
|
328
336
|
if (singleUser) {
|
|
329
337
|
// Single-user mode: registration disabled
|
package/src/idp/interactions.js
CHANGED
|
@@ -297,6 +297,89 @@ export async function handleConsent(request, reply, provider) {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Handle POST /idp/interaction/:uid/switch
|
|
302
|
+
*
|
|
303
|
+
* "Sign in as a different user" from the consent page (#384). Destroys
|
|
304
|
+
* the current OIDC session, mutates the in-flight interaction back to
|
|
305
|
+
* the login prompt, and redirects the user to the same /idp/interaction
|
|
306
|
+
* URL — which `handleInteractionGet` will render as the login page.
|
|
307
|
+
*
|
|
308
|
+
* Re-using the same interaction uid (rather than starting a fresh
|
|
309
|
+
* /idp/auth flow) preserves the original authz request params so the
|
|
310
|
+
* caller's redirect_uri / state / nonce all flow through unchanged.
|
|
311
|
+
*/
|
|
312
|
+
export async function handleSwitchAccount(request, reply, provider) {
|
|
313
|
+
const { uid } = request.params;
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const interaction = await provider.Interaction.find(uid);
|
|
317
|
+
if (!interaction) {
|
|
318
|
+
return reply.code(404).type('text/html').send(errorPage('Interaction not found', 'This interaction may have expired. Try signing in again from your app.'));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The UI entrypoint is the consent page only. Refusing on other
|
|
322
|
+
// prompt states (login, passkey, etc.) prevents a crafted request
|
|
323
|
+
// from corrupting an in-flight non-consent interaction.
|
|
324
|
+
if (interaction.prompt?.name !== 'consent') {
|
|
325
|
+
return reply.code(400).type('text/html').send(errorPage('Cannot switch account here', 'Account switching is only available from the consent page.'));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Destroy the bound session so the new login starts cold. The cookie
|
|
329
|
+
// becomes a stale reference; oidc-provider's Session.get treats a
|
|
330
|
+
// missing session blob as "new browser", which is the shape we want.
|
|
331
|
+
if (interaction.session?.uid) {
|
|
332
|
+
const sess = await provider.Session.findByUid(interaction.session.uid);
|
|
333
|
+
if (sess) await sess.destroy();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Reset the interaction back to the login prompt, dropping the
|
|
337
|
+
// session reference and any prior `result` snapshot. `prompt`,
|
|
338
|
+
// `session`, and `result` are all in the oidc-provider Interaction
|
|
339
|
+
// IN_PAYLOAD allowlist, so the mutations persist through the
|
|
340
|
+
// adapter. Original `params` (client_id, redirect_uri, state, etc.)
|
|
341
|
+
// are untouched, so resume picks them up after login. Clearing
|
|
342
|
+
// `result` prevents a stale `result.login` from a previous identity
|
|
343
|
+
// influencing the next resume.
|
|
344
|
+
interaction.session = undefined;
|
|
345
|
+
interaction.result = undefined;
|
|
346
|
+
interaction.prompt = { name: 'login', reasons: ['no_session'], details: {} };
|
|
347
|
+
interaction.lastError = undefined;
|
|
348
|
+
const ttl = Math.max(1, interaction.exp - Math.floor(Date.now() / 1000));
|
|
349
|
+
await interaction.save(ttl);
|
|
350
|
+
|
|
351
|
+
// Clear the user-agent's session cookie too. The IdP runs with
|
|
352
|
+
// signed cookies (provider.js cookies.long.signed = true), so each
|
|
353
|
+
// session cookie has a paired `.sig`. The `.legacy` variant is
|
|
354
|
+
// created during identifier rotation and likewise has its own
|
|
355
|
+
// `.sig`. Clearing all four keeps the browser fully tidy. JSS
|
|
356
|
+
// doesn't register @fastify/cookie, so we emit Set-Cookie headers
|
|
357
|
+
// directly with an expired Expires + Max-Age=0. Server-side state
|
|
358
|
+
// is already gone via session.destroy() above — these expirations
|
|
359
|
+
// are belt-and-suspenders.
|
|
360
|
+
const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly';
|
|
361
|
+
reply.header('Set-Cookie', [
|
|
362
|
+
`_session=; ${expired}`,
|
|
363
|
+
`_session.sig=; ${expired}`,
|
|
364
|
+
`_session.legacy=; ${expired}`,
|
|
365
|
+
`_session.legacy.sig=; ${expired}`,
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
// 303 See Other — explicitly forces the UA to issue GET on the
|
|
369
|
+
// Location target. 302 leaves it ambiguous (and some legacy UAs
|
|
370
|
+
// repeat the POST), which would re-trigger this handler in a loop.
|
|
371
|
+
// Status-then-URL arg order matches the rest of the codebase
|
|
372
|
+
// (src/server.js:637, src/tunnel/index.js:222).
|
|
373
|
+
return reply.redirect(303, `/idp/interaction/${uid}`);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
request.log.error(err, 'Switch-account error');
|
|
376
|
+
// Don't surface raw err.message — adapter errors and stack-leaking
|
|
377
|
+
// strings on an auth endpoint are a soft info-leak. Full error is
|
|
378
|
+
// already in the server log via request.log.error above.
|
|
379
|
+
return reply.code(500).type('text/html').send(errorPage('Error', 'Something went wrong. Please try signing in again.'));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
300
383
|
/**
|
|
301
384
|
* Handle POST /idp/interaction/:uid/abort
|
|
302
385
|
* User cancelled the flow
|
package/src/idp/views.js
CHANGED
|
@@ -503,7 +503,15 @@ export function consentPage(uid, client, params, account) {
|
|
|
503
503
|
${clientUri ? `<div class="client-uri">${escapeHtml(clientUri)}</div>` : ''}
|
|
504
504
|
</div>
|
|
505
505
|
|
|
506
|
-
${account ?
|
|
506
|
+
${account ? `
|
|
507
|
+
<div style="display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; margin: 12px 0;">
|
|
508
|
+
<span>Signed in as <strong>${escapeHtml(account.email)}</strong></span>
|
|
509
|
+
<span style="color: #94a3b8;">·</span>
|
|
510
|
+
<form method="POST" action="/idp/interaction/${uid}/switch" style="display: inline; margin: 0;">
|
|
511
|
+
<button type="submit" style="background: none; border: 0; padding: 0; color: #2563eb; font: inherit; cursor: pointer; text-decoration: underline;">Sign in as a different user</button>
|
|
512
|
+
</form>
|
|
513
|
+
</div>
|
|
514
|
+
` : ''}
|
|
507
515
|
|
|
508
516
|
<div class="scopes">
|
|
509
517
|
<label>This app is requesting access to:</label>
|
package/src/webid/profile.js
CHANGED
|
@@ -31,6 +31,12 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
31
31
|
const docUrl = webId.split('#')[0];
|
|
32
32
|
|
|
33
33
|
return {
|
|
34
|
+
// CID v1 vocabulary is declared inline (rather than via an imported
|
|
35
|
+
// context URL) so JSS's JSON-LD → Turtle conneg layer can expand
|
|
36
|
+
// every term without fetching external contexts. Semantically
|
|
37
|
+
// equivalent to importing https://www.w3.org/ns/cid/v1: the IRIs
|
|
38
|
+
// each term expands to are the same. This keeps the profile a valid
|
|
39
|
+
// W3C Controlled Identifier document per LWS 1.0 (#386 Phase A).
|
|
34
40
|
'@context': {
|
|
35
41
|
'foaf': FOAF,
|
|
36
42
|
'solid': SOLID,
|
|
@@ -48,13 +54,39 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
48
54
|
'isPrimaryTopicOf': { '@id': 'foaf:isPrimaryTopicOf', '@type': '@id' },
|
|
49
55
|
'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' },
|
|
50
56
|
'service': { '@id': 'cid:service', '@container': '@set' },
|
|
51
|
-
'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' }
|
|
57
|
+
'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' },
|
|
58
|
+
// CID v1 terms used by Phase A and prepped for Phase B (the
|
|
59
|
+
// standalone "add my keys" app). Declaring these now means the
|
|
60
|
+
// app can PATCH in verificationMethod entries without having to
|
|
61
|
+
// also rewrite the @context.
|
|
62
|
+
//
|
|
63
|
+
// verificationMethod: NO @type:@id — values are inline verification
|
|
64
|
+
// method *objects* (id/type/controller/publicKey…), not just IRI
|
|
65
|
+
// references. @container:@set so a single entry stays an array.
|
|
66
|
+
// authentication / assertionMethod: @type:@id — values reference a
|
|
67
|
+
// verificationMethod entry by its IRI. @container:@set for arrays.
|
|
68
|
+
// publicKeyJwk: @type:@json so the JWK object round-trips as a
|
|
69
|
+
// literal JSON value (rdf:JSON datatype). Note: JSS's Turtle
|
|
70
|
+
// conneg layer doesn't yet emit @type:@json literals (tracked as
|
|
71
|
+
// a Phase B blocker in the PR description); declaring here is
|
|
72
|
+
// forward-looking and spec-correct.
|
|
73
|
+
'controller': { '@id': 'cid:controller', '@type': '@id' },
|
|
74
|
+
'verificationMethod': { '@id': 'cid:verificationMethod', '@container': '@set' },
|
|
75
|
+
'authentication': { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' },
|
|
76
|
+
'assertionMethod': { '@id': 'cid:assertionMethod', '@type': '@id', '@container': '@set' },
|
|
77
|
+
'publicKeyJwk': { '@id': 'cid:publicKeyJwk', '@type': '@json' },
|
|
78
|
+
'publicKeyMultibase': { '@id': 'cid:publicKeyMultibase' }
|
|
52
79
|
},
|
|
53
80
|
'@id': webId,
|
|
54
81
|
'@type': ['foaf:Person', 'schema:Person'],
|
|
55
82
|
'foaf:name': name,
|
|
56
83
|
'isPrimaryTopicOf': '',
|
|
57
84
|
'mainEntityOfPage': '',
|
|
85
|
+
// CID v1 self-control: the WebID is its own controller. Phase A of
|
|
86
|
+
// #386 ships this triple even with no verificationMethods yet, so a
|
|
87
|
+
// future Phase B "add-keys" app PATCHing in verificationMethod
|
|
88
|
+
// entries doesn't have to also wire up controllership separately.
|
|
89
|
+
'controller': webId,
|
|
58
90
|
'inbox': `${pod}inbox/`,
|
|
59
91
|
'storage': pod,
|
|
60
92
|
'oidcIssuer': issuer,
|
package/test/idp.test.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
6
6
|
import assert from 'node:assert';
|
|
7
|
+
import http from 'node:http';
|
|
7
8
|
import { createServer } from '../src/server.js';
|
|
8
9
|
import fs from 'fs-extra';
|
|
9
10
|
import path from 'path';
|
|
@@ -182,6 +183,122 @@ describe('Identity Provider', () => {
|
|
|
182
183
|
});
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
// Regression coverage for #384 — "Sign in as a different user" on consent
|
|
187
|
+
describe('Switch account on consent (#384)', () => {
|
|
188
|
+
// The IDP's filesystem adapter stores Interaction records as JSON at
|
|
189
|
+
// <DATA_ROOT>/.idp/interaction/<uid>.json (model name "Interaction"
|
|
190
|
+
// → dir "interaction" via the adapter's modelToDir camelCase split).
|
|
191
|
+
// Tests write a synthetic interaction directly so we don't have to
|
|
192
|
+
// walk a full OIDC client flow to set up state.
|
|
193
|
+
const interactionDir = `${DATA_DIR}/.idp/interaction`;
|
|
194
|
+
|
|
195
|
+
function writeInteraction(uid, payload) {
|
|
196
|
+
const ttlSec = 3600;
|
|
197
|
+
const data = {
|
|
198
|
+
...payload,
|
|
199
|
+
kind: 'Interaction',
|
|
200
|
+
jti: uid,
|
|
201
|
+
exp: Math.floor(Date.now() / 1000) + ttlSec,
|
|
202
|
+
iat: Math.floor(Date.now() / 1000),
|
|
203
|
+
_id: uid,
|
|
204
|
+
_expiresAt: Date.now() + ttlSec * 1000,
|
|
205
|
+
};
|
|
206
|
+
return fs.outputJson(`${interactionDir}/${uid}.json`, data, { spaces: 2 });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Use node:http directly for the cookie-clearing assertion. fetch's
|
|
210
|
+
// Headers.getSetCookie() is only available on Node 19.7+, but the
|
|
211
|
+
// package declares engines.node >= 18. http.request gives us
|
|
212
|
+
// res.headers['set-cookie'] as a real array on every supported
|
|
213
|
+
// Node version, no version-gated branches needed.
|
|
214
|
+
function rawPost(urlString) {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
const u = new URL(urlString);
|
|
217
|
+
const req = http.request({
|
|
218
|
+
method: 'POST',
|
|
219
|
+
hostname: u.hostname,
|
|
220
|
+
port: u.port,
|
|
221
|
+
path: u.pathname + u.search,
|
|
222
|
+
}, (res) => {
|
|
223
|
+
let body = '';
|
|
224
|
+
res.on('data', (c) => body += c);
|
|
225
|
+
res.on('end', () => resolve({
|
|
226
|
+
statusCode: res.statusCode,
|
|
227
|
+
headers: res.headers,
|
|
228
|
+
body,
|
|
229
|
+
}));
|
|
230
|
+
});
|
|
231
|
+
req.on('error', reject);
|
|
232
|
+
req.end();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
it('redirects back to /idp/interaction/:uid and resets the prompt to login', async () => {
|
|
237
|
+
const uid = 'test-switch-' + Math.random().toString(36).slice(2);
|
|
238
|
+
await writeInteraction(uid, {
|
|
239
|
+
prompt: { name: 'consent', reasons: [], details: {} },
|
|
240
|
+
session: { uid: 'fake-session-uid', accountId: 'acct-foo' },
|
|
241
|
+
params: { client_id: 'test-client', redirect_uri: 'http://localhost', state: 'xyz' },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const res = await rawPost(`${baseUrl}/idp/interaction/${uid}/switch`);
|
|
245
|
+
|
|
246
|
+
// 303 See Other — forces UA to GET the Location target so a
|
|
247
|
+
// (broken) UA can't loop by re-POSTing to /switch.
|
|
248
|
+
assert.strictEqual(res.statusCode, 303);
|
|
249
|
+
assert.strictEqual(res.headers.location, `/idp/interaction/${uid}`);
|
|
250
|
+
|
|
251
|
+
// Verify the interaction was mutated as expected.
|
|
252
|
+
const saved = await fs.readJson(`${interactionDir}/${uid}.json`);
|
|
253
|
+
assert.strictEqual(saved.prompt.name, 'login');
|
|
254
|
+
assert.ok(saved.session === undefined || saved.session === null,
|
|
255
|
+
'session should be cleared');
|
|
256
|
+
// Original params survive so resume can continue the authz request.
|
|
257
|
+
assert.strictEqual(saved.params.client_id, 'test-client');
|
|
258
|
+
assert.strictEqual(saved.params.state, 'xyz');
|
|
259
|
+
|
|
260
|
+
// Cookies should be cleared so the user's UA forgets the prior
|
|
261
|
+
// session. Node's http module gives Set-Cookie as an array on
|
|
262
|
+
// every supported version, so no Node 19.7+ gating needed.
|
|
263
|
+
const setCookies = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [];
|
|
264
|
+
assert.ok(setCookies.length >= 4, `expected at least 4 Set-Cookie headers, got ${setCookies.length}`);
|
|
265
|
+
// All four signed-cookie names should be cleared:
|
|
266
|
+
// _session + _session.sig + _session.legacy + _session.legacy.sig.
|
|
267
|
+
for (const name of ['_session=', '_session.sig=', '_session.legacy=', '_session.legacy.sig=']) {
|
|
268
|
+
assert.ok(setCookies.some(c => c.startsWith(name)),
|
|
269
|
+
`should clear ${name.slice(0, -1)}`);
|
|
270
|
+
}
|
|
271
|
+
assert.ok(setCookies.every(c => /Max-Age=0|Expires=Thu, 01 Jan 1970/.test(c)),
|
|
272
|
+
'all Set-Cookies should be expirations');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('returns 400 when the interaction is not on the consent prompt', async () => {
|
|
276
|
+
const uid = 'test-switch-bad-' + Math.random().toString(36).slice(2);
|
|
277
|
+
await writeInteraction(uid, {
|
|
278
|
+
prompt: { name: 'login', reasons: ['no_session'], details: {} },
|
|
279
|
+
params: { client_id: 'test-client' },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const res = await fetch(`${baseUrl}/idp/interaction/${uid}/switch`, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
redirect: 'manual',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
assert.strictEqual(res.status, 400);
|
|
288
|
+
// Original interaction should be untouched.
|
|
289
|
+
const saved = await fs.readJson(`${interactionDir}/${uid}.json`);
|
|
290
|
+
assert.strictEqual(saved.prompt.name, 'login');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('returns 404 for an unknown interaction uid', async () => {
|
|
294
|
+
const res = await fetch(`${baseUrl}/idp/interaction/does-not-exist-${Date.now()}/switch`, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
redirect: 'manual',
|
|
297
|
+
});
|
|
298
|
+
assert.strictEqual(res.status, 404);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
185
302
|
// Regression coverage for #286 — friendly /idp landing + /idp/auth guard.
|
|
186
303
|
describe('Landing page', () => {
|
|
187
304
|
it('GET /idp returns the landing HTML', async () => {
|
package/test/webid.test.js
CHANGED
|
@@ -47,6 +47,53 @@ describe('WebID Profile', () => {
|
|
|
47
47
|
assert.ok(jsonLd['@id'], 'Should have @id');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
// LWS-CID document conformance, Phase A of #386. The profile must be
|
|
51
|
+
// structurally a W3C Controlled Identifier document so a future
|
|
52
|
+
// PATCH-in-keys app (or server migration) can drop verificationMethod
|
|
53
|
+
// entries in without further plumbing. CID v1 vocabulary is declared
|
|
54
|
+
// inline rather than via context URL so JSS's conneg layer can
|
|
55
|
+
// expand every term without fetching external contexts — the IRIs
|
|
56
|
+
// are the same either way.
|
|
57
|
+
it('declares all six CID v1 terms in @context (#386 Phase A)', async () => {
|
|
58
|
+
const res = await request(profilePath);
|
|
59
|
+
const jsonLd = await res.json();
|
|
60
|
+
const ctx = jsonLd['@context'];
|
|
61
|
+
assert.ok(ctx, '@context required');
|
|
62
|
+
|
|
63
|
+
// All six CID terms must be declared and expand to the CID v1
|
|
64
|
+
// namespace. Accept either prefixed (cid:term) or full-URI
|
|
65
|
+
// (https://www.w3.org/ns/cid/v1#term) form.
|
|
66
|
+
const cidTerms = ['controller', 'verificationMethod', 'authentication', 'assertionMethod', 'publicKeyJwk', 'publicKeyMultibase'];
|
|
67
|
+
for (const term of cidTerms) {
|
|
68
|
+
const mapping = ctx[term];
|
|
69
|
+
assert.ok(mapping, `@context must define \`${term}\``);
|
|
70
|
+
const id = typeof mapping === 'string' ? mapping : mapping['@id'];
|
|
71
|
+
assert.match(id, new RegExp(`^(cid:${term}|https://www\\.w3\\.org/ns/cid/v1#${term})$`),
|
|
72
|
+
`${term} must map to the CID v1 namespace`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Container/type flags Phase B relies on:
|
|
76
|
+
// verificationMethod values are inline objects, NOT IRIs — must
|
|
77
|
+
// NOT have @type:@id (would force string-only) and SHOULD have
|
|
78
|
+
// @container:@set so a single entry is still an array.
|
|
79
|
+
assert.notStrictEqual(ctx.verificationMethod['@type'], '@id',
|
|
80
|
+
'verificationMethod values are objects, not IRIs');
|
|
81
|
+
assert.strictEqual(ctx.verificationMethod['@container'], '@set');
|
|
82
|
+
// authentication / assertionMethod reference verificationMethod
|
|
83
|
+
// entries by IRI, so @type:@id is correct.
|
|
84
|
+
assert.strictEqual(ctx.authentication['@type'], '@id');
|
|
85
|
+
assert.strictEqual(ctx.assertionMethod['@type'], '@id');
|
|
86
|
+
// JWK is a literal JSON value (rdf:JSON datatype) per JSON-LD 1.1.
|
|
87
|
+
assert.strictEqual(ctx.publicKeyJwk['@type'], '@json');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('declares self-control via controller === @id (#386 Phase A)', async () => {
|
|
91
|
+
const res = await request(profilePath);
|
|
92
|
+
const jsonLd = await res.json();
|
|
93
|
+
assert.strictEqual(jsonLd.controller, jsonLd['@id'],
|
|
94
|
+
'profile must declare itself as its own controller per CID v1');
|
|
95
|
+
});
|
|
96
|
+
|
|
50
97
|
it('should have correct WebID URI', async () => {
|
|
51
98
|
const res = await request(profilePath);
|
|
52
99
|
const jsonLd = await res.json();
|