Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/handlers/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
import { createToken } from '../auth/token.js';
import { canAcceptInput, toJsonLd, getVaryHeader, RDF_TYPES } from '../rdf/conneg.js';
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
import { emitChange } from '../notifications/events.js';

/**
Expand Down Expand Up @@ -138,10 +138,10 @@ export async function handlePost(request, reply) {
const headers = getAllHeaders({
isContainer: isCreatingContainer,
origin,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Location'] = resourceUrl;
headers['Vary'] = getVaryHeader(connegEnabled);

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));

Expand Down
46 changes: 30 additions & 16 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
canAcceptInput,
toJsonLd,
fromJsonLd,
getVaryHeader,
RDF_TYPES
} from '../rdf/conneg.js';
import { emitChange } from '../notifications/events.js';
Expand All @@ -22,6 +21,12 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMash
*/
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>`;

// Cache-Control for RDF data responses: let clients keep the body but force
// revalidation via ETag on every use. This prevents stale bodies from leaking
// across auth-state changes (WAC) and closes the mashlib render-race window
// where a cached data variant was served on top-level navigation (#315).
const RDF_CACHE_CONTROL = 'private, no-cache, must-revalidate';

Comment on lines +24 to +29

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RDF_CACHE_CONTROL is applied to most RDF data responses, but the index.html container code path that extracts a JSON-LD data island and returns Turtle/JSON-LD (handleGet() when stats.isDirectory and indexExists) still returns those RDF variants without setting this Cache-Control. That leaves a caching/revalidation gap for those variants; consider setting Cache-Control: private, no-cache, must-revalidate (and keeping the ETag) for both the Turtle and JSON-LD responses from that branch as well.

Copilot uses AI. Check for mistakes.
/**
* Inject live reload script into HTML content
*/
Expand Down Expand Up @@ -181,6 +186,7 @@ export async function handleGet(request, reply) {
resourceUrl,
connegEnabled
});
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(turtleContent);
Expand All @@ -194,6 +200,7 @@ export async function handleGet(request, reply) {
resourceUrl,
connegEnabled
});
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(JSON.stringify(jsonLd, null, 2));
Expand Down Expand Up @@ -239,9 +246,9 @@ export async function handleGet(request, reply) {
contentType: 'text/html',
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Vary'] = 'Accept';
headers['X-Frame-Options'] = 'DENY';
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
headers['Cache-Control'] = 'no-store';
Expand Down Expand Up @@ -274,9 +281,10 @@ export async function handleGet(request, reply) {
contentType: 'text/turtle',
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Vary'] = 'Accept';
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(turtleContent);
Expand All @@ -292,8 +300,10 @@ export async function handleGet(request, reply) {
contentType: 'application/ld+json',
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Cache-Control'] = RDF_CACHE_CONTROL;
Comment on lines 302 to +306

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For container listings (no index.html), the ETag used here is stats.etag, which is derived from the directory mtime/size. Directory mtime typically does not change when an existing child resource is modified, but the JSON-LD listing includes per-entry dcterms:modified/stat:size, so the representation can change without the ETag changing. With must-revalidate, clients may get 304 and keep a stale container listing. Consider computing a representation-specific ETag based on the listing content/entries (or otherwise ensure the container ETag changes when any child metadata reflected in the listing changes).

Copilot uses AI. Check for mistakes.

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(serializeJsonLd(jsonLd));
Expand All @@ -315,9 +325,9 @@ export async function handleGet(request, reply) {
contentType: 'text/html',
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Vary'] = 'Accept';
headers['X-Frame-Options'] = 'DENY';
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
// Don't cache the HTML wrapper - always negotiate fresh
Expand Down Expand Up @@ -397,9 +407,10 @@ export async function handleGet(request, reply) {
contentType: 'text/turtle',
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(turtleContent);
Expand Down Expand Up @@ -427,9 +438,10 @@ export async function handleGet(request, reply) {
contentType: outputType,
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(outputContent);
Expand All @@ -455,9 +467,12 @@ export async function handleGet(request, reply) {
contentType: actualContentType,
origin,
resourceUrl,
connegEnabled
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
if (isRdfContentType(actualContentType)) {
headers['Cache-Control'] = RDF_CACHE_CONTROL;
}

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));

Expand Down Expand Up @@ -671,9 +686,8 @@ export async function handlePut(request, reply) {
}

const origin = request.headers.origin;
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled, mashlibEnabled: request.mashlibEnabled });
headers['Location'] = resourceUrl;
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));

Expand Down
14 changes: 7 additions & 7 deletions src/ldp/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* LDP (Linked Data Platform) header utilities
*/

import { getAcceptHeaders } from '../rdf/conneg.js';
import { getAcceptHeaders, getVaryHeader } from '../rdf/conneg.js';

const LDP = 'http://www.w3.org/ns/ldp#';

Expand Down Expand Up @@ -49,7 +49,7 @@ export function getAclUrl(resourceUrl, isContainer) {
* @param {object} options
* @returns {object}
*/
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
// Calculate ACL URL if resource URL provided
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;

Expand All @@ -58,7 +58,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
'Accept-Patch': 'text/n3, application/sparql-update',
'Accept-Ranges': isContainer ? 'none' : 'bytes',
'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
};

// Only set WAC-Allow if explicitly provided (otherwise the auth hook sets it)
Expand Down Expand Up @@ -107,9 +107,9 @@ export function getCorsHeaders(origin) {
* @param {object} options
* @returns {object}
*/
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
return {
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, updatesVia }),
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, updatesVia }),
...getCorsHeaders(origin)
};
}
Expand All @@ -120,7 +120,7 @@ export function getAllHeaders({ isContainer = false, etag = null, contentType =
* @param {object} options
* @returns {object}
*/
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false }) {
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false, mashlibEnabled = false }) {
// Determine if this would be a container based on URL ending with /
const isContainer = resourceUrl?.endsWith('/') || false;
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
Expand All @@ -134,7 +134,7 @@ export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEn
'Accept-Patch': 'text/n3, application/sparql-update',
'Accept-Put': acceptHeaders['Accept-Put'] || 'application/ld+json, */*',
'Allow': 'GET, HEAD, PUT, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
};

if (isContainer && acceptHeaders['Accept-Post']) {
Expand Down
13 changes: 11 additions & 2 deletions src/rdf/conneg.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,19 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa

/**
* Get Vary header value for content negotiation
* Include Accept when conneg or mashlib is enabled (response varies by Accept header)
*
* Must be identical across all variants of a given URL — inconsistent Vary
* across variants confuses browser caches and can cause the wrong variant
* to be served on reload (see #315).
*
* - `Accept` — response body depends on Accept (conneg or mashlib HTML shell)
* - `Authorization` — response body depends on the authenticated user (WAC)
* - `Origin` — CORS headers echo the request's Origin
*/
export function getVaryHeader(connegEnabled, mashlibEnabled = false) {
return (connegEnabled || mashlibEnabled) ? 'Accept, Origin' : 'Origin';
return (connegEnabled || mashlibEnabled)
? 'Accept, Authorization, Origin'
: 'Authorization, Origin';
}

/**
Expand Down
114 changes: 114 additions & 0 deletions test/vary-cache-headers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Regression tests for #315 — inconsistent Vary / Cache-Control across
* conneg variants caused stale-render races on browser reload.
*
* What we guarantee now:
* - Every variant of the same URL returns an *identical* Vary header.
* - RDF data variants carry Cache-Control that forces revalidation via
* ETag, so a cached body cannot silently serve across auth changes or
* be picked up on a top-level navigation by mistake.
* - The mashlib HTML wrapper keeps `no-store` (it's a bootstrap template).
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import {
startTestServer,
stopTestServer,
request,
createTestPod
} from './helpers.js';

describe('Vary / Cache-Control consistency (#315)', () => {
before(async () => {
await startTestServer({ conneg: true, mashlibCdn: true });
await createTestPod('varytest');
// Create a JSON-LD resource to exercise all variants.
await request('/varytest/public/card.jsonld', {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
'@id': '#me',
'foaf:name': 'Vary Test'
}),
auth: 'varytest'
});
});

after(async () => { await stopTestServer(); });

it('Vary header is identical across all conneg variants of the same URL', async () => {
const accepts = [
'text/html,*/*;q=0.8', // mashlib HTML wrapper
'text/turtle', // Turtle conversion
'application/ld+json' // native JSON-LD
];
const varyValues = [];
for (const accept of accepts) {
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
varyValues.push({ accept, vary: res.headers.get('vary') });
}
// All three variants must carry the same Vary — inconsistent Vary is
// what confused browser caches into serving the wrong variant.
const uniqueVaryValues = new Set(varyValues.map((v) => v.vary));
assert.strictEqual(uniqueVaryValues.size, 1,
`expected identical Vary across variants, got: ${JSON.stringify(varyValues)}`);
const vary = [...uniqueVaryValues][0];
assert.ok(vary, `expected Vary header across variants, got: ${JSON.stringify(varyValues)}`);
assert.match(vary, /Accept/, 'Vary must include Accept (conneg active)');
assert.match(vary, /Authorization/, 'Vary must include Authorization (WAC)');
assert.match(vary, /Origin/, 'Vary must include Origin (CORS)');
});

it('mashlib HTML wrapper uses Cache-Control: no-store', async () => {
const res = await request('/varytest/public/card.jsonld', {
headers: { Accept: 'text/html,*/*;q=0.8' }
});
assert.match(res.headers.get('content-type') || '', /text\/html/);
assert.strictEqual(res.headers.get('cache-control'), 'no-store');
});

it('RDF data variants force revalidation (no stale bodies across auth changes)', async () => {
// Full expected policy — pinning every directive so a regression that
// drops `private` or `must-revalidate` (both needed to prevent auth-state
// leakage and force freshness) fails the test.
const expected = 'private, no-cache, must-revalidate';
for (const accept of ['text/turtle', 'application/ld+json']) {
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
assert.strictEqual(res.headers.get('cache-control'), expected,
`Cache-Control mismatch on Accept: ${accept}`);
// ETag is preserved so revalidation is cheap (304).
assert.ok(res.headers.get('etag'), `expected ETag on ${accept} variant`);
}
});

it('container index.html data-island variants also carry revalidating Cache-Control', async () => {
// Publish an index.html with a JSON-LD data island; conneg should extract
// and serve it as Turtle/JSON-LD. Those variants were missing
// Cache-Control pre-#315.
const html = [
'<!doctype html><html><head>',
'<script type="application/ld+json">',
JSON.stringify({
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
'@id': '#this',
'foaf:name': 'Island'
}),
'</script></head><body>hi</body></html>'
].join('');
await request('/varytest/public/index.html', {
method: 'PUT',
headers: { 'Content-Type': 'text/html' },
body: html,
auth: 'varytest'
});

const expected = 'private, no-cache, must-revalidate';
for (const accept of ['text/turtle', 'application/ld+json']) {
const res = await request('/varytest/public/', { headers: { Accept: accept } });
assert.strictEqual(res.headers.get('cache-control'), expected,
`Cache-Control mismatch on island variant (Accept: ${accept})`);
}
});
});