javascript-solid-server 0.0.137 → 0.0.139

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.137",
3
+ "version": "0.0.139",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/ap/index.js CHANGED
@@ -68,7 +68,7 @@ export async function activityPubPlugin(fastify, options = {}) {
68
68
  const getActorId = (request) => {
69
69
  const protocol = getProtocol(request)
70
70
  const host = request.headers['x-forwarded-host'] || request.hostname
71
- return `${protocol}://${host}/profile/card#me`
71
+ return `${protocol}://${host}/profile/card.jsonld#me`
72
72
  }
73
73
 
74
74
  // Helper to get base URL
@@ -96,11 +96,11 @@ export async function activityPubPlugin(fastify, options = {}) {
96
96
  return reply.code(404).send({ error: 'Not found' })
97
97
  }
98
98
 
99
- // For now, accept any username and map to /profile/card#me
99
+ // For now, accept any username and map to /profile/card.jsonld#me
100
100
  // In multi-user mode, we'd look up the user
101
101
  const baseUrl = getBaseUrl(request)
102
- const actorUrl = `${baseUrl}/profile/card#me`
103
- const profileUrl = `${baseUrl}/profile/card`
102
+ const actorUrl = `${baseUrl}/profile/card.jsonld#me`
103
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
104
104
 
105
105
  const response = webfinger.createResponse(
106
106
  `${parsed.username}@${parsed.domain}`,
@@ -174,18 +174,18 @@ export async function activityPubPlugin(fastify, options = {}) {
174
174
  // Inbox endpoint
175
175
  const inboxHandler = createInboxHandler(config, keypair)
176
176
  fastify.post('/inbox', inboxHandler)
177
- fastify.post('/profile/card/inbox', inboxHandler)
177
+ fastify.post('/profile/card.jsonld/inbox', inboxHandler)
178
178
 
179
179
  // Outbox endpoint
180
180
  const outboxHandler = createOutboxHandler(config, keypair)
181
181
  const outboxPostHandler = createOutboxPostHandler(config, keypair)
182
- fastify.get('/profile/card/outbox', outboxHandler)
183
- fastify.post('/profile/card/outbox', outboxPostHandler)
182
+ fastify.get('/profile/card.jsonld/outbox', outboxHandler)
183
+ fastify.post('/profile/card.jsonld/outbox', outboxPostHandler)
184
184
 
185
185
  // Followers/Following collections
186
186
  const collectionsHandler = createCollectionsHandler(config)
187
- fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
188
- fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
187
+ fastify.get('/profile/card.jsonld/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
188
+ fastify.get('/profile/card.jsonld/following', (req, reply) => collectionsHandler(req, reply, 'following'))
189
189
 
190
190
  // Mastodon-compatible API endpoints
191
191
  fastify.post('/api/v1/apps', createAppsHandler())
package/src/ap/keys.js CHANGED
@@ -52,8 +52,8 @@ export function loadOrCreateKeypair(path = DEFAULT_KEY_PATH) {
52
52
 
53
53
  /**
54
54
  * Get key ID for HTTP Signatures
55
- * @param {string} actorId - Actor URL (e.g., https://example.com/profile/card#me)
56
- * @returns {string} Key ID (e.g., https://example.com/profile/card#main-key)
55
+ * @param {string} actorId - Actor URL (e.g., https://example.com/profile/card.jsonld#me)
56
+ * @returns {string} Key ID (e.g., https://example.com/profile/card.jsonld#main-key)
57
57
  */
58
58
  export function getKeyId(actorId) {
59
59
  // Strip fragment and add #main-key
@@ -30,7 +30,7 @@ export function createActorHandler(config, keypair) {
30
30
  }
31
31
  protocol = protocol || request.protocol
32
32
  const baseUrl = `${protocol}://${host}`
33
- const profileUrl = `${baseUrl}/profile/card`
33
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
34
34
  const actorId = `${profileUrl}#me`
35
35
 
36
36
  const actor = {
@@ -15,7 +15,7 @@ export function createCollectionsHandler(config) {
15
15
  const protocol = request.headers['x-forwarded-proto'] || request.protocol
16
16
  const host = request.headers['x-forwarded-host'] || request.hostname
17
17
  const baseUrl = `${protocol}://${host}`
18
- const profileUrl = `${baseUrl}/profile/card`
18
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
19
19
 
20
20
  let items, totalItems
21
21
 
@@ -150,7 +150,7 @@ export function createInboxHandler(config, keypair) {
150
150
  const protocol = request.headers['x-forwarded-proto'] || request.protocol
151
151
  const host = request.headers['x-forwarded-host'] || request.hostname
152
152
  const baseUrl = `${protocol}://${host}`
153
- const profileUrl = `${baseUrl}/profile/card`
153
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
154
154
  const actorId = `${profileUrl}#me`
155
155
 
156
156
  request.log.info(`Received ${activity.type} from ${activity.actor}`)
@@ -78,8 +78,8 @@ export function createVerifyCredentialsHandler (config) {
78
78
  acct: config.username,
79
79
  display_name: config.displayName,
80
80
  note: config.summary ? `<p>${escapeHtml(config.summary)}</p>` : '',
81
- url: `${baseUrl}/profile/card`,
82
- uri: `${baseUrl}/profile/card#me`,
81
+ url: `${baseUrl}/profile/card.jsonld`,
82
+ uri: `${baseUrl}/profile/card.jsonld#me`,
83
83
  avatar: `${baseUrl}/profile/avatar.png`,
84
84
  header: '',
85
85
  locked: false,
@@ -19,7 +19,7 @@ export function createOutboxHandler(config, keypair) {
19
19
  const protocol = request.headers['x-forwarded-proto'] || request.protocol
20
20
  const host = request.headers['x-forwarded-host'] || request.hostname
21
21
  const baseUrl = `${protocol}://${host}`
22
- const profileUrl = `${baseUrl}/profile/card`
22
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
23
23
  const actorId = `${profileUrl}#me`
24
24
 
25
25
  const posts = getPosts(20)
@@ -63,7 +63,7 @@ export function createOutboxPostHandler(config, keypair) {
63
63
  const protocol = request.headers['x-forwarded-proto'] || request.protocol
64
64
  const host = request.headers['x-forwarded-host'] || request.hostname
65
65
  const baseUrl = `${protocol}://${host}`
66
- const profileUrl = `${baseUrl}/profile/card`
66
+ const profileUrl = `${baseUrl}/profile/card.jsonld`
67
67
  const actorId = `${profileUrl}#me`
68
68
 
69
69
  // Parse body
@@ -0,0 +1,122 @@
1
+ /**
2
+ * TOKEN_SECRET resolution.
3
+ *
4
+ * Extracted from token.js so it can be unit-tested without pulling in the
5
+ * full auth graph (solid-oidc, nostr, webid-tls), which does module-level
6
+ * work that keeps the node:test event loop busy.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+
14
+ export const DEFAULT_SECRET_PATH = path.join(os.homedir(), '.jss', 'token.secret');
15
+
16
+ // Tighten permissions on POSIX, best-effort. No-op on Windows (ACLs) and
17
+ // on read-only filesystems — we never want perm-tightening to block using
18
+ // an otherwise-valid secret.
19
+ function chmodBestEffort(target, mode) {
20
+ try {
21
+ fs.chmodSync(target, mode);
22
+ } catch {
23
+ // Intentionally swallow — perms are defensive hardening, not required.
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Read a persisted secret from `filePath`, or generate one and write it
29
+ * (with dir mode 0700 and file mode 0600) if the file is missing.
30
+ *
31
+ * Read-first: if the file already exists and is non-empty we return it
32
+ * without trying to mkdir or tighten the containing directory. Deployments
33
+ * with a pre-provisioned secret on a read-only filesystem boot cleanly.
34
+ *
35
+ * Concurrent-startup safe: new secrets are written to a per-process temp
36
+ * file in the same directory and `renameSync`'d into place, so another
37
+ * process reading the target never sees a half-written file. If a peer
38
+ * process won the rename we fall back to reading their value.
39
+ *
40
+ * Anything other than ENOENT on the initial read (permission denied,
41
+ * corrupt FS, …) propagates.
42
+ */
43
+ export function readOrWritePersistedSecret(filePath = DEFAULT_SECRET_PATH) {
44
+ const dir = path.dirname(filePath);
45
+
46
+ // Fast path: pre-existing non-empty file. We do not mkdir the parent
47
+ // dir here, and perm-tightening is best-effort (chmodBestEffort swallows
48
+ // all errors), so a pre-provisioned secret on a read-only filesystem
49
+ // still boots cleanly.
50
+ try {
51
+ const existing = fs.readFileSync(filePath, 'utf8').trim();
52
+ if (existing) {
53
+ chmodBestEffort(dir, 0o700);
54
+ chmodBestEffort(filePath, 0o600);
55
+ return existing;
56
+ }
57
+ } catch (e) {
58
+ if (e.code !== 'ENOENT') throw e;
59
+ }
60
+
61
+ // Slow path: create it. Only touch the FS with writes from here on.
62
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ chmodBestEffort(dir, 0o700);
64
+
65
+ const generated = crypto.randomBytes(32).toString('hex');
66
+ // Atomic write: fully write a temp file, then rename into place. On
67
+ // POSIX the rename is atomic, so concurrent readers see either the old
68
+ // content or the new complete content — never a half-written file.
69
+ const tmpPath = `${filePath}.${crypto.randomBytes(8).toString('hex')}.tmp`;
70
+ try {
71
+ fs.writeFileSync(tmpPath, generated, { mode: 0o600 });
72
+ fs.renameSync(tmpPath, filePath);
73
+ } catch (e) {
74
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
75
+ throw e;
76
+ }
77
+ chmodBestEffort(filePath, 0o600);
78
+
79
+ // Multiple processes racing each produce a different secret; only the
80
+ // last renamer's value sticks on disk. Re-read so every process ends up
81
+ // using the winning secret and token verification stays consistent.
82
+ const persisted = fs.readFileSync(filePath, 'utf8').trim();
83
+ return persisted || generated;
84
+ }
85
+
86
+ /**
87
+ * Resolve the token secret.
88
+ *
89
+ * 1. TOKEN_SECRET env → use it.
90
+ * 2. Else read/create ~/.jss/token.secret.
91
+ * 3. On file-write failure: hard-exit in production, ephemeral secret otherwise.
92
+ *
93
+ * Console I/O is injected so tests can assert log behaviour without spamming
94
+ * the real console; defaults to the real console.
95
+ */
96
+ export function resolveTokenSecret({
97
+ env = process.env,
98
+ secretPath = DEFAULT_SECRET_PATH,
99
+ log = console,
100
+ exit = (code) => process.exit(code),
101
+ } = {}) {
102
+ if (env.TOKEN_SECRET) return env.TOKEN_SECRET;
103
+
104
+ try {
105
+ const s = readOrWritePersistedSecret(secretPath);
106
+ log.warn(`Using persisted TOKEN_SECRET at ${secretPath} (set TOKEN_SECRET env var to override).`);
107
+ return s;
108
+ } catch (e) {
109
+ if (env.NODE_ENV === 'production') {
110
+ const code = e?.code ? ` [${e.code}]` : '';
111
+ log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} could not be read or created${code} (${e.message}).`);
112
+ log.error(`Set TOKEN_SECRET explicitly, or grant the necessary access to ${path.dirname(secretPath)}.`);
113
+ exit(1);
114
+ // `exit` is injectable; if a caller stubs it out we must not silently
115
+ // return undefined and let downstream code use an invalid secret.
116
+ throw new Error(`Failed to resolve TOKEN_SECRET in production: ${e.message}`);
117
+ }
118
+ const ephemeral = crypto.randomBytes(32).toString('hex');
119
+ log.warn(`WARNING: Could not persist TOKEN_SECRET (${e.message}). Using ephemeral secret; tokens will not survive restarts.`);
120
+ return ephemeral;
121
+ }
122
+ }
package/src/auth/token.js CHANGED
@@ -11,30 +11,11 @@ import crypto from 'crypto';
11
11
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
12
12
  import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
13
13
  import { webIdTlsAuth, hasClientCertificate } from './webid-tls.js';
14
+ import { resolveTokenSecret } from './token-secret.js';
14
15
 
15
- // Secret for signing tokens
16
- // SECURITY: In production, TOKEN_SECRET must be set via environment variable
17
- const getSecret = () => {
18
- if (process.env.TOKEN_SECRET) {
19
- return process.env.TOKEN_SECRET;
20
- }
21
-
22
- // In production (NODE_ENV=production), require explicit secret
23
- if (process.env.NODE_ENV === 'production') {
24
- console.error('SECURITY ERROR: TOKEN_SECRET environment variable must be set in production');
25
- console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
26
- process.exit(1);
27
- }
28
-
29
- // In development, generate a random secret per process (tokens won't survive restarts)
30
- const devSecret = crypto.randomBytes(32).toString('hex');
31
- console.warn('WARNING: No TOKEN_SECRET set. Using random secret (tokens will not survive restarts).');
32
- console.warn('Set TOKEN_SECRET environment variable for persistent tokens.');
33
- return devSecret;
34
- };
35
-
36
- // Initialize secret once at module load
37
- const SECRET = getSecret();
16
+ // Initialize secret once at module load. See token-secret.js for the
17
+ // resolution order (env ~/.jss/token.secret exit-or-ephemeral).
18
+ const SECRET = resolveTokenSecret();
38
19
 
39
20
  /**
40
21
  * Create a simple token for a WebID
package/src/db/index.js CHANGED
@@ -59,8 +59,8 @@ export async function dbPlugin(fastify, options) {
59
59
  if (podName) {
60
60
  // Build expected WebID for both path and subdomain modes
61
61
  const expectedWebId = request.subdomainsEnabled && request.baseDomain
62
- ? `${request.protocol}://${podName}.${request.baseDomain}/profile/card#me`
63
- : `${request.protocol}://${request.hostname}/${podName}/profile/card#me`;
62
+ ? `${request.protocol}://${podName}.${request.baseDomain}/profile/card.jsonld#me`
63
+ : `${request.protocol}://${request.hostname}/${podName}/profile/card.jsonld#me`;
64
64
  if (webId !== expectedWebId) {
65
65
  return reply.code(403).send({ error: 'Forbidden', message: 'You can only write to your own /db/ space' });
66
66
  }
@@ -173,20 +173,20 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
173
173
  await storage.createContainer(`${podPath}settings/`);
174
174
  await storage.createContainer(`${podPath}profile/`);
175
175
 
176
- // Generate and write WebID profile at /profile/card (standard Solid location)
177
- const profileHtml = generateProfile({ webId, name, podUri, issuer });
178
- await storage.write(`${podPath}profile/card`, profileHtml);
176
+ // Generate and write WebID profile at /profile/card.jsonld
177
+ const profile = generateProfile({ webId, name, podUri, issuer });
178
+ await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));
179
179
 
180
- // Generate and write preferences (mashlib-compatible paths)
180
+ // Generate and write preferences
181
181
  const prefs = generatePreferences({ webId, podUri });
182
- await storage.write(`${podPath}settings/Preferences.ttl`, serialize(prefs));
182
+ await storage.write(`${podPath}settings/prefs.jsonld`, serialize(prefs));
183
183
 
184
- // Generate and write type indexes with .ttl extension for mashlib
185
- const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.ttl`);
186
- await storage.write(`${podPath}settings/publicTypeIndex.ttl`, serialize(publicTypeIndex));
184
+ // Generate and write type indexes
185
+ const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`);
186
+ await storage.write(`${podPath}settings/publicTypeIndex.jsonld`, serialize(publicTypeIndex));
187
187
 
188
- const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.ttl`);
189
- await storage.write(`${podPath}settings/privateTypeIndex.ttl`, serialize(privateTypeIndex));
188
+ const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`);
189
+ await storage.write(`${podPath}settings/privateTypeIndex.jsonld`, serialize(privateTypeIndex));
190
190
 
191
191
  // Create default ACL files
192
192
  // Pod root: owner full control, public read
@@ -229,13 +229,13 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
229
229
  *
230
230
  * Creates the following structure:
231
231
  * /{name}/
232
- * /{name}/profile/card - WebID profile
233
- * /{name}/inbox/ - Notifications
234
- * /{name}/public/ - Public files
235
- * /{name}/private/ - Private files
236
- * /{name}/settings/prefs - Preferences
237
- * /{name}/settings/publicTypeIndex
238
- * /{name}/settings/privateTypeIndex
232
+ * /{name}/profile/card.jsonld - WebID profile
233
+ * /{name}/inbox/ - Notifications
234
+ * /{name}/public/ - Public files
235
+ * /{name}/private/ - Private files
236
+ * /{name}/settings/prefs.jsonld - Preferences
237
+ * /{name}/settings/publicTypeIndex.jsonld
238
+ * /{name}/settings/privateTypeIndex.jsonld
239
239
  */
240
240
  export async function handleCreatePod(request, reply) {
241
241
  // Read-only mode - block pod creation
@@ -272,23 +272,22 @@ export async function handleCreatePod(request, reply) {
272
272
  return reply.code(409).send({ error: 'Pod already exists' });
273
273
  }
274
274
 
275
- // Build URIs
276
- // WebID follows standard Solid convention: /alice/profile/card#me
275
+ // Build URIs. WebID is the JSON-LD profile with an #me fragment.
277
276
  const subdomainsEnabled = request.subdomainsEnabled;
278
277
  const baseDomain = request.baseDomain;
279
278
 
280
279
  let baseUri, podUri, webId;
281
280
  if (subdomainsEnabled && baseDomain) {
282
- // Subdomain mode: alice.example.com/profile/card#me
281
+ // Subdomain mode: alice.example.com/profile/card.jsonld#me
283
282
  const podHost = `${name}.${baseDomain}`;
284
283
  baseUri = `${request.protocol}://${baseDomain}`;
285
284
  podUri = `${request.protocol}://${podHost}/`;
286
- webId = `${podUri}profile/card#me`;
285
+ webId = `${podUri}profile/card.jsonld#me`;
287
286
  } else {
288
- // Path mode: example.com/alice/profile/card#me
287
+ // Path mode: example.com/alice/profile/card.jsonld#me
289
288
  baseUri = `${request.protocol}://${request.hostname}`;
290
289
  podUri = `${baseUri}${podPath}`;
291
- webId = `${podUri}profile/card#me`;
290
+ webId = `${podUri}profile/card.jsonld#me`;
292
291
  }
293
292
 
294
293
  // Issuer needs trailing slash for CTH compatibility
@@ -405,20 +405,20 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
405
405
  }
406
406
 
407
407
  try {
408
- // Build URLs - WebID follows standard Solid convention: /profile/card#me
408
+ // Build URLs. WebID is the JSON-LD profile with an #me fragment.
409
409
  const subdomainsEnabled = request.subdomainsEnabled;
410
410
  const baseDomain = request.baseDomain;
411
411
  const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
412
412
 
413
413
  let podUri, webId;
414
414
  if (subdomainsEnabled && baseDomain) {
415
- // Subdomain mode: alice.example.com/profile/card#me
415
+ // Subdomain mode: alice.example.com/profile/card.jsonld#me
416
416
  podUri = `${request.protocol}://${username}.${baseDomain}/`;
417
- webId = `${podUri}profile/card#me`;
417
+ webId = `${podUri}profile/card.jsonld#me`;
418
418
  } else {
419
- // Path mode: example.com/alice/profile/card#me
419
+ // Path mode: example.com/alice/profile/card.jsonld#me
420
420
  podUri = `${baseUrl}/${username}/`;
421
- webId = `${podUri}profile/card#me`;
421
+ webId = `${podUri}profile/card.jsonld#me`;
422
422
  }
423
423
 
424
424
  // Check if pod already exists
package/src/server.js CHANGED
@@ -314,12 +314,12 @@ export function createServer(options = {}) {
314
314
  // Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
315
315
  });
316
316
 
317
- // ActivityPub actor endpoint - dedicated route for /profile/card with AP Accept header
317
+ // ActivityPub actor endpoint - dedicated route for /profile/card.jsonld with AP Accept header
318
318
  // Registered before wildcard routes to take priority
319
319
  if (activitypubEnabled) {
320
320
  fastify.route({
321
321
  method: 'GET',
322
- url: '/profile/card',
322
+ url: '/profile/card.jsonld',
323
323
  handler: async (request, reply) => {
324
324
  const accept = request.headers.accept || '';
325
325
  const wantsAP = accept.includes('activity+json') ||
@@ -426,13 +426,13 @@ export function createServer(options = {}) {
426
426
  fastify.addHook('preHandler', async (request, reply) => {
427
427
  // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
428
428
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
429
- const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
429
+ const apPaths = ['/inbox', '/profile/card.jsonld/inbox', '/profile/card.jsonld/outbox', '/profile/card.jsonld/followers', '/profile/card.jsonld/following',
430
430
  '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
431
431
  '/oauth/authorize', '/oauth/token'];
432
432
  // Check if request wants ActivityPub content for profile
433
433
  const accept = request.headers.accept || '';
434
434
  const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
435
- const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card' || request.url.startsWith('/profile/card?'));
435
+ const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card.jsonld' || request.url.startsWith('/profile/card.jsonld?'));
436
436
  if (request.url === '/.pods' ||
437
437
  request.url === '/.notifications' ||
438
438
  request.method === 'OPTIONS' ||
@@ -557,11 +557,15 @@ export function createServer(options = {}) {
557
557
  const isRootPod = !singleUserName || singleUserName === '/';
558
558
  const podPath = isRootPod ? '/' : `/${singleUserName}/`;
559
559
  const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
560
- const webId = `${podUri}profile/card#me`;
560
+ const webId = `${podUri}profile/card.jsonld#me`;
561
561
  const displayName = isRootPod ? 'me' : singleUserName;
562
562
 
563
- // Check if pod already exists (profile/card is the indicator)
564
- const profileExists = await storage.exists(`${podPath}profile/card`);
563
+ // Check if pod already exists. Accept either the new `card.jsonld`
564
+ // or legacy extensionless `card` layout so we don't re-seed a pod
565
+ // that was created by an older JSS version.
566
+ const profileExists =
567
+ await storage.exists(`${podPath}profile/card.jsonld`) ||
568
+ await storage.exists(`${podPath}profile/card`);
565
569
 
566
570
  if (!profileExists) {
567
571
  fastify.log.info(`Creating single-user pod at ${podUri}...`);
@@ -593,18 +597,18 @@ export function createServer(options = {}) {
593
597
  await storage.createContainer('/profile/');
594
598
 
595
599
  // Generate profile
596
- const profileHtml = generateProfile({ webId, name: displayName, podUri, issuer });
597
- await storage.write('/profile/card', profileHtml);
600
+ const profile = generateProfile({ webId, name: displayName, podUri, issuer });
601
+ await storage.write('/profile/card.jsonld', serialize(profile));
598
602
 
599
603
  // Preferences and type indexes
600
604
  const prefs = generatePreferences({ webId, podUri });
601
- await storage.write('/settings/Preferences.ttl', serialize(prefs));
605
+ await storage.write('/settings/prefs.jsonld', serialize(prefs));
602
606
 
603
- const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.ttl`);
604
- await storage.write('/settings/publicTypeIndex.ttl', serialize(publicTypeIndex));
607
+ const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`);
608
+ await storage.write('/settings/publicTypeIndex.jsonld', serialize(publicTypeIndex));
605
609
 
606
- const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.ttl`);
607
- await storage.write('/settings/privateTypeIndex.ttl', serialize(privateTypeIndex));
610
+ const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`);
611
+ await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex));
608
612
 
609
613
  // ACL files
610
614
  const rootAcl = generateOwnerAcl(podUri, webId, true);
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * WebID Profile generation
3
- * Creates profile documents following Solid conventions
4
- * Profile is HTML with embedded JSON-LD structured data
3
+ *
4
+ * Creates profile documents following Solid conventions. Default profile
5
+ * shape is now plain JSON-LD at `profile/card.jsonld` — operators who
6
+ * want a human-readable HTML shell can serve their own `index.html` with
7
+ * an embedded `<script type="application/ld+json">` data island.
5
8
  */
6
9
 
7
10
  const FOAF = 'http://xmlns.com/foaf/0.1/';
@@ -45,95 +48,33 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
45
48
  'inbox': `${pod}inbox/`,
46
49
  'storage': pod,
47
50
  'oidcIssuer': issuer,
48
- 'preferencesFile': `${pod}settings/Preferences.ttl`,
49
- 'publicTypeIndex': `${pod}settings/publicTypeIndex.ttl`,
50
- 'privateTypeIndex': `${pod}settings/privateTypeIndex.ttl`
51
+ 'preferencesFile': `${pod}settings/prefs.jsonld`,
52
+ 'publicTypeIndex': `${pod}settings/publicTypeIndex.jsonld`,
53
+ 'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`
51
54
  };
52
55
  }
53
56
 
54
57
  /**
55
- * Generate HTML profile with embedded JSON-LD data island
56
- * The page uses mashlib + solidos-lite to render the profile from the data island
58
+ * Generate the profile document as a plain JSON-LD object.
59
+ *
60
+ * Previously returned an HTML shell with an embedded data island; that
61
+ * shell still exists for hand-curated personal sites, but server-default
62
+ * profiles are now plain JSON-LD for predictability and easier
63
+ * post-processing by clients.
64
+ *
57
65
  * @param {object} options
58
66
  * @param {string} options.webId - Full WebID URI
59
67
  * @param {string} options.name - Display name
60
68
  * @param {string} options.podUri - Pod root URI
61
69
  * @param {string} options.issuer - OIDC issuer URI
62
- * @returns {string} HTML document with JSON-LD data island
70
+ * @returns {object} JSON-LD profile document
63
71
  */
64
72
  export function generateProfile({ webId, name, podUri, issuer }) {
65
- const jsonLd = generateProfileJsonLd({ webId, name, podUri, issuer });
66
-
67
- return `<!DOCTYPE html>
68
- <html lang="en">
69
- <head>
70
- <meta charset="utf-8">
71
- <meta name="viewport" content="width=device-width, initial-scale=1">
72
- <title>${escapeHtml(name)}'s Profile</title>
73
- <link rel="stylesheet" href="https://javascriptsolidserver.github.io/mashlib-jss/dist/mash.css">
74
- <script type="application/ld+json">
75
- ${JSON.stringify(jsonLd, null, 2)}
76
- </script>
77
- <style>
78
- body { margin: 0; font-family: system-ui, sans-serif; }
79
- .loading { padding: 2rem; text-align: center; color: #666; }
80
- </style>
81
- </head>
82
- <body>
83
- <div class="TabulatorOutline" id="DummyUUID" role="main">
84
- <table id="outline"></table>
85
- <div id="GlobalDashboard"></div>
86
- </div>
87
- <div class="loading" id="loading">Loading profile...</div>
88
-
89
- <script src="https://javascriptsolidserver.github.io/mashlib-jss/dist/mashlib.min.js"></script>
90
- <script src="https://cdn.jsdelivr.net/npm/solidos-lite/solidos-lite.js"></script>
91
- <script>
92
- document.addEventListener('DOMContentLoaded', function() {
93
- const loadingEl = document.getElementById('loading');
94
-
95
- // Initialize solidos-lite to handle data islands
96
- const success = SolidOSLite.init({ verbose: false });
97
- if (!success) {
98
- loadingEl.textContent = 'Failed to initialize. Please try refreshing.';
99
- return;
100
- }
101
-
102
- // Parse data islands into the RDF store
103
- SolidOSLite.parseAllIslands();
104
-
105
- // Mark this document as already fetched
106
- const pageBase = window.location.href.split('?')[0].split('#')[0];
107
- const fetcher = SolidLogic.store.fetcher;
108
- fetcher.requested[pageBase] = 'done';
109
- fetcher.requested[pageBase.replace(/\\/$/, '')] = 'done';
110
-
111
- // Navigate to #me
112
- const subject = $rdf.sym(pageBase + '#me');
113
- const outliner = panes.getOutliner(document);
114
- outliner.GotoSubject(subject, true, undefined, true, undefined);
115
-
116
- loadingEl.style.display = 'none';
117
- });
118
- </script>
119
- </body>
120
- </html>`;
121
- }
122
-
123
- /**
124
- * Escape HTML entities
125
- */
126
- function escapeHtml(str) {
127
- return str
128
- .replace(/&/g, '&amp;')
129
- .replace(/</g, '&lt;')
130
- .replace(/>/g, '&gt;')
131
- .replace(/"/g, '&quot;');
73
+ return generateProfileJsonLd({ webId, name, podUri, issuer });
132
74
  }
133
75
 
134
76
  /**
135
- * Generate preferences file as JSON-LD
136
- * Uses mashlib-compatible paths (settings/Preferences.ttl)
77
+ * Generate preferences file as JSON-LD.
137
78
  * @param {object} options
138
79
  * @param {string} options.webId - Full WebID URI
139
80
  * @param {string} options.podUri - Pod root URI
@@ -149,9 +90,9 @@ export function generatePreferences({ webId, podUri }) {
149
90
  'publicTypeIndex': { '@id': 'solid:publicTypeIndex', '@type': '@id' },
150
91
  'privateTypeIndex': { '@id': 'solid:privateTypeIndex', '@type': '@id' }
151
92
  },
152
- '@id': `${pod}settings/Preferences.ttl`,
153
- 'publicTypeIndex': `${pod}settings/publicTypeIndex.ttl`,
154
- 'privateTypeIndex': `${pod}settings/privateTypeIndex.ttl`
93
+ '@id': `${pod}settings/prefs.jsonld`,
94
+ 'publicTypeIndex': `${pod}settings/publicTypeIndex.jsonld`,
95
+ 'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`
155
96
  };
156
97
  }
157
98
 
package/test/auth.test.js CHANGED
@@ -173,7 +173,7 @@ describe('Authentication', () => {
173
173
  {
174
174
  '@id': '#owner',
175
175
  '@type': 'acl:Authorization',
176
- 'acl:agent': { '@id': `${baseUrl}/authuser1/profile/card#me` },
176
+ 'acl:agent': { '@id': `${baseUrl}/authuser1/profile/card.jsonld#me` },
177
177
  'acl:accessTo': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
178
178
  'acl:default': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
179
179
  'acl:mode': [
package/test/pod.test.js CHANGED
@@ -36,7 +36,7 @@ describe('Pod Lifecycle', () => {
36
36
 
37
37
  const data = await res.json();
38
38
  assert.strictEqual(data.name, 'alice');
39
- assert.ok(data.webId.endsWith('/alice/profile/card#me'));
39
+ assert.ok(data.webId.endsWith('/alice/profile/card.jsonld#me'));
40
40
  assert.ok(data.podUri.endsWith('/alice/'));
41
41
  });
42
42
 
@@ -103,16 +103,16 @@ describe('Pod Lifecycle', () => {
103
103
  it('should create settings files', async () => {
104
104
  await createTestPod('dan');
105
105
 
106
- // Check Preferences.ttl (needs auth - Settings is private)
107
- const prefs = await request('/dan/settings/Preferences.ttl', { auth: 'dan' });
106
+ // Check prefs.jsonld (needs auth - settings is private)
107
+ const prefs = await request('/dan/settings/prefs.jsonld', { auth: 'dan' });
108
108
  assertStatus(prefs, 200);
109
109
 
110
110
  // Check public type index (needs auth)
111
- const pubIndex = await request('/dan/settings/publicTypeIndex.ttl', { auth: 'dan' });
111
+ const pubIndex = await request('/dan/settings/publicTypeIndex.jsonld', { auth: 'dan' });
112
112
  assertStatus(pubIndex, 200);
113
113
 
114
114
  // Check private type index (needs auth)
115
- const privIndex = await request('/dan/settings/privateTypeIndex.ttl', { auth: 'dan' });
115
+ const privIndex = await request('/dan/settings/privateTypeIndex.jsonld', { auth: 'dan' });
116
116
  assertStatus(privIndex, 200);
117
117
  });
118
118
  });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Unit tests for TOKEN_SECRET resolution (src/auth/token-secret.js).
3
+ *
4
+ * Covers #280: TOKEN_SECRET auto-persists on first run rather than hard-exiting.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import fs from 'fs';
10
+ import os from 'os';
11
+ import path from 'path';
12
+ import {
13
+ readOrWritePersistedSecret,
14
+ resolveTokenSecret,
15
+ DEFAULT_SECRET_PATH,
16
+ } from '../src/auth/token-secret.js';
17
+
18
+ describe('readOrWritePersistedSecret', () => {
19
+ let tmpDir;
20
+ let secretPath;
21
+
22
+ before(() => {
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-token-secret-'));
24
+ secretPath = path.join(tmpDir, '.jss', 'token.secret');
25
+ });
26
+
27
+ after(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true });
29
+ });
30
+
31
+ it('generates + persists a secret when the file is missing', () => {
32
+ const s = readOrWritePersistedSecret(secretPath);
33
+ assert.strictEqual(typeof s, 'string');
34
+ assert.strictEqual(s.length, 64); // 32 bytes, hex-encoded
35
+ assert.strictEqual(fs.readFileSync(secretPath, 'utf8').trim(), s);
36
+ });
37
+
38
+ it('returns the same secret on subsequent calls', () => {
39
+ const first = readOrWritePersistedSecret(secretPath);
40
+ const second = readOrWritePersistedSecret(secretPath);
41
+ assert.strictEqual(first, second);
42
+ });
43
+
44
+ it('enforces tight permissions on POSIX (skipped on Windows)', { skip: process.platform === 'win32' }, () => {
45
+ const stat = fs.statSync(secretPath);
46
+ assert.strictEqual(stat.mode & 0o777, 0o600, 'secret file should be mode 0600');
47
+ const dirStat = fs.statSync(path.dirname(secretPath));
48
+ assert.strictEqual(dirStat.mode & 0o777, 0o700, 'secret dir should be mode 0700');
49
+ });
50
+
51
+ it('propagates errors other than ENOENT', () => {
52
+ // Use a regular file as the would-be parent directory — mkdirSync then
53
+ // fails with ENOTDIR synchronously. Portable across OSes.
54
+ const blockerFile = path.join(tmpDir, 'blocker-file');
55
+ fs.writeFileSync(blockerFile, 'not a dir');
56
+ const unwritable = path.join(blockerFile, '.jss', 'token.secret');
57
+ assert.throws(() => readOrWritePersistedSecret(unwritable));
58
+ });
59
+
60
+ it('recovers when the secret file already exists but is empty', () => {
61
+ // Simulates a concurrent or interrupted persistence case: the file
62
+ // is present (so the fast path falls through the trim-empty check)
63
+ // but carries no usable secret yet. tmp-file + renameSync repairs
64
+ // it by overwriting atomically.
65
+ const p = path.join(tmpDir, 'empty', '.jss', 'token.secret');
66
+ fs.mkdirSync(path.dirname(p), { recursive: true });
67
+ fs.writeFileSync(p, '');
68
+ const s = readOrWritePersistedSecret(p);
69
+ assert.strictEqual(s.length, 64);
70
+ assert.strictEqual(fs.readFileSync(p, 'utf8').trim(), s);
71
+ });
72
+
73
+ it('tightens permissions when the file already exists with loose mode', { skip: process.platform === 'win32' }, () => {
74
+ const p = path.join(tmpDir, 'loose', '.jss', 'token.secret');
75
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o755 });
76
+ fs.writeFileSync(p, 'a'.repeat(64), { mode: 0o644 });
77
+ readOrWritePersistedSecret(p);
78
+ assert.strictEqual(fs.statSync(p).mode & 0o777, 0o600);
79
+ assert.strictEqual(fs.statSync(path.dirname(p)).mode & 0o777, 0o700);
80
+ });
81
+
82
+ it('reads a pre-existing secret even when the parent dir is not writable', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, () => {
83
+ // Simulates a read-only deployment: secret provisioned ahead of time,
84
+ // parent dir not writable for the current user. Must not block startup.
85
+ const p = path.join(tmpDir, 'readonly-parent', '.jss', 'token.secret');
86
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
87
+ const expected = 'b'.repeat(64);
88
+ fs.writeFileSync(p, expected);
89
+ fs.chmodSync(path.dirname(p), 0o500); // r-x, no write
90
+ try {
91
+ const s = readOrWritePersistedSecret(p);
92
+ assert.strictEqual(s, expected);
93
+ } finally {
94
+ fs.chmodSync(path.dirname(p), 0o700); // let after()'s rmSync clean up
95
+ }
96
+ });
97
+ });
98
+
99
+ describe('resolveTokenSecret', () => {
100
+ let tmpDir;
101
+
102
+ before(() => {
103
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-resolve-secret-'));
104
+ });
105
+
106
+ after(() => {
107
+ fs.rmSync(tmpDir, { recursive: true, force: true });
108
+ });
109
+
110
+ const silentLog = { warn: () => {}, error: () => {} };
111
+
112
+ it('prefers TOKEN_SECRET env var', () => {
113
+ const s = resolveTokenSecret({
114
+ env: { TOKEN_SECRET: 'from-env' },
115
+ secretPath: path.join(tmpDir, 'unused', 'token.secret'),
116
+ log: silentLog,
117
+ });
118
+ assert.strictEqual(s, 'from-env');
119
+ });
120
+
121
+ it('persists a generated secret when env is unset', () => {
122
+ const p = path.join(tmpDir, 'persist', 'token.secret');
123
+ const s = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
124
+ assert.strictEqual(s.length, 64);
125
+ assert.strictEqual(fs.readFileSync(p, 'utf8').trim(), s);
126
+ });
127
+
128
+ it('returns the same persisted secret on the next call', () => {
129
+ const p = path.join(tmpDir, 'persist-twice', 'token.secret');
130
+ const first = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
131
+ const second = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
132
+ assert.strictEqual(first, second);
133
+ });
134
+
135
+ // Build an unwritable path by planting a regular file where the helper
136
+ // would try to mkdir a directory. mkdirSync then fails synchronously.
137
+ function buildUnwritable(name) {
138
+ const blocker = path.join(tmpDir, name, 'blocker-file');
139
+ fs.mkdirSync(path.dirname(blocker), { recursive: true });
140
+ fs.writeFileSync(blocker, 'not a dir');
141
+ return path.join(blocker, '.jss', 'token.secret');
142
+ }
143
+
144
+ it('hard-exits in production when persistence fails', () => {
145
+ let exitCode;
146
+ assert.throws(() => {
147
+ resolveTokenSecret({
148
+ env: { NODE_ENV: 'production' },
149
+ secretPath: buildUnwritable('prod'),
150
+ log: silentLog,
151
+ exit: (code) => { exitCode = code; }, // stubbed — doesn't actually terminate
152
+ });
153
+ });
154
+ // exit(1) must still have been invoked even though we throw afterwards,
155
+ // so a non-stubbed production process actually terminates.
156
+ assert.strictEqual(exitCode, 1);
157
+ });
158
+
159
+ it('throws after exit so a stubbed exit() cannot leak undefined downstream', () => {
160
+ // Regression: earlier versions returned undefined "for tests" after
161
+ // calling exit(), which could let callers continue with an invalid
162
+ // secret when exit is stubbed.
163
+ assert.throws(
164
+ () => resolveTokenSecret({
165
+ env: { NODE_ENV: 'production' },
166
+ secretPath: buildUnwritable('no-leak'),
167
+ log: silentLog,
168
+ exit: () => {},
169
+ }),
170
+ /TOKEN_SECRET/
171
+ );
172
+ });
173
+
174
+ it('production error message references the actual secret directory', () => {
175
+ const secretPath = buildUnwritable('custom-path');
176
+ const errors = [];
177
+ assert.throws(() => {
178
+ resolveTokenSecret({
179
+ env: { NODE_ENV: 'production' },
180
+ secretPath,
181
+ log: { warn: () => {}, error: (msg) => errors.push(msg) },
182
+ exit: () => {},
183
+ });
184
+ });
185
+ assert.ok(
186
+ errors.some(m => m.includes(path.dirname(secretPath))),
187
+ `expected an error to mention ${path.dirname(secretPath)}, got: ${errors.join(' | ')}`
188
+ );
189
+ });
190
+
191
+ it('falls back to an ephemeral secret outside production when persistence fails', () => {
192
+ const s = resolveTokenSecret({
193
+ env: {},
194
+ secretPath: buildUnwritable('dev'),
195
+ log: silentLog,
196
+ exit: () => { throw new Error('exit should not be called in dev') },
197
+ });
198
+ assert.strictEqual(typeof s, 'string');
199
+ assert.strictEqual(s.length, 64);
200
+ });
201
+ });
202
+
203
+ describe('DEFAULT_SECRET_PATH', () => {
204
+ it('is absolute and platform-native', () => {
205
+ assert.ok(path.isAbsolute(DEFAULT_SECRET_PATH));
206
+ assert.ok(DEFAULT_SECRET_PATH.includes('.jss'));
207
+ assert.ok(DEFAULT_SECRET_PATH.includes('token.secret'));
208
+ });
209
+ });
@@ -12,7 +12,6 @@ import {
12
12
  assertStatus,
13
13
  assertHeader,
14
14
  assertHeaderContains,
15
- extractJsonLdFromHtml
16
15
  } from './helpers.js';
17
16
 
18
17
  describe('WebID Profile', () => {
@@ -30,87 +29,80 @@ describe('WebID Profile', () => {
30
29
  });
31
30
 
32
31
  describe('Profile Document', () => {
33
- // Profile is at /pod/profile/card following Solid convention
34
- const profilePath = '/webidtest/profile/card';
32
+ // Profile is now a plain JSON-LD doc at /pod/profile/card.jsonld.
33
+ const profilePath = '/webidtest/profile/card.jsonld';
35
34
 
36
- it('should serve profile as HTML', async () => {
35
+ it('should serve profile as JSON-LD', async () => {
37
36
  const res = await request(profilePath);
38
37
 
39
38
  assertStatus(res, 200);
40
- assertHeaderContains(res, 'Content-Type', 'text/html');
39
+ assertHeaderContains(res, 'Content-Type', 'application/ld+json');
41
40
  });
42
41
 
43
- it('should contain JSON-LD structured data', async () => {
42
+ it('should be valid JSON-LD with @context and @id', async () => {
44
43
  const res = await request(profilePath);
45
- const html = await res.text();
44
+ const jsonLd = await res.json();
46
45
 
47
- const jsonLd = extractJsonLdFromHtml(html);
48
46
  assert.ok(jsonLd['@context'], 'Should have @context');
49
- // Profile uses flat structure, not @graph
50
47
  assert.ok(jsonLd['@id'], 'Should have @id');
51
48
  });
52
49
 
53
50
  it('should have correct WebID URI', async () => {
54
51
  const res = await request(profilePath);
55
- const html = await res.text();
56
- const jsonLd = extractJsonLdFromHtml(html);
52
+ const jsonLd = await res.json();
57
53
 
58
- // Profile is a flat structure with the person as the main entity
59
- assert.ok(jsonLd['@id'].endsWith('/webidtest/profile/card#me'), 'WebID should end with /profile/card#me');
54
+ assert.ok(jsonLd['@id'].endsWith('/webidtest/profile/card.jsonld#me'),
55
+ `WebID should end with /profile/card.jsonld#me, got ${jsonLd['@id']}`);
60
56
  });
61
57
 
62
58
  it('should have foaf:name', async () => {
63
59
  const res = await request(profilePath);
64
- const html = await res.text();
65
- const jsonLd = extractJsonLdFromHtml(html);
60
+ const jsonLd = await res.json();
66
61
 
67
62
  assert.strictEqual(jsonLd['foaf:name'], 'webidtest');
68
63
  });
69
64
 
70
65
  it('should have solid:oidcIssuer', async () => {
71
66
  const res = await request(profilePath);
72
- const html = await res.text();
73
- const jsonLd = extractJsonLdFromHtml(html);
67
+ const jsonLd = await res.json();
74
68
 
75
69
  assert.ok(jsonLd['oidcIssuer'], 'Should have oidcIssuer');
76
70
  });
77
71
 
78
72
  it('should have pim:storage pointing to pod', async () => {
79
73
  const res = await request(profilePath);
80
- const html = await res.text();
81
- const jsonLd = extractJsonLdFromHtml(html);
74
+ const jsonLd = await res.json();
82
75
 
83
76
  assert.ok(jsonLd['storage'].endsWith('/webidtest/'), 'Storage should point to pod');
84
77
  });
85
78
 
86
79
  it('should have ldp:inbox', async () => {
87
80
  const res = await request(profilePath);
88
- const html = await res.text();
89
- const jsonLd = extractJsonLdFromHtml(html);
81
+ const jsonLd = await res.json();
90
82
 
91
83
  assert.ok(jsonLd['inbox'].endsWith('/webidtest/inbox/'), 'Should have inbox');
92
84
  });
93
85
 
94
86
  it('should have mainEntityOfPage', async () => {
95
87
  const res = await request(profilePath);
96
- const html = await res.text();
97
- const jsonLd = extractJsonLdFromHtml(html);
88
+ const jsonLd = await res.json();
98
89
 
99
- // Check for mainEntityOfPage which links to the profile document
100
90
  assert.ok(jsonLd['mainEntityOfPage'], 'Should have mainEntityOfPage');
101
91
  });
102
92
  });
103
93
 
104
94
  describe('WebID Resolution', () => {
95
+ const profilePath = '/webidtest/profile/card.jsonld';
96
+
105
97
  it('should return LDP headers', async () => {
106
- const res = await request('/webidtest/profile/card');
98
+ const res = await request(profilePath);
107
99
 
108
100
  assertHeaderContains(res, 'Link', 'ldp#Resource');
109
101
  assertHeader(res, 'WAC-Allow');
110
102
  });
111
103
 
112
104
  it('should return CORS headers', async () => {
113
- const res = await request('/webidtest/profile/card', {
105
+ const res = await request(profilePath, {
114
106
  headers: { 'Origin': 'https://example.com' }
115
107
  });
116
108