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.
- package/.claude/settings.local.json +4 -1
- package/bin/jss.js +43 -0
- package/package.json +1 -1
- package/src/handlers/resource.js +39 -5
- package/src/mashlib/index.js +85 -6
- package/test/cli-flag-like-values.test.js +88 -0
- package/test/data-island.test.js +209 -0
|
@@ -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
package/src/handlers/resource.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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,
|
package/src/mashlib/index.js
CHANGED
|
@@ -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
|
|
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"
|
|
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"
|
|
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
|
|
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"><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
|
+
});
|