javascript-solid-server 0.0.151 → 0.0.153

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.151",
3
+ "version": "0.0.153",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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, getVaryHeader, RDF_TYPES } from '../rdf/conneg.js';
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
 
@@ -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['Vary'] = 'Accept';
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['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
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['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
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
- headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
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
 
@@ -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 ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
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 ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
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
- * Include Accept when conneg or mashlib is enabled (response varies by Accept header)
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) ? 'Accept, Origin' : 'Origin';
202
+ return (connegEnabled || mashlibEnabled)
203
+ ? 'Accept, Authorization, Origin'
204
+ : 'Authorization, Origin';
196
205
  }
197
206
 
198
207
  /**
package/src/server.js CHANGED
@@ -21,6 +21,7 @@ import { dbPlugin } from './db/index.js';
21
21
  import { webrtcPlugin } from './webrtc/index.js';
22
22
  import { tunnelPlugin } from './tunnel/index.js';
23
23
  import { terminalPlugin } from './terminal/index.js';
24
+ import { registerErrorHandler } from './utils/error-handler.js';
24
25
 
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
 
@@ -154,6 +155,7 @@ export function createServer(options = {}) {
154
155
  }
155
156
 
156
157
  const fastify = Fastify(fastifyOptions);
158
+ registerErrorHandler(fastify);
157
159
 
158
160
  // Add raw body parser for all content types
159
161
  fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Top-level Fastify error handler that logs the full stack for any 5xx.
3
+ *
4
+ * Without this, 500 responses carry only Fastify's default 91-byte body and
5
+ * leave no trace in logs — production debugging has to infer the exception
6
+ * from the response body alone (see #309 investigation for why that's bad).
7
+ *
8
+ * 4xx errors carry their own statusCode and don't need stack logging — they
9
+ * are expected client errors with self-explanatory messages.
10
+ */
11
+ export function registerErrorHandler(fastify) {
12
+ fastify.setErrorHandler(function (err, request, reply) {
13
+ const statusCode = err.statusCode ?? 500;
14
+ if (statusCode >= 500) {
15
+ request.log.error({
16
+ err,
17
+ method: request.method,
18
+ url: request.url,
19
+ hostname: request.hostname
20
+ }, 'Unhandled 5xx error');
21
+ }
22
+ reply.send(err);
23
+ });
24
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tests for the top-level error handler (#312).
3
+ *
4
+ * Verifies that:
5
+ * 1. Unhandled exceptions from route handlers produce a 500 whose body
6
+ * matches Fastify's default shape — the existing 91-byte response
7
+ * format must not regress (that's the baseline consumers rely on).
8
+ * 2. The error's stack is actually logged (with method/url/hostname
9
+ * context), so production debugging has a real trace.
10
+ * 3. 4xx errors are NOT stack-logged (they're expected client errors).
11
+ *
12
+ * Uses a minimal Fastify instance so the test exercises only the handler,
13
+ * not the full server's auth/routing stack.
14
+ */
15
+
16
+ import { describe, it, before, after } from 'node:test';
17
+ import assert from 'node:assert';
18
+ import Fastify from 'fastify';
19
+ import { registerErrorHandler } from '../src/utils/error-handler.js';
20
+
21
+ class LogCapture {
22
+ constructor() { this.lines = []; }
23
+ write(chunk) {
24
+ try { this.lines.push(JSON.parse(chunk)); } catch { /* non-JSON */ }
25
+ return true;
26
+ }
27
+ errorLines() {
28
+ return this.lines.filter((l) => l.level >= 50);
29
+ }
30
+ clear() { this.lines.length = 0; }
31
+ }
32
+
33
+ describe('registerErrorHandler (#312)', () => {
34
+ let app;
35
+ const capture = new LogCapture();
36
+
37
+ before(async () => {
38
+ app = Fastify({
39
+ logger: { level: 'error', stream: capture },
40
+ disableRequestLogging: true
41
+ });
42
+ registerErrorHandler(app);
43
+ app.get('/throw500', async () => { throw new Error('synthetic 5xx for test'); });
44
+ app.get('/throw400', async () => {
45
+ const err = new Error('bad client input');
46
+ err.statusCode = 400;
47
+ throw err;
48
+ });
49
+ });
50
+
51
+ after(async () => { await app.close(); });
52
+
53
+ it('500 response body matches Fastify default shape (no regression)', async () => {
54
+ capture.clear();
55
+ const res = await app.inject({ method: 'GET', url: '/throw500' });
56
+ assert.strictEqual(res.statusCode, 500);
57
+ assert.deepStrictEqual(res.json(), {
58
+ statusCode: 500,
59
+ error: 'Internal Server Error',
60
+ message: 'synthetic 5xx for test'
61
+ });
62
+ });
63
+
64
+ it('500 logs include the stack and request context', async () => {
65
+ capture.clear();
66
+ await app.inject({ method: 'GET', url: '/throw500', headers: { host: 'example.test' } });
67
+ const line = capture.errorLines().find((l) => l.msg === 'Unhandled 5xx error');
68
+ assert.ok(line, `expected 'Unhandled 5xx error' log line, got: ${JSON.stringify(capture.errorLines())}`);
69
+ assert.strictEqual(line.method, 'GET');
70
+ assert.strictEqual(line.url, '/throw500');
71
+ assert.strictEqual(line.hostname, 'example.test');
72
+ assert.ok(line.err && line.err.stack, 'expected err.stack in log');
73
+ assert.ok(line.err.stack.includes('synthetic 5xx'), 'stack should identify the error');
74
+ });
75
+
76
+ it('4xx errors do not trigger the 5xx stack log', async () => {
77
+ capture.clear();
78
+ const res = await app.inject({ method: 'GET', url: '/throw400' });
79
+ assert.strictEqual(res.statusCode, 400);
80
+ const unhandled = capture.errorLines().filter((l) => l.msg === 'Unhandled 5xx error');
81
+ assert.strictEqual(unhandled.length, 0, '4xx must not produce a 5xx stack log');
82
+ });
83
+ });
@@ -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
+ });