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 +1 -1
- package/docs/configuration.md +19 -5
- package/package.json +1 -1
- package/src/config.js +15 -14
- package/src/mashlib/index.js +183 -3
- package/src/server.js +33 -6
- package/test/config.test.js +32 -0
- package/test/idp.test.js +78 -0
- package/test/round-trip-optimization.test.js +638 -0
- package/test/url.test.js +8 -0
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>', '
|
|
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')
|
package/docs/configuration.md
CHANGED
|
@@ -258,19 +258,21 @@ Response:
|
|
|
258
258
|
For personal pod servers where only one user needs access:
|
|
259
259
|
|
|
260
260
|
```bash
|
|
261
|
-
#
|
|
262
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
273
|
-
jss start --single-user --single-user-name
|
|
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
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
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 (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
}
|
package/src/mashlib/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
562
|
-
|
|
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
|
-
|
|
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
|
}
|
package/test/config.test.js
CHANGED
|
@@ -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');
|