javascript-solid-server 0.0.150 → 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.
@@ -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.150",
3
+ "version": "0.0.152",
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
  /**
@@ -8,6 +8,10 @@ import { join } from 'path';
8
8
  import { getDataRoot } from '../utils/url.js';
9
9
 
10
10
  const QUOTA_FILE = '.quota.json';
11
+ // Temp files created by saveQuota live next to the quota file; they must be
12
+ // excluded from disk-usage reconciliation so an orphan (e.g. from a crash
13
+ // between writeFile and rename) doesn't inflate pod usage.
14
+ const QUOTA_TMP_PREFIX = `${QUOTA_FILE}.tmp.`;
11
15
 
12
16
  /**
13
17
  * Get quota file path for a pod
@@ -16,6 +20,18 @@ function getQuotaPath(podName) {
16
20
  return join(getDataRoot(), podName, QUOTA_FILE);
17
21
  }
18
22
 
23
+ /**
24
+ * Coerce a parsed quota object to a sane shape so all callers can do
25
+ * arithmetic on it without worrying about undefined/NaN/negative values.
26
+ */
27
+ function sanitizeQuota(q) {
28
+ const toNum = (v) => {
29
+ const n = Number(v);
30
+ return Number.isFinite(n) && n >= 0 ? n : 0;
31
+ };
32
+ return { limit: toNum(q?.limit), used: toNum(q?.used) };
33
+ }
34
+
19
35
  /**
20
36
  * Load quota data for a pod
21
37
  * @param {string} podName - The pod name
@@ -24,11 +40,15 @@ function getQuotaPath(podName) {
24
40
  export async function loadQuota(podName) {
25
41
  try {
26
42
  const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
27
- return JSON.parse(data);
43
+ // Empty/partial file can appear if a write was interrupted (or from a
44
+ // racing non-atomic save on older versions). Treat as missing and
45
+ // reconcile against on-disk usage so we don't under-count.
46
+ if (!data.trim()) return { limit: 0, used: await calculatePodSize(podName) };
47
+ return sanitizeQuota(JSON.parse(data));
28
48
  } catch (err) {
29
- if (err.code === 'ENOENT') {
30
- // No quota file - return defaults (will be initialized on first write)
31
- return { limit: 0, used: 0 };
49
+ if (err.code === 'ENOENT') return { limit: 0, used: 0 };
50
+ if (err instanceof SyntaxError) {
51
+ return { limit: 0, used: await calculatePodSize(podName) };
32
52
  }
33
53
  throw err;
34
54
  }
@@ -40,7 +60,19 @@ export async function loadQuota(podName) {
40
60
  * @param {object} quota - Quota data
41
61
  */
42
62
  export async function saveQuota(podName, quota) {
43
- await fs.writeFile(getQuotaPath(podName), JSON.stringify(quota, null, 2));
63
+ // Atomic write: fs.writeFile truncates before writing, which lets concurrent
64
+ // readers see an empty file. Write to a temp file and rename (POSIX-atomic).
65
+ // Any failure (writeFile or rename) cleans up the temp file so failed writes
66
+ // don't accumulate orphans.
67
+ const finalPath = getQuotaPath(podName);
68
+ const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
69
+ try {
70
+ await fs.writeFile(tmpPath, JSON.stringify(quota, null, 2));
71
+ await fs.rename(tmpPath, finalPath);
72
+ } catch (err) {
73
+ await fs.unlink(tmpPath).catch(() => {});
74
+ throw err;
75
+ }
44
76
  }
45
77
 
46
78
  /**
@@ -76,8 +108,9 @@ async function calculateDirSize(dirPath) {
76
108
  for (const entry of entries) {
77
109
  const fullPath = join(dirPath, entry.name);
78
110
 
79
- // Skip quota file itself
111
+ // Skip the quota file and any orphaned temp files from saveQuota.
80
112
  if (entry.name === QUOTA_FILE) continue;
113
+ if (entry.name.startsWith(QUOTA_TMP_PREFIX)) continue;
81
114
 
82
115
  if (entry.isDirectory()) {
83
116
  total += await calculateDirSize(fullPath);
@@ -106,9 +139,12 @@ async function calculateDirSize(dirPath) {
106
139
  export async function checkQuota(podName, additionalBytes, defaultQuota) {
107
140
  let quota = await loadQuota(podName);
108
141
 
109
- // Initialize if no quota set
142
+ // Initialize if no quota set. loadQuota already sanitizes shape, so `used`
143
+ // is a finite non-negative number here — preserve it so reconciled usage
144
+ // from a recovered corrupt/empty file is not reset to 0.
110
145
  if (quota.limit === 0 && defaultQuota > 0) {
111
- quota = await initializeQuota(podName, defaultQuota);
146
+ quota = { limit: defaultQuota, used: quota.used };
147
+ await saveQuota(podName, quota);
112
148
  }
113
149
 
114
150
  // No quota enforcement if limit is 0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Regression tests for #309 — concurrent quota updates causing 500s.
3
+ *
4
+ * Without atomic writes, two concurrent updateQuotaUsage calls race on
5
+ * .quota.json — the second load can read an empty/partial file and
6
+ * JSON.parse throws "Unexpected end of JSON input".
7
+ */
8
+
9
+ import { describe, it, before, after } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import fs from 'fs-extra';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import {
15
+ initializeQuota,
16
+ updateQuotaUsage,
17
+ loadQuota,
18
+ saveQuota,
19
+ checkQuota,
20
+ calculatePodSize
21
+ } from '../src/storage/quota.js';
22
+
23
+ const POD = 'testpod';
24
+ let TEST_ROOT;
25
+ let originalDataRoot;
26
+
27
+ describe('quota — concurrent updates (#309)', () => {
28
+ before(async () => {
29
+ originalDataRoot = process.env.DATA_ROOT;
30
+ TEST_ROOT = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-quota-'));
31
+ process.env.DATA_ROOT = TEST_ROOT;
32
+ await fs.ensureDir(path.join(TEST_ROOT, POD));
33
+ await initializeQuota(POD, 50 * 1024 * 1024);
34
+ });
35
+
36
+ after(async () => {
37
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
38
+ else process.env.DATA_ROOT = originalDataRoot;
39
+ await fs.remove(TEST_ROOT);
40
+ });
41
+
42
+ it('many concurrent updates do not throw', async () => {
43
+ const N = 50;
44
+ const updates = Array.from({ length: N }, (_, i) =>
45
+ updateQuotaUsage(POD, 10 + i)
46
+ );
47
+ await assert.doesNotReject(Promise.all(updates));
48
+ const final = await loadQuota(POD);
49
+ assert.strictEqual(typeof final.used, 'number');
50
+ assert.ok(final.used > 0);
51
+ });
52
+
53
+ async function withResourceAndBrokenQuota(brokenContent) {
54
+ const resourcePath = path.join(TEST_ROOT, POD, 'resource.txt');
55
+ const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
56
+ const size = 321;
57
+ await fs.writeFile(resourcePath, 'x'.repeat(size));
58
+ await fs.writeFile(quotaPath, brokenContent);
59
+ return { size, cleanup: () => fs.remove(resourcePath) };
60
+ }
61
+
62
+ it('loadQuota reconciles usage from disk when quota file is empty', async () => {
63
+ const { size, cleanup } = await withResourceAndBrokenQuota('');
64
+ try {
65
+ const q = await loadQuota(POD);
66
+ assert.strictEqual(q.limit, 0);
67
+ assert.strictEqual(q.used, size);
68
+ } finally { await cleanup(); }
69
+ });
70
+
71
+ it('loadQuota reconciles usage from disk when quota file is corrupt', async () => {
72
+ const { size, cleanup } = await withResourceAndBrokenQuota('{"limit":524');
73
+ try {
74
+ const q = await loadQuota(POD);
75
+ assert.strictEqual(q.limit, 0);
76
+ assert.strictEqual(q.used, size);
77
+ } finally { await cleanup(); }
78
+ });
79
+
80
+ it('loadQuota sanitizes malformed fields (missing/null/negative)', async () => {
81
+ const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
82
+ const cases = [
83
+ ['{"limit":0}', { limit: 0, used: 0 }],
84
+ ['{"limit":null,"used":null}', { limit: 0, used: 0 }],
85
+ ['{"limit":-10,"used":-5}', { limit: 0, used: 0 }],
86
+ // Numeric strings are coerced — tolerates legacy/manually-edited files.
87
+ ['{"limit":"100","used":"50"}', { limit: 100, used: 50 }]
88
+ ];
89
+ for (const [body, expected] of cases) {
90
+ await fs.writeFile(quotaPath, body);
91
+ const q = await loadQuota(POD);
92
+ assert.deepStrictEqual(q, expected, `for ${body}`);
93
+ }
94
+ });
95
+
96
+ it('calculatePodSize ignores orphaned quota temp files', async () => {
97
+ // Simulate an orphan from a crashed saveQuota (process died between
98
+ // writeFile and rename) — must not be counted toward pod usage.
99
+ const resource = path.join(TEST_ROOT, POD, 'data.txt');
100
+ const orphan = path.join(TEST_ROOT, POD, '.quota.json.tmp.999.1.abc');
101
+ await fs.writeFile(resource, 'x'.repeat(50));
102
+ await fs.writeFile(orphan, 'x'.repeat(9999));
103
+ try {
104
+ const size = await calculatePodSize(POD);
105
+ assert.strictEqual(size, 50, 'orphan temp file must not be counted');
106
+ } finally {
107
+ await fs.remove(resource);
108
+ await fs.remove(orphan);
109
+ }
110
+ });
111
+
112
+ it('checkQuota preserves reconciled usage when re-initializing limit', async () => {
113
+ const { size, cleanup } = await withResourceAndBrokenQuota('');
114
+ try {
115
+ const defaultQuota = 10 * 1024 * 1024;
116
+ const { quota } = await checkQuota(POD, 0, defaultQuota);
117
+ assert.strictEqual(quota.limit, defaultQuota);
118
+ assert.strictEqual(quota.used, size, 'reconciled usage must not be reset to 0');
119
+ } finally { await cleanup(); }
120
+ });
121
+
122
+ it('saveQuota is atomic — concurrent read during save never sees empty file', async () => {
123
+ await saveQuota(POD, { limit: 1000, used: 100 });
124
+ const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
125
+
126
+ // Interleave 200 saves with 200 reads; with non-atomic writes, at least
127
+ // one read would land on a truncated file — the empty-file assertion
128
+ // below (or the JSON.parse) would then fail.
129
+ const ops = [];
130
+ for (let i = 0; i < 200; i++) {
131
+ ops.push(saveQuota(POD, { limit: 1000, used: i }));
132
+ ops.push(fs.readFile(quotaPath, 'utf-8').then((data) => {
133
+ assert.notStrictEqual(
134
+ data.length,
135
+ 0,
136
+ 'concurrent read saw an empty quota file'
137
+ );
138
+ JSON.parse(data);
139
+ }));
140
+ }
141
+ await assert.doesNotReject(Promise.all(ops));
142
+ });
143
+ });
@@ -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
+ });