javascript-solid-server 0.0.151 → 0.0.152
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 +3 -1
- package/package.json +1 -1
- package/src/handlers/container.js +3 -3
- package/src/handlers/resource.js +30 -16
- package/src/ldp/headers.js +7 -7
- package/src/rdf/conneg.js +11 -2
- package/test/vary-cache-headers.test.js +114 -0
|
@@ -355,7 +355,9 @@
|
|
|
355
355
|
"Read(//home/melvin/remote/github.com/solid-helper/core/**)",
|
|
356
356
|
"Bash(curl -s https://www.gnu.org/licenses/agpl-3.0.txt)",
|
|
357
357
|
"Bash(cat LICENSE.full)",
|
|
358
|
-
"Bash(rm LICENSE.full)"
|
|
358
|
+
"Bash(rm LICENSE.full)",
|
|
359
|
+
"Bash(awk -F: '{print $1,$2}')",
|
|
360
|
+
"Bash(awk -F: '{print $1}')"
|
|
359
361
|
]
|
|
360
362
|
}
|
|
361
363
|
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
|
|
|
5
5
|
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
|
|
6
6
|
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
|
|
7
7
|
import { createToken } from '../auth/token.js';
|
|
8
|
-
import { canAcceptInput, toJsonLd,
|
|
8
|
+
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
|
|
9
9
|
import { emitChange } from '../notifications/events.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -138,10 +138,10 @@ export async function handlePost(request, reply) {
|
|
|
138
138
|
const headers = getAllHeaders({
|
|
139
139
|
isContainer: isCreatingContainer,
|
|
140
140
|
origin,
|
|
141
|
-
connegEnabled
|
|
141
|
+
connegEnabled,
|
|
142
|
+
mashlibEnabled: request.mashlibEnabled
|
|
142
143
|
});
|
|
143
144
|
headers['Location'] = resourceUrl;
|
|
144
|
-
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
145
145
|
|
|
146
146
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
147
147
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
canAcceptInput,
|
|
11
11
|
toJsonLd,
|
|
12
12
|
fromJsonLd,
|
|
13
|
-
getVaryHeader,
|
|
14
13
|
RDF_TYPES
|
|
15
14
|
} from '../rdf/conneg.js';
|
|
16
15
|
import { emitChange } from '../notifications/events.js';
|
|
@@ -22,6 +21,12 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMash
|
|
|
22
21
|
*/
|
|
23
22
|
const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//' +location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>`;
|
|
24
23
|
|
|
24
|
+
// Cache-Control for RDF data responses: let clients keep the body but force
|
|
25
|
+
// revalidation via ETag on every use. This prevents stale bodies from leaking
|
|
26
|
+
// across auth-state changes (WAC) and closes the mashlib render-race window
|
|
27
|
+
// where a cached data variant was served on top-level navigation (#315).
|
|
28
|
+
const RDF_CACHE_CONTROL = 'private, no-cache, must-revalidate';
|
|
29
|
+
|
|
25
30
|
/**
|
|
26
31
|
* Inject live reload script into HTML content
|
|
27
32
|
*/
|
|
@@ -181,6 +186,7 @@ export async function handleGet(request, reply) {
|
|
|
181
186
|
resourceUrl,
|
|
182
187
|
connegEnabled
|
|
183
188
|
});
|
|
189
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
184
190
|
|
|
185
191
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
186
192
|
return reply.send(turtleContent);
|
|
@@ -194,6 +200,7 @@ export async function handleGet(request, reply) {
|
|
|
194
200
|
resourceUrl,
|
|
195
201
|
connegEnabled
|
|
196
202
|
});
|
|
203
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
197
204
|
|
|
198
205
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
199
206
|
return reply.send(JSON.stringify(jsonLd, null, 2));
|
|
@@ -239,9 +246,9 @@ export async function handleGet(request, reply) {
|
|
|
239
246
|
contentType: 'text/html',
|
|
240
247
|
origin,
|
|
241
248
|
resourceUrl,
|
|
242
|
-
connegEnabled
|
|
249
|
+
connegEnabled,
|
|
250
|
+
mashlibEnabled: request.mashlibEnabled
|
|
243
251
|
});
|
|
244
|
-
headers['Vary'] = 'Accept';
|
|
245
252
|
headers['X-Frame-Options'] = 'DENY';
|
|
246
253
|
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
|
|
247
254
|
headers['Cache-Control'] = 'no-store';
|
|
@@ -274,9 +281,10 @@ export async function handleGet(request, reply) {
|
|
|
274
281
|
contentType: 'text/turtle',
|
|
275
282
|
origin,
|
|
276
283
|
resourceUrl,
|
|
277
|
-
connegEnabled
|
|
284
|
+
connegEnabled,
|
|
285
|
+
mashlibEnabled: request.mashlibEnabled
|
|
278
286
|
});
|
|
279
|
-
headers['
|
|
287
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
280
288
|
|
|
281
289
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
282
290
|
return reply.send(turtleContent);
|
|
@@ -292,8 +300,10 @@ export async function handleGet(request, reply) {
|
|
|
292
300
|
contentType: 'application/ld+json',
|
|
293
301
|
origin,
|
|
294
302
|
resourceUrl,
|
|
295
|
-
connegEnabled
|
|
303
|
+
connegEnabled,
|
|
304
|
+
mashlibEnabled: request.mashlibEnabled
|
|
296
305
|
});
|
|
306
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
297
307
|
|
|
298
308
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
299
309
|
return reply.send(serializeJsonLd(jsonLd));
|
|
@@ -315,9 +325,9 @@ export async function handleGet(request, reply) {
|
|
|
315
325
|
contentType: 'text/html',
|
|
316
326
|
origin,
|
|
317
327
|
resourceUrl,
|
|
318
|
-
connegEnabled
|
|
328
|
+
connegEnabled,
|
|
329
|
+
mashlibEnabled: request.mashlibEnabled
|
|
319
330
|
});
|
|
320
|
-
headers['Vary'] = 'Accept';
|
|
321
331
|
headers['X-Frame-Options'] = 'DENY';
|
|
322
332
|
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
|
|
323
333
|
// Don't cache the HTML wrapper - always negotiate fresh
|
|
@@ -397,9 +407,10 @@ export async function handleGet(request, reply) {
|
|
|
397
407
|
contentType: 'text/turtle',
|
|
398
408
|
origin,
|
|
399
409
|
resourceUrl,
|
|
400
|
-
connegEnabled
|
|
410
|
+
connegEnabled,
|
|
411
|
+
mashlibEnabled: request.mashlibEnabled
|
|
401
412
|
});
|
|
402
|
-
headers['
|
|
413
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
403
414
|
|
|
404
415
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
405
416
|
return reply.send(turtleContent);
|
|
@@ -427,9 +438,10 @@ export async function handleGet(request, reply) {
|
|
|
427
438
|
contentType: outputType,
|
|
428
439
|
origin,
|
|
429
440
|
resourceUrl,
|
|
430
|
-
connegEnabled
|
|
441
|
+
connegEnabled,
|
|
442
|
+
mashlibEnabled: request.mashlibEnabled
|
|
431
443
|
});
|
|
432
|
-
headers['
|
|
444
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
433
445
|
|
|
434
446
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
435
447
|
return reply.send(outputContent);
|
|
@@ -455,9 +467,12 @@ export async function handleGet(request, reply) {
|
|
|
455
467
|
contentType: actualContentType,
|
|
456
468
|
origin,
|
|
457
469
|
resourceUrl,
|
|
458
|
-
connegEnabled
|
|
470
|
+
connegEnabled,
|
|
471
|
+
mashlibEnabled: request.mashlibEnabled
|
|
459
472
|
});
|
|
460
|
-
|
|
473
|
+
if (isRdfContentType(actualContentType)) {
|
|
474
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
475
|
+
}
|
|
461
476
|
|
|
462
477
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
463
478
|
|
|
@@ -671,9 +686,8 @@ export async function handlePut(request, reply) {
|
|
|
671
686
|
}
|
|
672
687
|
|
|
673
688
|
const origin = request.headers.origin;
|
|
674
|
-
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
|
|
689
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled, mashlibEnabled: request.mashlibEnabled });
|
|
675
690
|
headers['Location'] = resourceUrl;
|
|
676
|
-
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
677
691
|
|
|
678
692
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
679
693
|
|
package/src/ldp/headers.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* LDP (Linked Data Platform) header utilities
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { getAcceptHeaders } from '../rdf/conneg.js';
|
|
5
|
+
import { getAcceptHeaders, getVaryHeader } from '../rdf/conneg.js';
|
|
6
6
|
|
|
7
7
|
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
8
8
|
|
|
@@ -49,7 +49,7 @@ export function getAclUrl(resourceUrl, isContainer) {
|
|
|
49
49
|
* @param {object} options
|
|
50
50
|
* @returns {object}
|
|
51
51
|
*/
|
|
52
|
-
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
|
|
52
|
+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
|
|
53
53
|
// Calculate ACL URL if resource URL provided
|
|
54
54
|
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
55
55
|
|
|
@@ -58,7 +58,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
|
|
|
58
58
|
'Accept-Patch': 'text/n3, application/sparql-update',
|
|
59
59
|
'Accept-Ranges': isContainer ? 'none' : 'bytes',
|
|
60
60
|
'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
61
|
-
'Vary': connegEnabled
|
|
61
|
+
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
// Only set WAC-Allow if explicitly provided (otherwise the auth hook sets it)
|
|
@@ -107,9 +107,9 @@ export function getCorsHeaders(origin) {
|
|
|
107
107
|
* @param {object} options
|
|
108
108
|
* @returns {object}
|
|
109
109
|
*/
|
|
110
|
-
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
|
|
110
|
+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
|
|
111
111
|
return {
|
|
112
|
-
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, updatesVia }),
|
|
112
|
+
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, updatesVia }),
|
|
113
113
|
...getCorsHeaders(origin)
|
|
114
114
|
};
|
|
115
115
|
}
|
|
@@ -120,7 +120,7 @@ export function getAllHeaders({ isContainer = false, etag = null, contentType =
|
|
|
120
120
|
* @param {object} options
|
|
121
121
|
* @returns {object}
|
|
122
122
|
*/
|
|
123
|
-
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false }) {
|
|
123
|
+
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false, mashlibEnabled = false }) {
|
|
124
124
|
// Determine if this would be a container based on URL ending with /
|
|
125
125
|
const isContainer = resourceUrl?.endsWith('/') || false;
|
|
126
126
|
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
@@ -134,7 +134,7 @@ export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEn
|
|
|
134
134
|
'Accept-Patch': 'text/n3, application/sparql-update',
|
|
135
135
|
'Accept-Put': acceptHeaders['Accept-Put'] || 'application/ld+json, */*',
|
|
136
136
|
'Allow': 'GET, HEAD, PUT, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
137
|
-
'Vary': connegEnabled
|
|
137
|
+
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
if (isContainer && acceptHeaders['Accept-Post']) {
|
package/src/rdf/conneg.js
CHANGED
|
@@ -189,10 +189,19 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa
|
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
191
|
* Get Vary header value for content negotiation
|
|
192
|
-
*
|
|
192
|
+
*
|
|
193
|
+
* Must be identical across all variants of a given URL — inconsistent Vary
|
|
194
|
+
* across variants confuses browser caches and can cause the wrong variant
|
|
195
|
+
* to be served on reload (see #315).
|
|
196
|
+
*
|
|
197
|
+
* - `Accept` — response body depends on Accept (conneg or mashlib HTML shell)
|
|
198
|
+
* - `Authorization` — response body depends on the authenticated user (WAC)
|
|
199
|
+
* - `Origin` — CORS headers echo the request's Origin
|
|
193
200
|
*/
|
|
194
201
|
export function getVaryHeader(connegEnabled, mashlibEnabled = false) {
|
|
195
|
-
return (connegEnabled || mashlibEnabled)
|
|
202
|
+
return (connegEnabled || mashlibEnabled)
|
|
203
|
+
? 'Accept, Authorization, Origin'
|
|
204
|
+
: 'Authorization, Origin';
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
/**
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #315 — inconsistent Vary / Cache-Control across
|
|
3
|
+
* conneg variants caused stale-render races on browser reload.
|
|
4
|
+
*
|
|
5
|
+
* What we guarantee now:
|
|
6
|
+
* - Every variant of the same URL returns an *identical* Vary header.
|
|
7
|
+
* - RDF data variants carry Cache-Control that forces revalidation via
|
|
8
|
+
* ETag, so a cached body cannot silently serve across auth changes or
|
|
9
|
+
* be picked up on a top-level navigation by mistake.
|
|
10
|
+
* - The mashlib HTML wrapper keeps `no-store` (it's a bootstrap template).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, before, after } from 'node:test';
|
|
14
|
+
import assert from 'node:assert';
|
|
15
|
+
import {
|
|
16
|
+
startTestServer,
|
|
17
|
+
stopTestServer,
|
|
18
|
+
request,
|
|
19
|
+
createTestPod
|
|
20
|
+
} from './helpers.js';
|
|
21
|
+
|
|
22
|
+
describe('Vary / Cache-Control consistency (#315)', () => {
|
|
23
|
+
before(async () => {
|
|
24
|
+
await startTestServer({ conneg: true, mashlibCdn: true });
|
|
25
|
+
await createTestPod('varytest');
|
|
26
|
+
// Create a JSON-LD resource to exercise all variants.
|
|
27
|
+
await request('/varytest/public/card.jsonld', {
|
|
28
|
+
method: 'PUT',
|
|
29
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
|
|
32
|
+
'@id': '#me',
|
|
33
|
+
'foaf:name': 'Vary Test'
|
|
34
|
+
}),
|
|
35
|
+
auth: 'varytest'
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
after(async () => { await stopTestServer(); });
|
|
40
|
+
|
|
41
|
+
it('Vary header is identical across all conneg variants of the same URL', async () => {
|
|
42
|
+
const accepts = [
|
|
43
|
+
'text/html,*/*;q=0.8', // mashlib HTML wrapper
|
|
44
|
+
'text/turtle', // Turtle conversion
|
|
45
|
+
'application/ld+json' // native JSON-LD
|
|
46
|
+
];
|
|
47
|
+
const varyValues = [];
|
|
48
|
+
for (const accept of accepts) {
|
|
49
|
+
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
|
|
50
|
+
varyValues.push({ accept, vary: res.headers.get('vary') });
|
|
51
|
+
}
|
|
52
|
+
// All three variants must carry the same Vary — inconsistent Vary is
|
|
53
|
+
// what confused browser caches into serving the wrong variant.
|
|
54
|
+
const uniqueVaryValues = new Set(varyValues.map((v) => v.vary));
|
|
55
|
+
assert.strictEqual(uniqueVaryValues.size, 1,
|
|
56
|
+
`expected identical Vary across variants, got: ${JSON.stringify(varyValues)}`);
|
|
57
|
+
const vary = [...uniqueVaryValues][0];
|
|
58
|
+
assert.ok(vary, `expected Vary header across variants, got: ${JSON.stringify(varyValues)}`);
|
|
59
|
+
assert.match(vary, /Accept/, 'Vary must include Accept (conneg active)');
|
|
60
|
+
assert.match(vary, /Authorization/, 'Vary must include Authorization (WAC)');
|
|
61
|
+
assert.match(vary, /Origin/, 'Vary must include Origin (CORS)');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('mashlib HTML wrapper uses Cache-Control: no-store', async () => {
|
|
65
|
+
const res = await request('/varytest/public/card.jsonld', {
|
|
66
|
+
headers: { Accept: 'text/html,*/*;q=0.8' }
|
|
67
|
+
});
|
|
68
|
+
assert.match(res.headers.get('content-type') || '', /text\/html/);
|
|
69
|
+
assert.strictEqual(res.headers.get('cache-control'), 'no-store');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('RDF data variants force revalidation (no stale bodies across auth changes)', async () => {
|
|
73
|
+
// Full expected policy — pinning every directive so a regression that
|
|
74
|
+
// drops `private` or `must-revalidate` (both needed to prevent auth-state
|
|
75
|
+
// leakage and force freshness) fails the test.
|
|
76
|
+
const expected = 'private, no-cache, must-revalidate';
|
|
77
|
+
for (const accept of ['text/turtle', 'application/ld+json']) {
|
|
78
|
+
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
|
|
79
|
+
assert.strictEqual(res.headers.get('cache-control'), expected,
|
|
80
|
+
`Cache-Control mismatch on Accept: ${accept}`);
|
|
81
|
+
// ETag is preserved so revalidation is cheap (304).
|
|
82
|
+
assert.ok(res.headers.get('etag'), `expected ETag on ${accept} variant`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('container index.html data-island variants also carry revalidating Cache-Control', async () => {
|
|
87
|
+
// Publish an index.html with a JSON-LD data island; conneg should extract
|
|
88
|
+
// and serve it as Turtle/JSON-LD. Those variants were missing
|
|
89
|
+
// Cache-Control pre-#315.
|
|
90
|
+
const html = [
|
|
91
|
+
'<!doctype html><html><head>',
|
|
92
|
+
'<script type="application/ld+json">',
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
|
|
95
|
+
'@id': '#this',
|
|
96
|
+
'foaf:name': 'Island'
|
|
97
|
+
}),
|
|
98
|
+
'</script></head><body>hi</body></html>'
|
|
99
|
+
].join('');
|
|
100
|
+
await request('/varytest/public/index.html', {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
headers: { 'Content-Type': 'text/html' },
|
|
103
|
+
body: html,
|
|
104
|
+
auth: 'varytest'
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const expected = 'private, no-cache, must-revalidate';
|
|
108
|
+
for (const accept of ['text/turtle', 'application/ld+json']) {
|
|
109
|
+
const res = await request('/varytest/public/', { headers: { Accept: accept } });
|
|
110
|
+
assert.strictEqual(res.headers.get('cache-control'), expected,
|
|
111
|
+
`Cache-Control mismatch on island variant (Accept: ${accept})`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|