javascript-solid-server 0.0.159 → 0.0.161

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.
@@ -358,7 +358,10 @@
358
358
  "Bash(rm LICENSE.full)",
359
359
  "Bash(awk -F: '{print $1,$2}')",
360
360
  "Bash(awk -F: '{print $1}')",
361
- "WebFetch(domain:www.gitfork.app)"
361
+ "WebFetch(domain:www.gitfork.app)",
362
+ "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user --single-user-name alice --idp)",
363
+ "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 2 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user-name alice --idp)",
364
+ "Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)"
362
365
  ]
363
366
  }
364
367
  }
package/bin/jss.js CHANGED
@@ -32,6 +32,49 @@ program
32
32
  .description('JavaScript Solid Server - A minimal, fast, JSON-LD native Solid server')
33
33
  .version(pkg.version);
34
34
 
35
+ /**
36
+ * Convert a camelCase option name back to its kebab-case CLI form for
37
+ * error messages (`singleUserName` → `single-user-name`).
38
+ */
39
+ function camelToKebab (name) {
40
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
41
+ }
42
+
43
+ /**
44
+ * Reject any option value that looks like another flag (#103).
45
+ *
46
+ * Commander happily consumes the next argv as a value, so
47
+ * `jss start --single-user-name --idp`
48
+ * silently sets `singleUserName="--idp"` and the IdP flag is lost.
49
+ * This validator runs as a `preAction` hook for every subcommand, so
50
+ * any option with a missing value gets a clear error instead of a
51
+ * confusing downstream failure ("issuer has no registration endpoint",
52
+ * "Single-user: --idp" in the banner, etc.).
53
+ */
54
+ program.hook('preAction', (_thisCommand, actionCommand) => {
55
+ const opts = actionCommand.opts();
56
+ for (const [key, value] of Object.entries(opts)) {
57
+ const flag = camelToKebab(key);
58
+ if (typeof value === 'string' && value.startsWith('--')) {
59
+ console.error(
60
+ `Error: --${flag} value "${value}" looks like a flag, not a value.\n` +
61
+ `Hint: did you forget to provide a value? e.g. --${flag} someValue`
62
+ );
63
+ process.exit(1);
64
+ }
65
+ // Numeric options (parseInt-coerced like --port) silently produce
66
+ // NaN when given a flag like `--idp`. Catch that too — same root
67
+ // cause, different surface.
68
+ if (typeof value === 'number' && Number.isNaN(value)) {
69
+ console.error(
70
+ `Error: --${flag} got a non-numeric value (parsed as NaN).\n` +
71
+ `Hint: did you forget to provide a number? e.g. --${flag} 8080`
72
+ );
73
+ process.exit(1);
74
+ }
75
+ }
76
+ });
77
+
35
78
  /**
36
79
  * Start command
37
80
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.159",
3
+ "version": "0.0.161",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -14,7 +14,7 @@ import {
14
14
  } from '../rdf/conneg.js';
15
15
  import { emitChange } from '../notifications/events.js';
16
16
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
17
- import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
17
+ import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js';
18
18
 
19
19
  /**
20
20
  * Live reload script - injected into HTML when --live-reload is enabled
@@ -238,9 +238,21 @@ export async function handleGet(request, reply) {
238
238
 
239
239
  // Check if we should serve Mashlib data browser for containers
240
240
  if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
241
+ // Phase 1 of #7: also embed the container's JSON-LD listing as a
242
+ // data island so consumers that look for `<script
243
+ // type="application/ld+json">` (search-engine rich-results,
244
+ // archival crawlers, future mashlib zero-fetch path) get the data
245
+ // without a second request. Use compact (no-whitespace) form for
246
+ // the embed so we don't burn bytes against DATA_ISLAND_MAX_BYTES
247
+ // on indentation that nothing will ever read.
248
+ const embedJsonLd = JSON.stringify(jsonLd);
241
249
  const html = request.mashlibModule
242
- ? generateModuleDatabrowserHtml(request.mashlibModule)
243
- : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
250
+ ? generateModuleDatabrowserHtml(request.mashlibModule, resourceUrl, { embedJsonLd })
251
+ : generateDatabrowserHtml(
252
+ resourceUrl,
253
+ request.mashlibCdn ? request.mashlibVersion : null,
254
+ { embedJsonLd }
255
+ );
244
256
  const headers = getAllHeaders({
245
257
  isContainer: true,
246
258
  etag: stats.etag,
@@ -318,9 +330,31 @@ export async function handleGet(request, reply) {
318
330
  // Check if we should serve Mashlib data browser
319
331
  // Only for RDF resources when Accept: text/html is requested
320
332
  if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
333
+ // Phase 1 of #7: embed the resource's JSON-LD bytes as a data
334
+ // island when it's already JSON-LD (the JSS-native format). Other
335
+ // formats are out of Phase-1 scope; the wrapper still loads
336
+ // correctly and mashlib XHR-fetches as before.
337
+ //
338
+ // Cap-aware short-circuit: skip the read entirely when the file is
339
+ // already over the embed cap. The island would be dropped anyway,
340
+ // and large JSON-LD resources would otherwise load into memory on
341
+ // every HTML navigation.
342
+ let embedJsonLd;
343
+ if (storedContentType === 'application/ld+json' &&
344
+ stats.size <= DATA_ISLAND_MAX_BYTES) {
345
+ // dataIsland() in mashlib/index.js coerces Buffer → string itself,
346
+ // so we hand it the Buffer directly instead of allocating a UTF-8
347
+ // string copy on every navigation.
348
+ const buf = await storage.read(storagePath);
349
+ if (buf) embedJsonLd = buf;
350
+ }
321
351
  const html = request.mashlibModule
322
- ? generateModuleDatabrowserHtml(request.mashlibModule)
323
- : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
352
+ ? generateModuleDatabrowserHtml(request.mashlibModule, resourceUrl, { embedJsonLd })
353
+ : generateDatabrowserHtml(
354
+ resourceUrl,
355
+ request.mashlibCdn ? request.mashlibVersion : null,
356
+ { embedJsonLd }
357
+ );
324
358
  const headers = getAllHeaders({
325
359
  isContainer: false,
326
360
  etag: stats.etag,
@@ -4,23 +4,97 @@
4
4
  * Generates HTML wrapper that loads SolidOS Mashlib from CDN.
5
5
  * When a browser requests an RDF resource with Accept: text/html,
6
6
  * we return this wrapper which then fetches and renders the data.
7
+ *
8
+ * Phase 1 of #7: when the originating resource is reasonably small
9
+ * RDF, the JSON-LD bytes are embedded in the wrapper as a `<script
10
+ * type="application/ld+json" id="dataisland" data-uri="…">` block.
11
+ * Browsers ignore non-JS script bodies, so this is harmless to all
12
+ * existing clients (mashlib still XHR-fetches today). It immediately
13
+ * benefits anything that knows to look for `application/ld+json`
14
+ * islands — search engine rich-results, archival crawlers, scrapers,
15
+ * static-site exporters — and gives Phase 2 a zero-network fast path.
16
+ */
17
+
18
+ /**
19
+ * Cap on how much JSON-LD we'll inline. A 256 KB resource fits any
20
+ * realistic profile, type index, or container listing. Above that we
21
+ * drop the island and let the existing XHR path handle it so we don't
22
+ * make every navigation re-download a multi-megabyte resource.
23
+ */
24
+ export const DATA_ISLAND_MAX_BYTES = 256 * 1024;
25
+
26
+ /**
27
+ * Escape a JSON-LD body for safe inclusion inside `<script
28
+ * type="application/ld+json">…</script>`.
29
+ *
30
+ * Browsers don't execute the script (wrong MIME), but the HTML parser
31
+ * still scans the body for an end-of-script tag. The relevant rule:
32
+ * any `</` followed by `script` (case-insensitive) terminates the
33
+ * element regardless of what follows — `</script>`, `</script >`,
34
+ * `</script\n>`, `</SCRIPT>` and friends all close it. Escaping just
35
+ * the literal `</script>` token is too narrow.
36
+ *
37
+ * The robust fix is to replace every literal `<` byte in the body with
38
+ * the JSON string-escape for U+003C — the six characters
39
+ * backslash-u-0-0-3-c (the same form the implementation emits below).
40
+ * JSON-LD is JSON, and a JSON parser decodes that escape back to a
41
+ * literal `<` natively, so document semantics are preserved. After
42
+ * this transform the body literally cannot contain a `<` byte — so no
43
+ * end-tag (or comment, CDATA, etc.) can possibly start.
44
+ */
45
+ function escapeForScriptBlock(jsonLdString) {
46
+ return String(jsonLdString).replace(/</g, '\\u003c');
47
+ }
48
+
49
+ /**
50
+ * Build the data-island `<script>` block for the given JSON-LD payload.
51
+ * Returns an empty string if the payload is missing or over the size
52
+ * cap so callers can unconditionally interpolate `dataIsland(...)`.
53
+ *
54
+ * The cap applies to the *escaped* body — i.e. the bytes that will
55
+ * actually appear in the HTTP response. `escapeForScriptBlock` can
56
+ * expand input up to 6x (each literal `<` becomes the 6-byte JSON
57
+ * escape sequence backslash-u-0-0-3-c), so checking the raw input
58
+ * size alone could let an HTML response balloon past the cap.
59
+ *
60
+ * Two-stage check:
61
+ * 1. Cheap raw-byte pre-check — escape can only grow the body,
62
+ * so a raw payload already over the cap is guaranteed to be
63
+ * over after escaping; drop without doing the work.
64
+ * 2. Post-escape check — catches the rare case where input was
65
+ * under the cap but expanded above it (`<`-heavy bodies).
7
66
  */
67
+ function dataIsland(resourceUrl, jsonLdString) {
68
+ if (!jsonLdString) return '';
69
+ const raw = String(jsonLdString);
70
+ if (Buffer.byteLength(raw, 'utf8') > DATA_ISLAND_MAX_BYTES) return '';
71
+ const safeBody = escapeForScriptBlock(raw);
72
+ if (Buffer.byteLength(safeBody, 'utf8') > DATA_ISLAND_MAX_BYTES) return '';
73
+ const safeUri = escapeHtml(String(resourceUrl));
74
+ return `<script type="application/ld+json" id="dataisland" data-uri="${safeUri}">${safeBody}</script>`;
75
+ }
8
76
 
9
77
  /**
10
78
  * Generate Mashlib databrowser HTML
11
79
  *
12
- * @param {string} resourceUrl - The URL of the resource being viewed (unused, kept for API compatibility)
80
+ * @param {string} resourceUrl - The URL of the resource being viewed
13
81
  * @param {string} cdnVersion - If provided, load mashlib from unpkg CDN (e.g., "2.0.0")
82
+ * @param {object} [opts]
83
+ * @param {string|Buffer} [opts.embedJsonLd] - JSON-LD body to inline
84
+ * as a `<script type="application/ld+json">` data island. Accepts a
85
+ * UTF-8 string or a Buffer (coerced via `String()`). Honors a 256 KB
86
+ * size cap; oversize payloads are silently dropped. Phase 1 of #7.
14
87
  * @returns {string} HTML content
15
88
  */
16
- export function generateDatabrowserHtml(resourceUrl, cdnVersion = null) {
89
+ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {}) {
90
+ const island = dataIsland(resourceUrl, opts.embedJsonLd);
17
91
  if (cdnVersion) {
18
92
  // CDN mode - use script.onload to ensure mashlib is fully loaded before init
19
93
  // This avoids race conditions with defer + DOMContentLoaded
20
94
  const cdnBase = `https://unpkg.com/mashlib@${cdnVersion}/dist`;
21
95
  return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title>
22
96
  <link href="${cdnBase}/mash.css" rel="stylesheet"></head>
23
- <body id="PageBody"><header id="PageHeader"></header>
97
+ <body id="PageBody">${island}<header id="PageHeader"></header>
24
98
  <div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div>
25
99
  <footer id="PageFooter"></footer>
26
100
  <script>
@@ -37,22 +111,27 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null) {
37
111
  // Local mode - use defer (reliable when served locally)
38
112
  return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title><script>document.addEventListener('DOMContentLoaded', function() {
39
113
  panes.runDataBrowser()
40
- })</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody"><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>`;
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>`;
41
115
  }
42
116
 
43
117
  /**
44
118
  * Generate ES module-based databrowser HTML
45
119
  *
46
120
  * @param {string} moduleUrl - URL to the ES module entry point
121
+ * @param {string} resourceUrl - The URL of the resource being viewed
122
+ * @param {object} [opts]
123
+ * @param {string|Buffer} [opts.embedJsonLd] - JSON-LD body for the
124
+ * data island, same contract as `generateDatabrowserHtml`. Phase 1 of #7.
47
125
  * @returns {string} HTML content
48
126
  */
49
- export function generateModuleDatabrowserHtml(moduleUrl) {
127
+ export function generateModuleDatabrowserHtml(moduleUrl, resourceUrl = '', opts = {}) {
50
128
  const cssUrl = moduleUrl.replace(/\.js$/, '.css');
129
+ const island = dataIsland(resourceUrl, opts.embedJsonLd);
51
130
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"/>
52
131
  <meta name="viewport" content="width=device-width, initial-scale=1">
53
132
  <title>Solid Data Browser</title>
54
133
  <link rel="stylesheet" href="${cssUrl}"></head>
55
- <body><div id="mashlib"></div>
134
+ <body>${island}<div id="mashlib"></div>
56
135
  <script type="module" src="${moduleUrl}"></script>
57
136
  </body></html>`;
58
137
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Regression tests for #103 — `bin/jss.js` must reject option values
3
+ * that look like flags (e.g. `--single-user-name --idp`) instead of
4
+ * silently using `--idp` as the username and breaking IdP setup.
5
+ *
6
+ * These spawn the CLI as a subprocess and assert exit-code + stderr.
7
+ */
8
+
9
+ import { describe, it } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { spawnSync } from 'node:child_process';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const BIN = path.join(__dirname, '..', 'bin', 'jss.js');
18
+
19
+ // Timeout cap so the suite can never hang if the validator regresses
20
+ // and `start` actually tries to bind a port instead of exiting early.
21
+ const RUN_TIMEOUT_MS = 10_000;
22
+
23
+ function runCli(args) {
24
+ const r = spawnSync(process.execPath, [BIN, ...args], {
25
+ encoding: 'utf8',
26
+ timeout: RUN_TIMEOUT_MS,
27
+ killSignal: 'SIGKILL'
28
+ });
29
+ // spawnSync sets `signal` to the kill signal when timeout fires. Treat
30
+ // that as a hard test failure rather than letting downstream
31
+ // assertions on stderr accidentally pass.
32
+ assert.strictEqual(
33
+ r.signal, null,
34
+ `CLI did not exit within ${RUN_TIMEOUT_MS}ms — likely the preAction ` +
35
+ `validator regressed and \`start\` is actually trying to listen. ` +
36
+ `args: ${JSON.stringify(args)}; partial stderr: ${r.stderr}`
37
+ );
38
+ return r;
39
+ }
40
+
41
+ describe('bin/jss.js — flag-like option values (#103)', () => {
42
+ it('rejects `--single-user-name --idp` with a clear error', () => {
43
+ const r = runCli(['start', '--single-user-name', '--idp']);
44
+ assert.notStrictEqual(r.status, 0, 'exit code should be non-zero');
45
+ assert.match(r.stderr, /--single-user-name value "--idp" looks like a flag/);
46
+ assert.match(r.stderr, /Hint: did you forget to provide a value\?/);
47
+ });
48
+
49
+ it('rejects another option swallowing a flag (covers --idp-issuer too)', () => {
50
+ // Commander's behaviour: it greedily consumes the next argv as the
51
+ // value, which is the whole reason the bug exists. We use a flag
52
+ // commander doesn't know about so commander doesn't reroute it
53
+ // through its own argument-count error. The dummy name is
54
+ // collision-proof — if anyone ever adds a real `--bogus-...` flag
55
+ // matching this pattern, the duplication is the bigger problem.
56
+ const FAKE_FLAG = '--__jss103_unlikely_cli_option__';
57
+ const r = runCli(['start', '--idp-issuer', FAKE_FLAG]);
58
+ assert.notStrictEqual(r.status, 0);
59
+ assert.match(
60
+ r.stderr,
61
+ new RegExp(`--idp-issuer value "${FAKE_FLAG}" looks like a flag`)
62
+ );
63
+ });
64
+
65
+ it('rejects `--port --idp` (numeric option → NaN) with helpful error', () => {
66
+ const r = runCli(['start', '--port', '--idp']);
67
+ assert.notStrictEqual(r.status, 0);
68
+ assert.match(r.stderr, /--port got a non-numeric value/);
69
+ assert.match(r.stderr, /Hint: did you forget to provide a number\?/);
70
+ });
71
+
72
+ it('accepts a real value and reaches normal config processing', () => {
73
+ // --print-config exits 0 cleanly after dumping config; this proves
74
+ // the validator doesn't false-positive on legitimate values.
75
+ // Use os.tmpdir() rather than a hard-coded /tmp/... so the test is
76
+ // portable across platforms (and matches the rest of the suite).
77
+ const tmpRoot = path.join(os.tmpdir(), 'jss-103-sanity-doesnotneedtoexist');
78
+ const r = runCli(['start',
79
+ '--port', '4582',
80
+ '--root', tmpRoot,
81
+ '--single-user-name', 'alice',
82
+ '--print-config'
83
+ ]);
84
+ assert.strictEqual(r.status, 0,
85
+ `expected clean exit, got ${r.status}; stderr: ${r.stderr}`);
86
+ assert.match(r.stdout, /Configuration:/);
87
+ });
88
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Phase-1 tests for the JSON-LD data island (#7).
3
+ *
4
+ * The mashlib HTML wrapper now carries the resource's JSON-LD bytes
5
+ * inside a `<script type="application/ld+json" id="dataisland"
6
+ * data-uri="...">` block. Phase 1 doesn't change mashlib's runtime behaviour — the
7
+ * island is purely additive — so these tests pin:
8
+ * - emission shape (script tag, id, MIME, data-uri)
9
+ * - escape: any `</script>` substring inside the body must not
10
+ * prematurely close the script tag
11
+ * - size cap: oversized payloads silently drop the island so we
12
+ * don't make every navigation re-download a multi-megabyte file
13
+ * - presence in the live HTTP response (resource and container)
14
+ * - safe URI attribute against quote / angle-bracket injection
15
+ */
16
+
17
+ import { describe, it, before, after } from 'node:test';
18
+ import assert from 'node:assert';
19
+ import {
20
+ startTestServer,
21
+ stopTestServer,
22
+ request,
23
+ createTestPod,
24
+ assertStatus,
25
+ assertHeaderContains
26
+ } from './helpers.js';
27
+ import {
28
+ generateDatabrowserHtml,
29
+ generateModuleDatabrowserHtml,
30
+ DATA_ISLAND_MAX_BYTES
31
+ } from '../src/mashlib/index.js';
32
+
33
+ describe('mashlib data island — emission (unit, #7)', () => {
34
+ it('emits <script type="application/ld+json" id="dataisland" data-uri="..."> when payload supplied', () => {
35
+ const html = generateDatabrowserHtml(
36
+ 'https://test.solid.social/profile/card.jsonld',
37
+ '2.0.0',
38
+ { embedJsonLd: '{"@id":"#me","foaf:name":"Alice"}' }
39
+ );
40
+ assert.match(html, /<script type="application\/ld\+json" id="dataisland" data-uri="https:\/\/test\.solid\.social\/profile\/card\.jsonld">/);
41
+ assert.match(html, /"@id":"#me"/);
42
+ assert.match(html, /<\/script>/);
43
+ });
44
+
45
+ it('omits the data island when no payload is supplied (back-compat)', () => {
46
+ const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
47
+ assert.doesNotMatch(html, /id="dataisland"/);
48
+ });
49
+
50
+ it('omits the data island for oversized payloads (size cap)', () => {
51
+ // Construct a JSON-LD body just over the cap.
52
+ const filler = 'x'.repeat(DATA_ISLAND_MAX_BYTES + 1024);
53
+ const oversized = `{"content":"${filler}"}`;
54
+ const html = generateDatabrowserHtml(
55
+ 'https://x.test/big',
56
+ '2.0.0',
57
+ { embedJsonLd: oversized }
58
+ );
59
+ assert.doesNotMatch(html, /id="dataisland"/,
60
+ 'island must drop silently above DATA_ISLAND_MAX_BYTES');
61
+ });
62
+
63
+ it('cap applies post-escape (defends against `<`-heavy expansion)', () => {
64
+ // A pathological body that's well under the cap as raw bytes but
65
+ // explodes 6x after escaping — every literal `<` byte becomes the
66
+ // 6-byte JSON escape sequence backslash-u-0-0-3-c. Without the
67
+ // post-escape check, this would emit a multi-megabyte island.
68
+ const halfCap = Math.floor(DATA_ISLAND_MAX_BYTES / 2);
69
+ const payload = '<'.repeat(halfCap); // 128 KB raw, ~768 KB escaped
70
+ const html = generateDatabrowserHtml(
71
+ 'https://x.test/expand',
72
+ '2.0.0',
73
+ { embedJsonLd: payload }
74
+ );
75
+ assert.doesNotMatch(html, /id="dataisland"/,
76
+ 'island must drop when the escaped body exceeds the cap');
77
+ });
78
+
79
+ // The escape strategy is "encode every `<` byte as the six-character
80
+ // JSON escape `\\u003c`". Test the wide variety of strings that an
81
+ // HTML parser would otherwise treat as a closing tag — `</script>`,
82
+ // `</script >`, `</script\n>`, `</SCRIPT>`, `</scRIPT>` — plus
83
+ // `<!--`, all of which require a literal `<` to start the dangerous
84
+ // sequence. After escaping, no literal `<` exists in the body and
85
+ // every transformed location appears as `\\u003c`.
86
+ const escapeTrojans = [
87
+ ['exact </script>', '{"x":"a</script>b"}'],
88
+ ['with space </script >', '{"x":"a</script >b"}'],
89
+ ['with newline </script\\n>', '{"x":"a</script\n>b"}'],
90
+ ['uppercase </SCRIPT>', '{"x":"a</SCRIPT>b"}'],
91
+ ['mixed </ScRiPt>', '{"x":"a</ScRiPt>b"}'],
92
+ ['<!-- comment', '{"x":"<!-- hidden -->"}']
93
+ ];
94
+ for (const [label, body] of escapeTrojans) {
95
+ it(`escape blocks ${label} from prematurely terminating the tag`, () => {
96
+ const html = generateDatabrowserHtml(
97
+ 'https://x.test/r',
98
+ '2.0.0',
99
+ { embedJsonLd: body }
100
+ );
101
+ const start = html.indexOf('id="dataisland"');
102
+ assert.ok(start > 0, 'data island should be present');
103
+ // Slice from the island's `>` (end of opening tag) forward.
104
+ const open = html.indexOf('>', start) + 1;
105
+ const close = html.indexOf('</script>', open);
106
+ const inner = html.slice(open, close);
107
+ // After our escape, the body must contain NO literal `<`.
108
+ assert.doesNotMatch(inner, /</,
109
+ `script body must not contain a literal "<" — got: ${JSON.stringify(inner)}`);
110
+ // The escaped form (the six characters backslash-u-0-0-3-c)
111
+ // should be present.
112
+ assert.match(inner, /\\u003c/,
113
+ 'escaped form `\\u003c` must appear');
114
+ });
115
+ }
116
+
117
+ it('accepts a Buffer payload (handler may pass storage.read() result directly)', () => {
118
+ // The src/handlers/resource.js path used to convert with .toString()
119
+ // before passing in; tightening that contract risks regressions, so
120
+ // the helper accepts a Buffer transparently.
121
+ const buf = Buffer.from('{"@id":"#me","foaf:name":"BufferAlice"}', 'utf8');
122
+ const html = generateDatabrowserHtml(
123
+ 'https://x.test/r',
124
+ '2.0.0',
125
+ { embedJsonLd: buf }
126
+ );
127
+ assert.match(html, /id="dataisland"/);
128
+ assert.match(html, /"foaf:name":"BufferAlice"/);
129
+ });
130
+
131
+ it('the module-mode wrapper also emits the data island', () => {
132
+ const html = generateModuleDatabrowserHtml(
133
+ 'https://example.test/mashlib.js',
134
+ 'https://test.solid.social/profile/card.jsonld',
135
+ { embedJsonLd: '{"@id":"#me"}' }
136
+ );
137
+ assert.match(html, /<script type="application\/ld\+json" id="dataisland" data-uri="https:\/\/test\.solid\.social\/profile\/card\.jsonld">/);
138
+ assert.match(html, /"@id":"#me"/);
139
+ });
140
+
141
+ it('escapes the data-uri attribute against quote / angle-bracket injection', () => {
142
+ const html = generateDatabrowserHtml(
143
+ 'https://x.test/r"><img src=x onerror=alert(1)>',
144
+ '2.0.0',
145
+ { embedJsonLd: '{}' }
146
+ );
147
+ // The dangerous characters must be HTML-entity encoded inside the
148
+ // attribute, so the attribute can't be broken open.
149
+ assert.match(html, /data-uri="https:\/\/x\.test\/r&quot;&gt;&lt;img/);
150
+ assert.doesNotMatch(html, /data-uri="https:\/\/x\.test\/r"><img/);
151
+ });
152
+ });
153
+
154
+ // Integration coverage — the data island must actually appear in the
155
+ // HTTP response when a browser asks for the wrapper.
156
+ describe('mashlib data island — integration (#7)', () => {
157
+ before(async () => {
158
+ await startTestServer({ mashlibCdn: true });
159
+ await createTestPod('islandtest');
160
+ // Put a small JSON-LD resource we can fetch back as HTML.
161
+ await request('/islandtest/public/note.jsonld', {
162
+ method: 'PUT',
163
+ headers: { 'Content-Type': 'application/ld+json' },
164
+ body: JSON.stringify({
165
+ '@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
166
+ '@id': '#note',
167
+ 'foaf:name': 'island test'
168
+ }),
169
+ auth: 'islandtest'
170
+ });
171
+ });
172
+
173
+ after(async () => { await stopTestServer(); });
174
+
175
+ it('a browser GET to a JSON-LD resource carries the data island', async () => {
176
+ const res = await request('/islandtest/public/note.jsonld', {
177
+ headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }
178
+ });
179
+ assertStatus(res, 200);
180
+ assertHeaderContains(res, 'Content-Type', 'text/html');
181
+ const body = await res.text();
182
+ assert.match(body, /id="dataisland"/);
183
+ assert.match(body, /<script type="application\/ld\+json"/);
184
+ assert.match(body, /"foaf:name":"island test"/);
185
+ });
186
+
187
+ it('a browser GET to a container carries the listing as a data island', async () => {
188
+ const res = await request('/islandtest/public/', {
189
+ headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }
190
+ });
191
+ assertStatus(res, 200);
192
+ assertHeaderContains(res, 'Content-Type', 'text/html');
193
+ const body = await res.text();
194
+ assert.match(body, /id="dataisland"/);
195
+ assert.match(body, /<script type="application\/ld\+json"/);
196
+ // Contains an ldp:contains pointing at the resource we just PUT.
197
+ assert.match(body, /note\.jsonld/);
198
+ });
199
+
200
+ it('non-HTML Accept (mashlib XHR) does NOT trigger the wrapper', async () => {
201
+ const res = await request('/islandtest/public/note.jsonld', {
202
+ headers: { Accept: 'application/ld+json' }
203
+ });
204
+ assertHeaderContains(res, 'Content-Type', 'application/ld+json');
205
+ const body = await res.text();
206
+ assert.doesNotMatch(body, /id="dataisland"/);
207
+ assert.doesNotMatch(body, /<!doctype html>/i);
208
+ });
209
+ });