javascript-solid-server 0.0.162 → 0.0.164

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/bin/jss.js CHANGED
@@ -126,7 +126,7 @@ program
126
126
  .option('--invite-only', 'Require invite code for registration')
127
127
  .option('--no-invite-only', 'Allow open registration')
128
128
  .option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
129
- .option('--single-user-name <name>', 'Username for single-user mode (default: me)')
129
+ .option('--single-user-name <name>', 'Mount the pod at /<name>/ instead of at the server root (default: root pod at /)')
130
130
  .option('--single-user-password <pw>', 'Initial IDP password to seed when creating the single-user pod (or set JSS_SINGLE_USER_PASSWORD)')
131
131
  .option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
132
132
  .option('--no-webid-tls', 'Disable WebID-TLS authentication')
@@ -258,19 +258,21 @@ Response:
258
258
  For personal pod servers where only one user needs access:
259
259
 
260
260
  ```bash
261
- # Basic single-user mode (creates pod at /me/)
262
- # On first run JSS will prompt for an initial password (TTY only).
261
+ # Default: pod served at server root (#348). WebID is
262
+ # /profile/card.jsonld#me; the IDP login username is "me". On first
263
+ # run JSS will prompt for an initial password (TTY only).
263
264
  jss start --single-user --idp
264
265
 
265
266
  # Provide the initial IDP password non-interactively (systemd, containers, CI):
266
267
  jss start --single-user --idp --single-user-password 'choose-a-good-one'
267
268
  JSS_SINGLE_USER_PASSWORD='choose-a-good-one' jss start --single-user --idp
268
269
 
269
- # Custom username
270
+ # Mount the pod at a named path instead of the origin. WebID becomes
271
+ # /alice/profile/card.jsonld#me; login as "alice".
270
272
  jss start --single-user --single-user-name alice --idp
271
273
 
272
- # Root-level pod (pod at /, WebID at /profile/card#me)
273
- jss start --single-user --single-user-name '' --idp
274
+ # Legacy /me/ pod same as the old default before #348.
275
+ jss start --single-user --single-user-name me --idp
274
276
 
275
277
  # Via environment
276
278
  JSS_SINGLE_USER=true jss start --idp
@@ -283,6 +285,18 @@ JSS_SINGLE_USER=true jss start --idp
283
285
  - Login works for the single user via password (`POST /idp/credentials`) or any other configured method
284
286
  - Proper ACLs generated automatically
285
287
 
288
+ **Upgrading from a pre-#348 install:** if your existing pod was created with the old default (data lives under `<root>/me/`), JSS no longer auto-detects it — restarting plain `jss start --single-user` will start seeding a fresh empty root pod alongside your legacy `/me/` data, and your existing IDP account will keep authenticating against `/me/`. Pick one path on the next restart:
289
+ - **Keep the legacy layout:** add `--single-user-name me` to your launch command. No data movement needed.
290
+ - **Migrate to root pod:** move the *entire* contents of `<root>/me/` (including dotfiles like `.acl`, `.meta`, `.quota.json` — a plain `mv <root>/me/* <root>/` skips them) to `<root>/`, delete the IDP account for `me` (so the new root pod's `me` account can be seeded), then restart without the name flag. Use one of:
291
+
292
+ ```bash
293
+ # Option A: rsync handles dotfiles correctly with the trailing slash.
294
+ rsync -a <root>/me/ <root>/ && rm -rf <root>/me
295
+
296
+ # Option B: bash with dotglob enabled so * matches dotfiles too.
297
+ shopt -s dotglob && mv <root>/me/* <root>/ && rmdir <root>/me
298
+ ```
299
+
286
300
  **Initial password sources, in priority order:**
287
301
  1. `--single-user-password <pw>` CLI flag
288
302
  2. `JSS_SINGLE_USER_PASSWORD` env var
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.162",
3
+ "version": "0.0.164",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -74,7 +74,11 @@ export const defaults = {
74
74
 
75
75
  // Single-user mode (personal pod server)
76
76
  singleUser: false,
77
- singleUserName: 'me',
77
+ // null = root pod (mounted at server origin, WebID at
78
+ // /profile/card.jsonld#me). A string mounts the pod at /<name>/ —
79
+ // useful when more than one Solid identity coexists on the same
80
+ // origin, or when the operator wants the pre-#348 /me/ shape.
81
+ singleUserName: null,
78
82
  // Initial IDP password seeded on first single-user pod creation. If
79
83
  // unset and --idp is enabled, the server prompts on a TTY or logs a
80
84
  // warning and continues startup on non-TTY (so the pod is created but
@@ -399,20 +403,17 @@ export function printConfig(config) {
399
403
  console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
400
404
  console.log(` Multi-user: ${config.multiuser}`);
401
405
  if (config.singleUser) {
402
- let details = `${config.singleUserName}`;
403
- // Password seeding only runs when --idp is on AND the pod isn't the
404
- // root-level case ('/'). Reflect both gates in the printed line so
405
- // operators don't see a misleading "missing login disabled" when
406
- // login isn't governed by an IDP password at all.
406
+ const isRootPod = config.singleUserName === '/' || !config.singleUserName;
407
+ let details = isRootPod ? '/ (root pod)' : config.singleUserName;
408
+ // The "login as me" hint and password line only make sense when
409
+ // the built-in IdP is on. With --no-idp / external issuer there's
410
+ // no built-in login form, so don't imply one exists.
407
411
  if (config.idp) {
408
- if (config.singleUserName === '/' || !config.singleUserName) {
409
- details += ' (root pod; password not seeded)';
410
- } else {
411
- const pwSource = config.singleUserPassword
412
- ? 'provided'
413
- : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
414
- details += ` (password: ${pwSource})`;
415
- }
412
+ if (isRootPod) details += ', login as "me"';
413
+ const pwSource = config.singleUserPassword
414
+ ? 'provided'
415
+ : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
416
+ details += ` (password: ${pwSource})`;
416
417
  }
417
418
  console.log(` Single-user: ${details}`);
418
419
  }
@@ -74,6 +74,177 @@ function dataIsland(resourceUrl, jsonLdString) {
74
74
  return `<script type="application/ld+json" id="dataisland" data-uri="${safeUri}">${safeBody}</script>`;
75
75
  }
76
76
 
77
+ /**
78
+ * Inline round-trip optimization reader (#346).
79
+ *
80
+ * Exposes a generic `window.__dataIsland.get(uri)` accessor any client
81
+ * can use, plus a compatibility patch for rdflib-based clients
82
+ * (mashlib and friends) that intercepts `fetcher.load(uri)` and
83
+ * resolves from the inline JSON-LD data island instead of issuing a
84
+ * second HTTP request. Falls through cleanly to the original network
85
+ * fetch on any miss, parse error, or absent rdflib.
86
+ *
87
+ * Three detection paths to cover the timing space:
88
+ * 1. Synchronous check — if `$rdf` is already on the page when this
89
+ * script runs (e.g. reader injected after mashlib in some custom
90
+ * flow), patch immediately.
91
+ * 2. Setter on `window.$rdf` — catches the assignment as soon as
92
+ * mashlib publishes its rdflib instance. Closes the race where
93
+ * mashlib's bundle initializes and calls `panes.runDataBrowser()`
94
+ * synchronously inside an `onload` handler before any setTimeout
95
+ * poll could fire.
96
+ * 3. Polling fallback — bounded retry for environments where the
97
+ * property setter is rejected (e.g. `$rdf` already defined as a
98
+ * non-configurable own property).
99
+ *
100
+ * Net effect: when JSS serves an HTML wrapper with an embedded data
101
+ * island, the page renders with one HTTP round-trip instead of two.
102
+ */
103
+ export function roundTripOptimizationScript() {
104
+ return `<script>
105
+ (function () {
106
+ if (typeof window === 'undefined') return;
107
+
108
+ // Initialize defensively: another script may have set a truthy
109
+ // window.__dataIsland that lacks a .get function — or, worse,
110
+ // assigned a primitive (string, number, etc.) where attaching
111
+ // .get would silently fail in non-strict mode and throw in strict
112
+ // mode. Normalize to a plain object first if the existing value
113
+ // is not an object or function (this also handles a null value,
114
+ // since typeof null === 'object'). Preserve well-formed existing
115
+ // implementations so consumers can register custom .get hooks.
116
+ var di = window.__dataIsland;
117
+ if (di === null || di === undefined
118
+ || (typeof di !== 'object' && typeof di !== 'function')) {
119
+ di = window.__dataIsland = {};
120
+ }
121
+ if (typeof di.get !== 'function') {
122
+ di.get = function (uri) {
123
+ if (!uri) return null;
124
+ try {
125
+ // Fetch by id and compare data-uri as a string. Avoids
126
+ // selector construction entirely so there is no CSS.escape
127
+ // pitfall, no attribute-string-context injection surface,
128
+ // and no false misses on URIs containing characters older
129
+ // browsers' selector parsers handle inconsistently.
130
+ var el = document.getElementById('dataisland');
131
+ if (el && el.type === 'application/ld+json'
132
+ && el.getAttribute('data-uri') === String(uri)) {
133
+ return {
134
+ contentType: 'application/ld+json',
135
+ content: el.textContent
136
+ };
137
+ }
138
+ } catch (e) { /* fall through to null */ }
139
+ return null;
140
+ };
141
+ }
142
+
143
+ function applyPatch(rdf) {
144
+ if (!rdf || !rdf.fetcher || !rdf.fetcher.load) return;
145
+ if (rdf.fetcher.__dataIslandPatched) return;
146
+ rdf.fetcher.__dataIslandPatched = true;
147
+ var f = rdf.fetcher;
148
+ var orig = f.load.bind(f);
149
+ f.load = function (uri, options) {
150
+ var s = (uri && uri.uri) || (uri && uri.value) || String(uri);
151
+ var d = window.__dataIsland.get(s);
152
+ if (d) {
153
+ return new Promise(function (resolve, reject) {
154
+ rdf.parse(d.content, f.store, s, d.contentType, function (err) {
155
+ if (err) {
156
+ reject(err);
157
+ return;
158
+ }
159
+ // Wrap the success path so unexpected throws (e.g.
160
+ // f.requested missing/non-writable on some rdflib builds)
161
+ // surface as Promise rejections rather than hanging the
162
+ // resolution.
163
+ try {
164
+ if (f.requested && typeof f.requested === 'object') {
165
+ f.requested[s] = 'done';
166
+ }
167
+ // Return a real Response when available so consumers
168
+ // using "instanceof Response", ".text()", ".json()",
169
+ // etc. work the same as on the network path. Fall
170
+ // back to a Response-shaped plain object in environments
171
+ // where the Response constructor isn't available.
172
+ var resp;
173
+ if (typeof Response === 'function') {
174
+ resp = new Response(d.content, {
175
+ status: 200,
176
+ statusText: 'OK',
177
+ headers: { 'content-type': d.contentType }
178
+ });
179
+ // Response.url is read-only and empty when
180
+ // constructed; consumers reading it expect the
181
+ // resource URL. defineProperty is supported on
182
+ // Response in all browsers we target.
183
+ try {
184
+ Object.defineProperty(resp, 'url',
185
+ { value: s, configurable: true });
186
+ } catch (urlErr) { /* leave url empty */ }
187
+ } else {
188
+ resp = {
189
+ ok: true,
190
+ status: 200,
191
+ statusText: 'OK',
192
+ url: s,
193
+ headers: {
194
+ // Match real Response.headers.get() behavior on
195
+ // the inline-data path: case-insensitive lookup,
196
+ // returns the data island's content-type for
197
+ // 'content-type', null for unknown headers.
198
+ get: function (name) {
199
+ if (typeof name !== 'string') return null;
200
+ if (name.toLowerCase() === 'content-type') {
201
+ return d.contentType;
202
+ }
203
+ return null;
204
+ }
205
+ }
206
+ };
207
+ }
208
+ resolve(resp);
209
+ } catch (callbackErr) {
210
+ reject(callbackErr);
211
+ }
212
+ });
213
+ }).catch(function () { return orig(uri, options); });
214
+ }
215
+ return orig(uri, options);
216
+ };
217
+ }
218
+
219
+ // Path 1: synchronous check
220
+ if (typeof $rdf !== 'undefined') applyPatch($rdf);
221
+
222
+ // Path 2: setter for $rdf — catches synchronous mashlib initialization
223
+ try {
224
+ var captured = (typeof $rdf !== 'undefined') ? $rdf : undefined;
225
+ Object.defineProperty(window, '$rdf', {
226
+ configurable: true,
227
+ get: function () { return captured; },
228
+ set: function (v) { captured = v; applyPatch(v); }
229
+ });
230
+ } catch (e) {
231
+ /* property non-configurable or otherwise un-redefinable;
232
+ the polling fallback below covers this case */
233
+ }
234
+
235
+ // Path 3: polling fallback
236
+ var n = 0;
237
+ (function poll() {
238
+ if (++n > 100) return;
239
+ if (typeof $rdf !== 'undefined' && $rdf && $rdf.fetcher
240
+ && $rdf.fetcher.__dataIslandPatched) return;
241
+ if (typeof $rdf !== 'undefined') applyPatch($rdf);
242
+ setTimeout(poll, 100);
243
+ })();
244
+ })();
245
+ </script>`;
246
+ }
247
+
77
248
  /**
78
249
  * Generate Mashlib databrowser HTML
79
250
  *
@@ -84,17 +255,23 @@ function dataIsland(resourceUrl, jsonLdString) {
84
255
  * as a `<script type="application/ld+json">` data island. Accepts a
85
256
  * UTF-8 string or a Buffer (coerced via `String()`). Honors a 256 KB
86
257
  * size cap; oversize payloads are silently dropped. Phase 1 of #7.
258
+ * @param {boolean} [opts.roundTripOptimization=true] - Inline a small
259
+ * reader script that lets rdflib-based clients (mashlib and friends)
260
+ * resolve `fetcher.load(uri)` from the data island instead of issuing
261
+ * a second HTTP request. Falls through to network fetch on any miss
262
+ * or parse error. #346.
87
263
  * @returns {string} HTML content
88
264
  */
89
265
  export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {}) {
90
266
  const island = dataIsland(resourceUrl, opts.embedJsonLd);
267
+ const reader = opts.roundTripOptimization === false ? '' : roundTripOptimizationScript();
91
268
  if (cdnVersion) {
92
269
  // CDN mode - use script.onload to ensure mashlib is fully loaded before init
93
270
  // This avoids race conditions with defer + DOMContentLoaded
94
271
  const cdnBase = `https://unpkg.com/mashlib@${cdnVersion}/dist`;
95
272
  return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title>
96
273
  <link href="${cdnBase}/mash.css" rel="stylesheet"></head>
97
- <body id="PageBody">${island}<header id="PageHeader"></header>
274
+ <body id="PageBody">${island}${reader}<header id="PageHeader"></header>
98
275
  <div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div>
99
276
  <footer id="PageFooter"></footer>
100
277
  <script>
@@ -111,7 +288,7 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {
111
288
  // Local mode - use defer (reliable when served locally)
112
289
  return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title><script>document.addEventListener('DOMContentLoaded', function() {
113
290
  panes.runDataBrowser()
114
- })</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody">${island}<header id="PageHeader"></header><div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div><footer id="PageFooter"></footer></body></html>`;
291
+ })</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody">${island}${reader}<header id="PageHeader"></header><div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div><footer id="PageFooter"></footer></body></html>`;
115
292
  }
116
293
 
117
294
  /**
@@ -122,16 +299,19 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {
122
299
  * @param {object} [opts]
123
300
  * @param {string|Buffer} [opts.embedJsonLd] - JSON-LD body for the
124
301
  * data island, same contract as `generateDatabrowserHtml`. Phase 1 of #7.
302
+ * @param {boolean} [opts.roundTripOptimization=true] - Inline reader
303
+ * script (#346); see `generateDatabrowserHtml` for details.
125
304
  * @returns {string} HTML content
126
305
  */
127
306
  export function generateModuleDatabrowserHtml(moduleUrl, resourceUrl = '', opts = {}) {
128
307
  const cssUrl = moduleUrl.replace(/\.js$/, '.css');
129
308
  const island = dataIsland(resourceUrl, opts.embedJsonLd);
309
+ const reader = opts.roundTripOptimization === false ? '' : roundTripOptimizationScript();
130
310
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"/>
131
311
  <meta name="viewport" content="width=device-width, initial-scale=1">
132
312
  <title>Solid Data Browser</title>
133
313
  <link rel="stylesheet" href="${cssUrl}"></head>
134
- <body>${island}<div id="mashlib"></div>
314
+ <body>${island}${reader}<div id="mashlib"></div>
135
315
  <script type="module" src="${moduleUrl}"></script>
136
316
  </body></html>`;
137
317
  }
package/src/server.js CHANGED
@@ -93,7 +93,22 @@ export function createServer(options = {}) {
93
93
  const inviteOnly = options.inviteOnly ?? false;
94
94
  // Single-user mode - creates pod on startup, disables registration
95
95
  const singleUser = options.singleUser ?? false;
96
- const singleUserName = options.singleUserName ?? 'me';
96
+ // Default null = root pod (#348). Pass an explicit singleUserName
97
+ // to mount the pod at /<name>/ instead. Normalize the
98
+ // historical `'/'` / `''` forms to null up front so downstream
99
+ // code (remoteStoragePlugin, decorators, etc.) doesn't have to
100
+ // re-check for the same three shapes.
101
+ //
102
+ // Pre-#348 installs (default 'me') that upgrade in place will see
103
+ // a fresh empty root pod alongside their /me/ data. The fix is to
104
+ // pass `--single-user-name me` on restart (or move data/me/* out
105
+ // to the data root). At v0.0.x we accept that one-time
106
+ // intervention rather than carrying detection magic in the code.
107
+ const rawSingleUserName = options.singleUserName ?? null;
108
+ const singleUserName =
109
+ (rawSingleUserName === '/' || rawSingleUserName === '')
110
+ ? null
111
+ : rawSingleUserName;
97
112
  const singleUserPassword = options.singleUserPassword ?? null;
98
113
  // Default storage quota per pod (50MB default, 0 = unlimited)
99
114
  const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
@@ -558,8 +573,10 @@ export function createServer(options = {}) {
558
573
  const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`;
559
574
  const issuer = idpIssuer || `${baseUrl}/`;
560
575
 
561
- // Root-level pod (empty or '/' name) vs named pod
562
- const isRootPod = !singleUserName || singleUserName === '/';
576
+ // Root pod (no name) vs named pod. After the singleUserName
577
+ // normalization at the top of createServer(), null is the only
578
+ // root-pod shape we need to recognize here.
579
+ const isRootPod = !singleUserName;
563
580
  const podPath = isRootPod ? '/' : `/${singleUserName}/`;
564
581
  const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
565
582
  const displayName = isRootPod ? 'me' : singleUserName;
@@ -595,12 +612,22 @@ export function createServer(options = {}) {
595
612
  // this, single-user + --idp produces a pod but no credential, and
596
613
  // registration is intentionally disabled in single-user mode — so
597
614
  // the pod is unloggable until a password is set externally (#323).
598
- if (idpEnabled && !isRootPod) {
615
+ //
616
+ // Root pods (#348) need this too: the pod has no name, but the IDP
617
+ // still needs *some* username for the login form. Default to 'me'
618
+ // — matches the WebID fragment, fits the historical convention.
619
+ if (idpEnabled) {
620
+ // The IDP also persists `podName` and surfaces it as the
621
+ // `name` claim under the OIDC `profile` scope (see
622
+ // src/idp/accounts.js). For root pods we use 'me' here too —
623
+ // a null podName would leak through as a null/missing
624
+ // profile.name on every login, which OIDC clients expect to
625
+ // be a non-empty human-readable string.
599
626
  await seedSingleUserIdpAccount({
600
627
  fastify,
601
- username: singleUserName,
628
+ username: isRootPod ? 'me' : singleUserName,
602
629
  webId,
603
- podName: singleUserName,
630
+ podName: isRootPod ? 'me' : singleUserName,
604
631
  providedPassword: singleUserPassword
605
632
  });
606
633
  }
@@ -158,3 +158,35 @@ describe('config — --single-user implies --idp (#331)', () => {
158
158
  '--no-idp without --single-user should not trigger the #331 warning');
159
159
  });
160
160
  });
161
+
162
+ // #348: the user-visible default change — `jss start --single-user`
163
+ // (no name flag) must produce a config where singleUserName is null,
164
+ // so createServer() takes the root-pod path. createServer() has its
165
+ // own tests but a future refactor of loadConfig() could silently
166
+ // restore the old `'me'` default and only the server-level tests
167
+ // would catch it via behaviour, not the config layer directly.
168
+ describe('config — singleUserName default (#348)', () => {
169
+ it('loadConfig() returns singleUserName=null when no flag/env is set', async () => {
170
+ delete process.env.JSS_SINGLE_USER_NAME;
171
+ const cfg = await loadConfig({}, null);
172
+ assert.strictEqual(cfg.singleUserName, null,
173
+ 'default must be null (= root pod), not the legacy "me"');
174
+ });
175
+
176
+ it('loadConfig() preserves an explicit singleUserName CLI arg', async () => {
177
+ delete process.env.JSS_SINGLE_USER_NAME;
178
+ const cfg = await loadConfig({ singleUserName: 'alice' }, null);
179
+ assert.strictEqual(cfg.singleUserName, 'alice');
180
+ });
181
+
182
+ it('loadConfig() respects JSS_SINGLE_USER_NAME from env', async () => {
183
+ process.env.JSS_SINGLE_USER_NAME = 'me';
184
+ try {
185
+ const cfg = await loadConfig({}, null);
186
+ assert.strictEqual(cfg.singleUserName, 'me',
187
+ 'env var should restore the legacy "me" pod path on demand');
188
+ } finally {
189
+ delete process.env.JSS_SINGLE_USER_NAME;
190
+ }
191
+ });
192
+ });
package/test/idp.test.js CHANGED
@@ -458,6 +458,84 @@ describe('Identity Provider - Root pod type index ACLs', () => {
458
458
  });
459
459
  });
460
460
 
461
+ // #348: --single-user with no name flag now defaults to a root pod
462
+ // (was '/me/' historically). The server-side seed must land the
463
+ // profile at /profile/card.jsonld, not /me/profile/card.jsonld.
464
+ describe('Single-user default — root pod (#348)', () => {
465
+ let server;
466
+ let baseUrl;
467
+ const DEFAULT_DATA_DIR = './test-data-348-default-root';
468
+ const ROOT_POD_PASSWORD = 'root-pod-test-pw';
469
+
470
+ before(async () => {
471
+ await fs.remove(DEFAULT_DATA_DIR);
472
+ await fs.ensureDir(DEFAULT_DATA_DIR);
473
+
474
+ const port = await getAvailablePort();
475
+ baseUrl = `http://${TEST_HOST}:${port}`;
476
+
477
+ server = createServer({
478
+ logger: false,
479
+ root: DEFAULT_DATA_DIR,
480
+ idp: true,
481
+ idpIssuer: baseUrl,
482
+ singleUser: true,
483
+ // singleUserName intentionally omitted — exercises the new default.
484
+ // Provide a password so the seeding path runs non-interactively.
485
+ singleUserPassword: ROOT_POD_PASSWORD,
486
+ forceCloseConnections: true,
487
+ });
488
+
489
+ await server.listen({ port, host: TEST_HOST });
490
+ });
491
+
492
+ after(async () => {
493
+ await server.close();
494
+ await fs.remove(DEFAULT_DATA_DIR);
495
+ });
496
+
497
+ it('seeds the profile at /profile/card.jsonld (not /me/profile/...)', async () => {
498
+ const root = await fetch(`${baseUrl}/profile/card.jsonld`);
499
+ assert.strictEqual(root.status, 200,
500
+ '--single-user with no name should default to a root pod');
501
+ // Check the filesystem directly — an HTTP-only check could pass
502
+ // on a 401 even if /me/ data was somehow seeded, which would
503
+ // hide the regression we care about (root vs /me/ pod).
504
+ assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card.jsonld')), false,
505
+ 'no /me/ pod files should be created when singleUserName is unset');
506
+ assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card')), false,
507
+ 'no legacy /me/ pod files should be created either');
508
+ });
509
+
510
+ it('WebID resolves at the server origin', async () => {
511
+ const res = await fetch(`${baseUrl}/profile/card.jsonld`);
512
+ const body = await res.json();
513
+ const webId = `${baseUrl}/profile/card.jsonld#me`;
514
+ const matches = Array.isArray(body)
515
+ ? body.some(n => n['@id'] === webId)
516
+ : body['@id'] === webId || (body['@graph'] || []).some(n => n['@id'] === webId);
517
+ assert.ok(matches, `profile should declare WebID ${webId}, got: ${JSON.stringify(body).slice(0, 200)}`);
518
+ });
519
+
520
+ it('seeds an IDP account for "me" so the root pod is loggable', async () => {
521
+ // Round-2 review of #348: a regression here would mean a fresh
522
+ // `jss start --single-user --idp` produces a pod nobody can log
523
+ // in to (registration is disabled in single-user mode, so there
524
+ // would be no recovery path other than out-of-band account
525
+ // creation). Use the credentials endpoint as a black-box login
526
+ // probe — if it issues a token, the seed worked.
527
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
528
+ method: 'POST',
529
+ headers: { 'Content-Type': 'application/json' },
530
+ body: JSON.stringify({ username: 'me', password: ROOT_POD_PASSWORD }),
531
+ });
532
+ assert.strictEqual(res.status, 200,
533
+ `login as "me" should succeed for the default root pod (got ${res.status})`);
534
+ const body = await res.json();
535
+ assert.ok(body.access_token, 'response should carry an access token');
536
+ });
537
+ });
538
+
461
539
  describe('Identity Provider - Accounts', () => {
462
540
  let server;
463
541
  let accountsUrl;
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Tests for the round-trip optimization reader (#346).
3
+ *
4
+ * The reader is a small inline `<script>` that exposes
5
+ * `window.__dataIsland.get(uri)` and (when rdflib loads) patches
6
+ * `$rdf.fetcher.load()` to resolve from the inline JSON-LD data island
7
+ * instead of issuing a second HTTP request. These tests pin:
8
+ * - presence in CDN, local, and module HTML wrappers when enabled by default
9
+ * - opt-out via `roundTripOptimization: false`
10
+ * - reader exposes the documented accessor
11
+ * - reader contains the bounded-retry guard (no infinite polling)
12
+ * - reader body is well-formed JS (no premature `</script>` close)
13
+ * - runtime behavior of the accessor and the rdflib patch (via Node `vm`)
14
+ */
15
+
16
+ import { describe, it } from 'node:test';
17
+ import assert from 'node:assert';
18
+ import vm from 'node:vm';
19
+ import {
20
+ generateDatabrowserHtml,
21
+ generateModuleDatabrowserHtml,
22
+ roundTripOptimizationScript
23
+ } from '../src/mashlib/index.js';
24
+
25
+ describe('round-trip optimization reader — emission (#346)', () => {
26
+ it('emits the reader script in CDN mode by default', () => {
27
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
28
+ assert.match(html, /window\.__dataIsland/);
29
+ assert.match(html, /\$rdf\.fetcher/);
30
+ });
31
+
32
+ it('emits the reader script in local mode by default', () => {
33
+ const html = generateDatabrowserHtml('https://x.test/foo');
34
+ assert.match(html, /window\.__dataIsland/);
35
+ assert.match(html, /\$rdf\.fetcher/);
36
+ });
37
+
38
+ it('emits the reader script in module mode by default', () => {
39
+ const html = generateModuleDatabrowserHtml(
40
+ '/dist/databrowser.js',
41
+ 'https://x.test/foo'
42
+ );
43
+ assert.match(html, /window\.__dataIsland/);
44
+ assert.match(html, /\$rdf\.fetcher/);
45
+ });
46
+
47
+ it('omits the reader when roundTripOptimization is explicitly false (CDN)', () => {
48
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0', {
49
+ roundTripOptimization: false
50
+ });
51
+ assert.doesNotMatch(html, /window\.__dataIsland/);
52
+ });
53
+
54
+ it('omits the reader when roundTripOptimization is explicitly false (local)', () => {
55
+ const html = generateDatabrowserHtml('https://x.test/foo', null, {
56
+ roundTripOptimization: false
57
+ });
58
+ assert.doesNotMatch(html, /window\.__dataIsland/);
59
+ });
60
+
61
+ it('omits the reader when roundTripOptimization is explicitly false (module)', () => {
62
+ const html = generateModuleDatabrowserHtml(
63
+ '/dist/databrowser.js',
64
+ 'https://x.test/foo',
65
+ { roundTripOptimization: false }
66
+ );
67
+ assert.doesNotMatch(html, /window\.__dataIsland/);
68
+ });
69
+
70
+ it('exposes the documented public surface (window.__dataIsland with .get)', () => {
71
+ // Public-surface assertion only — minified formatting is not pinned.
72
+ // Runtime behavior is exercised in the vm-based suite below.
73
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
74
+ assert.match(html, /window\.__dataIsland/);
75
+ assert.match(html, /\.get\s*[:=(]/);
76
+ });
77
+
78
+ it('looks up the data island by id and compares data-uri', () => {
79
+ // Avoiding selector construction sidesteps CSS.escape pitfalls and
80
+ // selector-injection surface in older browsers.
81
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
82
+ assert.match(html, /document\.getElementById\(\s*['"]dataisland['"]\s*\)/);
83
+ assert.match(html, /getAttribute\(\s*['"]data-uri['"]\s*\)/);
84
+ });
85
+
86
+ it('marks the fetcher as patched to prevent double-patching', () => {
87
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
88
+ assert.match(html, /__dataIslandPatched/);
89
+ });
90
+
91
+ it('bounds the polling retry to prevent infinite loop on non-rdflib clients', () => {
92
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
93
+ // Polling guard caps total retries (whitespace-tolerant).
94
+ // Actual runtime behavior is exercised in the vm-based suite below.
95
+ assert.match(html, /\+\+\s*n\s*>\s*\d+/);
96
+ });
97
+
98
+ it('captures original fetcher.load before patching', () => {
99
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
100
+ // Public-surface assertion (whitespace-tolerant).
101
+ // Runtime fall-through is exercised in the vm-based suite below.
102
+ assert.match(html, /orig\s*=\s*f\.load\.bind\(f\)/);
103
+ });
104
+
105
+ it('reader contains exactly one </script> close tag (no premature close)', () => {
106
+ // Test the source string directly (not a slice of the emitted HTML)
107
+ // so a premature `</script>` cannot be silently treated as the
108
+ // terminator. A correct reader has exactly one `</script>`: its own
109
+ // closing tag.
110
+ const wrapped = roundTripOptimizationScript();
111
+ const closes = (wrapped.match(/<\/script\s*>/gi) || []).length;
112
+ assert.strictEqual(closes, 1,
113
+ 'reader must have exactly one </script> close tag, got ' + closes);
114
+ // Sanity: the close is at the very end (modulo trailing whitespace).
115
+ assert.match(wrapped, /<\/script>\s*$/);
116
+ });
117
+ });
118
+
119
+ describe('round-trip optimization reader — interaction with data island (#346)', () => {
120
+ it('reader and data island both present when JSON-LD payload supplied', () => {
121
+ const html = generateDatabrowserHtml(
122
+ 'https://test.solid.social/profile/card.jsonld',
123
+ '2.0.0',
124
+ { embedJsonLd: '{"@id":"#me","foaf:name":"Alice"}' }
125
+ );
126
+ assert.match(html, /id="dataisland"/);
127
+ assert.match(html, /window\.__dataIsland/);
128
+ });
129
+
130
+ it('reader still present when data island is absent (no payload)', () => {
131
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
132
+ assert.doesNotMatch(html, /id="dataisland"/);
133
+ // Reader is still emitted; it just no-ops on missing islands
134
+ assert.match(html, /window\.__dataIsland/);
135
+ });
136
+
137
+ it('data island appears before reader script in document order', () => {
138
+ const html = generateDatabrowserHtml(
139
+ 'https://x.test/foo',
140
+ '2.0.0',
141
+ { embedJsonLd: '{"@id":"#me"}' }
142
+ );
143
+ const islandPos = html.indexOf('id="dataisland"');
144
+ const readerPos = html.indexOf('window.__dataIsland');
145
+ assert.ok(islandPos > 0, 'data island missing');
146
+ assert.ok(readerPos > 0, 'reader missing');
147
+ assert.ok(islandPos < readerPos,
148
+ 'data island must appear before reader so the DOM element exists when reader queries it');
149
+ });
150
+ });
151
+
152
+ /**
153
+ * Helpers for the runtime suite: extract the reader IIFE from generated
154
+ * HTML and evaluate it in a Node `vm` context with stubbed window /
155
+ * document / $rdf so we can pin actual behavior, not just emitted
156
+ * tokens.
157
+ */
158
+ function extractReaderSource(html) {
159
+ // The reader IIFE begins with `(function () {` followed by the
160
+ // `if (typeof window` guard. Locate that signature, then walk back
161
+ // to the enclosing <script> open and forward to the closing </script>.
162
+ const sigMatch = html.match(/\(function \(\)\s*\{\s*if \(typeof window/);
163
+ if (!sigMatch) throw new Error('reader IIFE not found in HTML');
164
+ const start = sigMatch.index;
165
+ const scriptOpen = html.lastIndexOf('<script>', start);
166
+ const scriptClose = html.indexOf('</script>', start);
167
+ return html.slice(scriptOpen + '<script>'.length, scriptClose);
168
+ }
169
+
170
+ function makeContext({ islands = {}, $rdf = undefined } = {}) {
171
+ // Single data island per page (matches the real DOM contract). The
172
+ // accessor uses getElementById('dataisland'), so we expose at most
173
+ // one element regardless of how many entries the test passes in.
174
+ const islandEntries = Object.entries(islands);
175
+ const islandEl = islandEntries.length > 0
176
+ ? {
177
+ type: 'application/ld+json',
178
+ textContent: islandEntries[0][1],
179
+ getAttribute(name) {
180
+ return name === 'data-uri' ? islandEntries[0][0] : null;
181
+ }
182
+ }
183
+ : null;
184
+ const document = {
185
+ getElementById(id) {
186
+ return id === 'dataisland' ? islandEl : null;
187
+ }
188
+ };
189
+ const window = {};
190
+ // Stub setTimeout so the reader's polling fallback (up to ~10s of
191
+ // 100ms ticks when $rdf is absent) does not register real Node
192
+ // timers that keep the test process alive past the assertions.
193
+ // Tests that need to exercise polling can build a custom context.
194
+ return vm.createContext({
195
+ window, document, $rdf,
196
+ setTimeout: () => 0,
197
+ clearTimeout: () => {},
198
+ Promise, String, console, Response,
199
+ Object: globalThis.Object
200
+ });
201
+ }
202
+
203
+ describe('round-trip optimization reader — runtime behavior (#346)', () => {
204
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
205
+
206
+ it('window.__dataIsland.get returns {contentType, content} for matching island', () => {
207
+ const ctx = makeContext({
208
+ islands: { 'https://x.test/foo': '{"@id":"#me"}' }
209
+ });
210
+ vm.runInContext(extractReaderSource(html), ctx);
211
+ const result = ctx.window.__dataIsland.get('https://x.test/foo');
212
+ // Compare by value: result is constructed in the vm realm so its
213
+ // prototype is not reference-equal to the host realm's Object.
214
+ assert.strictEqual(result.contentType, 'application/ld+json');
215
+ assert.strictEqual(result.content, '{"@id":"#me"}');
216
+ });
217
+
218
+ it('window.__dataIsland.get returns null when no matching island exists', () => {
219
+ const ctx = makeContext({ islands: {} });
220
+ vm.runInContext(extractReaderSource(html), ctx);
221
+ assert.strictEqual(
222
+ ctx.window.__dataIsland.get('https://x.test/missing'),
223
+ null
224
+ );
225
+ });
226
+
227
+ it('window.__dataIsland.get returns null for falsy uri input', () => {
228
+ const ctx = makeContext({ islands: {} });
229
+ vm.runInContext(extractReaderSource(html), ctx);
230
+ assert.strictEqual(ctx.window.__dataIsland.get(null), null);
231
+ assert.strictEqual(ctx.window.__dataIsland.get(undefined), null);
232
+ assert.strictEqual(ctx.window.__dataIsland.get(''), null);
233
+ });
234
+
235
+ it('patches $rdf.fetcher.load synchronously when rdflib is already present', () => {
236
+ const fakeFetcher = {
237
+ requested: {},
238
+ store: {},
239
+ load: async () => 'original'
240
+ };
241
+ const $rdf = { fetcher: fakeFetcher, parse: () => {}, sym: (u) => u };
242
+ const ctx = makeContext({ islands: {}, $rdf });
243
+ vm.runInContext(extractReaderSource(html), ctx);
244
+ assert.strictEqual(fakeFetcher.__dataIslandPatched, true,
245
+ 'fetcher should be marked patched');
246
+ });
247
+
248
+ it('patched fetcher.load resolves from data island instead of network', async () => {
249
+ let networkCalls = 0;
250
+ const parseCalls = [];
251
+ const fakeFetcher = {
252
+ requested: {},
253
+ store: { kb: 'fake' },
254
+ load: async () => { networkCalls++; return 'network'; }
255
+ };
256
+ const $rdf = {
257
+ fetcher: fakeFetcher,
258
+ parse(content, store, uri, contentType, callback) {
259
+ parseCalls.push({ content, uri, contentType });
260
+ callback(null);
261
+ },
262
+ sym: (u) => ({ uri: u })
263
+ };
264
+ const ctx = makeContext({
265
+ islands: { 'https://x.test/foo': '{"@id":"#me"}' },
266
+ $rdf
267
+ });
268
+ vm.runInContext(extractReaderSource(html), ctx);
269
+
270
+ const result = await fakeFetcher.load('https://x.test/foo', {});
271
+
272
+ assert.strictEqual(networkCalls, 0, 'should not have hit network');
273
+ assert.strictEqual(parseCalls.length, 1, '$rdf.parse called once');
274
+ assert.strictEqual(parseCalls[0].content, '{"@id":"#me"}');
275
+ assert.strictEqual(parseCalls[0].uri, 'https://x.test/foo');
276
+ assert.strictEqual(parseCalls[0].contentType, 'application/ld+json');
277
+ assert.strictEqual(fakeFetcher.requested['https://x.test/foo'], 'done');
278
+
279
+ // Return value is a real Response (or Response-shaped fallback) so
280
+ // consumers using `instanceof Response` or `.text()` / `.json()`
281
+ // see the same kind of object as on the network path.
282
+ assert.strictEqual(result.ok, true);
283
+ assert.strictEqual(result.status, 200);
284
+ assert.strictEqual(result.url, 'https://x.test/foo');
285
+ assert.strictEqual(typeof result.headers.get, 'function');
286
+ // Content-type header round-trips through the Response init.
287
+ assert.strictEqual(result.headers.get('content-type'), 'application/ld+json');
288
+ });
289
+
290
+ it('patched fetcher.load falls through to original on data island miss', async () => {
291
+ let networkCalls = [];
292
+ const fakeFetcher = {
293
+ requested: {},
294
+ store: {},
295
+ load: async function (uri, options) {
296
+ networkCalls.push({ uri, options });
297
+ return { source: 'network', uri };
298
+ }
299
+ };
300
+ const $rdf = {
301
+ fetcher: fakeFetcher,
302
+ parse: () => {},
303
+ sym: (u) => u
304
+ };
305
+ const ctx = makeContext({ islands: {}, $rdf });
306
+ vm.runInContext(extractReaderSource(html), ctx);
307
+
308
+ const result = await fakeFetcher.load('https://x.test/foo', { force: true });
309
+
310
+ assert.strictEqual(networkCalls.length, 1);
311
+ assert.strictEqual(networkCalls[0].uri, 'https://x.test/foo');
312
+ assert.deepStrictEqual(networkCalls[0].options, { force: true });
313
+ assert.deepStrictEqual(result, {
314
+ source: 'network',
315
+ uri: 'https://x.test/foo'
316
+ });
317
+ });
318
+
319
+ it('patched fetcher.load falls through to original on parse error', async () => {
320
+ let networkCalls = 0;
321
+ const fakeFetcher = {
322
+ requested: {},
323
+ store: {},
324
+ load: async () => { networkCalls++; return 'network'; }
325
+ };
326
+ const $rdf = {
327
+ fetcher: fakeFetcher,
328
+ parse(content, store, uri, contentType, callback) {
329
+ callback(new Error('parse failed'));
330
+ },
331
+ sym: (u) => u
332
+ };
333
+ const ctx = makeContext({
334
+ islands: { 'https://x.test/foo': 'not valid json-ld' },
335
+ $rdf
336
+ });
337
+ vm.runInContext(extractReaderSource(html), ctx);
338
+
339
+ const result = await fakeFetcher.load('https://x.test/foo', {});
340
+
341
+ assert.strictEqual(networkCalls, 1, 'should have fallen through to network');
342
+ assert.strictEqual(result, 'network');
343
+ });
344
+
345
+ it('rdflib patch is idempotent (running reader twice does not double-wrap)', () => {
346
+ const fakeFetcher = {
347
+ requested: {},
348
+ store: {},
349
+ load: async () => 'original'
350
+ };
351
+ const $rdf = { fetcher: fakeFetcher, parse: () => {}, sym: (u) => u };
352
+ const ctx = makeContext({ islands: {}, $rdf });
353
+
354
+ vm.runInContext(extractReaderSource(html), ctx);
355
+ const firstPatchedLoad = fakeFetcher.load;
356
+
357
+ // Second run — must detect __dataIslandPatched and skip
358
+ vm.runInContext(extractReaderSource(html), ctx);
359
+
360
+ assert.strictEqual(fakeFetcher.load, firstPatchedLoad,
361
+ 'second run should not re-wrap an already-patched fetcher');
362
+ });
363
+
364
+ it('does nothing when rdflib never loads (bounded retry exits silently)', () => {
365
+ const ctx = makeContext({ islands: {} /* no $rdf */ });
366
+ // Should not throw despite $rdf being undefined.
367
+ assert.doesNotThrow(() => {
368
+ vm.runInContext(extractReaderSource(html), ctx);
369
+ });
370
+ // Generic accessor still set up.
371
+ assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function');
372
+ });
373
+
374
+ it('installs a setter on window.$rdf to catch synchronous mashlib init', () => {
375
+ // The setter closes the race where mashlib's bundle initializes and
376
+ // calls panes.runDataBrowser() (and hence fetcher.load) synchronously
377
+ // inside an onload handler — faster than any setTimeout poll could fire.
378
+ const ctx = makeContext({ islands: {} /* no $rdf yet */ });
379
+ vm.runInContext(extractReaderSource(html), ctx);
380
+ const desc = Object.getOwnPropertyDescriptor(ctx.window, '$rdf');
381
+ assert.ok(desc, 'setter descriptor missing on window.$rdf');
382
+ assert.strictEqual(typeof desc.get, 'function', 'getter should be installed');
383
+ assert.strictEqual(typeof desc.set, 'function', 'setter should be installed');
384
+ });
385
+
386
+ it('setter on window.$rdf patches fetcher immediately on assignment', () => {
387
+ const fakeFetcher = {
388
+ requested: {},
389
+ store: {},
390
+ load: async () => 'original'
391
+ };
392
+ const fakeRdf = { fetcher: fakeFetcher, parse: () => {}, sym: (u) => u };
393
+ const ctx = makeContext({ islands: {} /* $rdf not yet set */ });
394
+ vm.runInContext(extractReaderSource(html), ctx);
395
+ // Simulate mashlib publishing its rdflib instance.
396
+ ctx.window.$rdf = fakeRdf;
397
+ assert.strictEqual(fakeFetcher.__dataIslandPatched, true,
398
+ 'fetcher should be patched the moment $rdf is assigned');
399
+ });
400
+
401
+ it('returns a real Response when the constructor is available', async () => {
402
+ const fakeFetcher = {
403
+ requested: {},
404
+ store: {},
405
+ load: async () => 'orig'
406
+ };
407
+ const $rdf = {
408
+ fetcher: fakeFetcher,
409
+ parse: (content, store, uri, ct, cb) => cb(null),
410
+ sym: (u) => u
411
+ };
412
+ const ctx = makeContext({
413
+ islands: { 'https://x.test/foo': '{"@id":"#me"}' },
414
+ $rdf
415
+ });
416
+ vm.runInContext(extractReaderSource(html), ctx);
417
+ const result = await fakeFetcher.load('https://x.test/foo', {});
418
+ assert.ok(result instanceof Response,
419
+ 'should resolve to a real Response when constructor is available');
420
+ assert.strictEqual(await result.text(), '{"@id":"#me"}');
421
+ assert.strictEqual(result.url, 'https://x.test/foo');
422
+ });
423
+
424
+ it('falls back to Response-shaped object when Response is unavailable', async () => {
425
+ // Build a context without Response so the fallback path runs.
426
+ const islandEl = {
427
+ type: 'application/ld+json',
428
+ textContent: '{"@id":"#x"}',
429
+ getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
430
+ };
431
+ const document = {
432
+ getElementById: (id) => id === 'dataisland' ? islandEl : null
433
+ };
434
+ const window = {};
435
+ const fakeFetcher = {
436
+ requested: {},
437
+ store: {},
438
+ load: async () => 'orig'
439
+ };
440
+ const $rdf = {
441
+ fetcher: fakeFetcher,
442
+ parse: (c, s, u, ct, cb) => cb(null),
443
+ sym: (u) => u
444
+ };
445
+ const ctx = vm.createContext({
446
+ window, document, $rdf,
447
+ setTimeout: () => 0, clearTimeout: () => {},
448
+ Promise, String, console,
449
+ Object: globalThis.Object
450
+ // Note: no Response in this context.
451
+ });
452
+ vm.runInContext(extractReaderSource(html), ctx);
453
+ const result = await fakeFetcher.load('https://x.test/foo', {});
454
+ assert.strictEqual(result.ok, true);
455
+ assert.strictEqual(result.status, 200);
456
+ assert.strictEqual(result.url, 'https://x.test/foo');
457
+ assert.strictEqual(typeof result.headers.get, 'function');
458
+ });
459
+
460
+ it('fallback headers.get returns the data island content-type (case-insensitive)', async () => {
461
+ // Build a context without Response so the fallback headers path runs.
462
+ const islandEl = {
463
+ type: 'application/ld+json',
464
+ textContent: '{"@id":"#x"}',
465
+ getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
466
+ };
467
+ const document = {
468
+ getElementById: (id) => id === 'dataisland' ? islandEl : null
469
+ };
470
+ const window = {};
471
+ const fakeFetcher = {
472
+ requested: {},
473
+ store: {},
474
+ load: async () => 'orig'
475
+ };
476
+ const $rdf = {
477
+ fetcher: fakeFetcher,
478
+ parse: (c, s, u, ct, cb) => cb(null),
479
+ sym: (u) => u
480
+ };
481
+ const ctx = vm.createContext({
482
+ window, document, $rdf,
483
+ setTimeout: () => 0, clearTimeout: () => {},
484
+ Promise, String, console,
485
+ Object: globalThis.Object
486
+ // No Response — forces fallback path.
487
+ });
488
+ vm.runInContext(extractReaderSource(html), ctx);
489
+ const result = await fakeFetcher.load('https://x.test/foo', {});
490
+ assert.strictEqual(result.headers.get('content-type'), 'application/ld+json');
491
+ assert.strictEqual(result.headers.get('Content-Type'), 'application/ld+json');
492
+ assert.strictEqual(result.headers.get('CONTENT-TYPE'), 'application/ld+json');
493
+ assert.strictEqual(result.headers.get('etag'), null);
494
+ assert.strictEqual(result.headers.get(123), null,
495
+ 'non-string name should return null without throwing');
496
+ });
497
+
498
+ it('preserves existing window.__dataIsland but ensures .get is callable', () => {
499
+ // Simulate a prior script setting a truthy __dataIsland without .get.
500
+ const islandEl = {
501
+ type: 'application/ld+json',
502
+ textContent: '{"@id":"#me"}',
503
+ getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
504
+ };
505
+ const document = {
506
+ getElementById: (id) => id === 'dataisland' ? islandEl : null
507
+ };
508
+ const preExisting = { someOtherProperty: 'original-value' };
509
+ const window = { __dataIsland: preExisting };
510
+ const ctx = vm.createContext({
511
+ window, document,
512
+ setTimeout: () => 0, clearTimeout: () => {},
513
+ Promise, String, console, Response,
514
+ Object: globalThis.Object
515
+ });
516
+ vm.runInContext(extractReaderSource(html), ctx);
517
+
518
+ // Existing object preserved (not overwritten):
519
+ assert.strictEqual(ctx.window.__dataIsland, preExisting);
520
+ assert.strictEqual(ctx.window.__dataIsland.someOtherProperty, 'original-value');
521
+ // .get added since it was missing:
522
+ assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function');
523
+ // And it works:
524
+ const result = ctx.window.__dataIsland.get('https://x.test/foo');
525
+ assert.strictEqual(result.contentType, 'application/ld+json');
526
+ assert.strictEqual(result.content, '{"@id":"#me"}');
527
+ });
528
+
529
+ it('normalizes primitive window.__dataIsland to an object before attaching .get', () => {
530
+ // If a script accidentally assigned a primitive (string/number/etc),
531
+ // `|| {}` would preserve it and attaching .get would silently fail.
532
+ // The reader must reset to a plain object before adding .get.
533
+ const islandEl = {
534
+ type: 'application/ld+json',
535
+ textContent: '{"@id":"#me"}',
536
+ getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
537
+ };
538
+ const document = {
539
+ getElementById: (id) => id === 'dataisland' ? islandEl : null
540
+ };
541
+
542
+ for (const primitive of ['stringy', 42, true, Symbol('x')]) {
543
+ const window = { __dataIsland: primitive };
544
+ const ctx = vm.createContext({
545
+ window, document,
546
+ setTimeout: () => 0, clearTimeout: () => {},
547
+ Promise, String, console, Response,
548
+ Object: globalThis.Object
549
+ });
550
+ vm.runInContext(extractReaderSource(html), ctx);
551
+ assert.strictEqual(typeof ctx.window.__dataIsland, 'object',
552
+ 'primitive should be normalized to object');
553
+ assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function',
554
+ '.get should be installed after normalization');
555
+ // And it works:
556
+ const result = ctx.window.__dataIsland.get('https://x.test/foo');
557
+ assert.strictEqual(result.contentType, 'application/ld+json');
558
+ }
559
+ });
560
+
561
+ it('normalizes a null window.__dataIsland to an object before attaching .get', () => {
562
+ // typeof null === 'object', so a naive object-check would let null
563
+ // through. Explicit null check is required.
564
+ const islandEl = {
565
+ type: 'application/ld+json',
566
+ textContent: '{"@id":"#me"}',
567
+ getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
568
+ };
569
+ const document = {
570
+ getElementById: (id) => id === 'dataisland' ? islandEl : null
571
+ };
572
+ const window = { __dataIsland: null };
573
+ const ctx = vm.createContext({
574
+ window, document,
575
+ setTimeout: () => 0, clearTimeout: () => {},
576
+ Promise, String, console, Response,
577
+ Object: globalThis.Object
578
+ });
579
+ vm.runInContext(extractReaderSource(html), ctx);
580
+ assert.notStrictEqual(ctx.window.__dataIsland, null);
581
+ assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function');
582
+ });
583
+
584
+ it('does not overwrite a pre-existing window.__dataIsland.get', () => {
585
+ const customGet = function () {
586
+ return { contentType: 'custom', content: 'from-custom-get' };
587
+ };
588
+ const islandEl = {
589
+ type: 'application/ld+json',
590
+ textContent: '{"@id":"#me"}',
591
+ getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
592
+ };
593
+ const document = {
594
+ getElementById: (id) => id === 'dataisland' ? islandEl : null
595
+ };
596
+ const window = { __dataIsland: { get: customGet } };
597
+ const ctx = vm.createContext({
598
+ window, document,
599
+ setTimeout: () => 0, clearTimeout: () => {},
600
+ Promise, String, console, Response,
601
+ Object: globalThis.Object
602
+ });
603
+ vm.runInContext(extractReaderSource(html), ctx);
604
+
605
+ assert.strictEqual(ctx.window.__dataIsland.get, customGet,
606
+ 'pre-existing custom .get must not be replaced');
607
+ });
608
+
609
+ it('tolerates missing fetcher.requested without hanging the Promise', async () => {
610
+ // Some rdflib/mashlib builds may not initialize `requested`.
611
+ // The patched load must still resolve cleanly.
612
+ const fakeFetcher = {
613
+ // No `requested` property.
614
+ store: {},
615
+ load: async () => 'orig'
616
+ };
617
+ const $rdf = {
618
+ fetcher: fakeFetcher,
619
+ parse: (c, s, u, ct, cb) => cb(null),
620
+ sym: (u) => u
621
+ };
622
+ const ctx = makeContext({
623
+ islands: { 'https://x.test/foo': '{"@id":"#z"}' },
624
+ $rdf
625
+ });
626
+ vm.runInContext(extractReaderSource(html), ctx);
627
+
628
+ // Bound the wait so a hanging Promise fails the test rather than
629
+ // hanging the whole suite.
630
+ const result = await Promise.race([
631
+ fakeFetcher.load('https://x.test/foo', {}),
632
+ new Promise((_, reject) =>
633
+ setTimeout(() => reject(new Error('load() hung')), 500))
634
+ ]);
635
+ assert.strictEqual(result.ok, true);
636
+ assert.strictEqual(result.url, 'https://x.test/foo');
637
+ });
638
+ });
package/test/url.test.js CHANGED
@@ -33,6 +33,14 @@ describe('getPodName', () => {
33
33
  assert.strictEqual(getPodName(req), '.');
34
34
  });
35
35
 
36
+ it("returns '.' for a root pod (singleUserName null — #348 default)", () => {
37
+ // server.js normalizes '/' and '' to null at the top of
38
+ // createServer, so most root-pod requests now reach getPodName
39
+ // with singleUserName === null. Pin that path explicitly.
40
+ const req = { singleUser: true, singleUserName: null, url: '/index.html' };
41
+ assert.strictEqual(getPodName(req), '.');
42
+ });
43
+
36
44
  it('returns singleUserName for a named pod, regardless of URL', () => {
37
45
  const req = { singleUser: true, singleUserName: 'me', url: '/index.html' };
38
46
  assert.strictEqual(getPodName(req), 'me');