From 42009229c6121dc3e27ba86b079cf1c5340de695 Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 09:16:19 -0400 Subject: [PATCH 1/8] feat(lws): generateStorageDescription (Storage + service[]) Co-Authored-By: Claude Opus 4.8 --- src/lws/storage-description.js | 18 +++++++++++++++++ test/lws-storage-description.test.js | 29 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/lws/storage-description.js create mode 100644 test/lws-storage-description.test.js diff --git a/src/lws/storage-description.js b/src/lws/storage-description.js new file mode 100644 index 00000000..36339b7d --- /dev/null +++ b/src/lws/storage-description.js @@ -0,0 +1,18 @@ +const LWS_CONTEXT = 'https://www.w3.org/ns/lws/v1'; + +/** + * Generate the W3C LWS Storage Description resource (application/lws+json). + * Spec: Discovery.html — @context/id/type/service all REQUIRED; each service + * MUST carry type + serviceEndpoint. Single-storage; multi-pod deferred. + * @param {string} storageRootUrl the storage's URI (the `id`) + * @param {Array<{type:string, serviceEndpoint:string}>} services + * @returns {object} + */ +export function generateStorageDescription(storageRootUrl, services = []) { + return { + '@context': LWS_CONTEXT, + id: storageRootUrl, + type: 'Storage', + service: services, + }; +} diff --git a/test/lws-storage-description.test.js b/test/lws-storage-description.test.js new file mode 100644 index 00000000..6e25da7c --- /dev/null +++ b/test/lws-storage-description.test.js @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateStorageDescription } from '../src/lws/storage-description.js'; + +const ROOT = 'http://localhost:3000/'; +const DESC = 'http://localhost:3000/.well-known/lws-storage'; + +test('storage description: required top-level shape', () => { + const d = generateStorageDescription(ROOT, [ + { type: 'StorageDescription', serviceEndpoint: DESC }, + ]); + assert.equal(d['@context'], 'https://www.w3.org/ns/lws/v1'); + assert.equal(d.id, ROOT); + assert.equal(d.type, 'Storage'); + assert.ok(Array.isArray(d.service)); +}); + +test('storage description: every service has type + serviceEndpoint', () => { + const d = generateStorageDescription(ROOT, [ + { type: 'StorageDescription', serviceEndpoint: DESC }, + { type: 'NotificationService', serviceEndpoint: ROOT + 'notification/api' }, + ]); + for (const s of d.service) { + assert.equal(typeof s.type, 'string'); + assert.equal(typeof s.serviceEndpoint, 'string'); + } + assert.ok(d.service.some(s => s.type === 'StorageDescription' && s.serviceEndpoint === DESC)); + assert.ok(d.service.some(s => s.type === 'NotificationService')); +}); From 29b1b463ade0b44131a41e07feeed41e855177f8 Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 09:23:21 -0400 Subject: [PATCH 2/8] feat(lws): serve Storage Description at /.well-known/lws-storage (--lws) - Register GET /.well-known/lws-storage gated on lwsEnabled - Returns generateStorageDescription(root, services) as application/lws+json - root = proto://hostname/ (subdomain-safe); services includes StorageDescription self-entry; conditionally adds NotificationService when notificationsEnabled - 405 on PUT/POST/PATCH/DELETE (reuses existing methodNotAllowed helper) - Route not registered at all when --lws is off (404 from wildcard) - Add focused test (8 tests: 200+body+405s under --lws ON, 404 under --lws OFF) - Full suite: 1003 tests, 0 failures Co-Authored-By: Claude Opus 4.8 --- src/server.js | 25 ++++++ test/lws-storage-description-route.test.js | 96 ++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 test/lws-storage-description-route.test.js diff --git a/src/server.js b/src/server.js index e1fe8fc7..f3f02709 100644 --- a/src/server.js +++ b/src/server.js @@ -34,6 +34,7 @@ import { terminalPlugin } from './terminal/index.js'; import { registerErrorHandler } from './utils/error-handler.js'; import { seedServerRoot } from './ui/server-root.js'; import { assertProvisionKeysCompatible } from './keys/provision.js'; +import { generateStorageDescription } from './lws/storage-description.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -870,6 +871,30 @@ export function createServer(options = {}) { }); } + // LWS Storage Description — served at a fixed path under --lws. + // Auth: /.well-known/* is already globally bypassed by the preHandler above + // (~line 713) so no additional auth wiring is needed here. + if (lwsEnabled) { + const lwsStoragePath = '/.well-known/lws-storage'; + fastify.get(lwsStoragePath, async (request, reply) => { + const proto = options.ssl ? 'https' : 'http'; + const host = request.hostname; + const root = `${proto}://${host}/`; + const services = [{ type: 'StorageDescription', serviceEndpoint: `${proto}://${host}${lwsStoragePath}` }]; + if (notificationsEnabled) { + services.push({ type: 'NotificationService', serviceEndpoint: `${proto}://${host}/notification/api` }); + } + reply.header('Cache-Control', 'public, max-age=3600'); + reply.type('application/lws+json'); + return generateStorageDescription(root, services); + }); + // Block writes — this is a read-only well-known resource. + // Reuse the methodNotAllowed helper defined above for /.well-known/did/nostr. + for (const m of ['put', 'post', 'patch', 'delete']) { + fastify[m](lwsStoragePath, methodNotAllowed); + } + } + // LDP routes - using wildcard routing // Read operations - no rate limit (handled by bodyLimit) fastify.get('/*', handleGet); diff --git a/test/lws-storage-description-route.test.js b/test/lws-storage-description-route.test.js new file mode 100644 index 00000000..9b5857c3 --- /dev/null +++ b/test/lws-storage-description-route.test.js @@ -0,0 +1,96 @@ +/** + * LWS Storage Description Route Tests + * + * Tests GET /.well-known/lws-storage when --lws is enabled/disabled. + * Route is additive only — default behavior (lws: false) must not change. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { + startTestServer, + stopTestServer, + request, + assertStatus, + assertHeaderContains +} from './helpers.js'; + +const LWS_PATH = '/.well-known/lws-storage'; + +describe('LWS Storage Description Route (--lws ON)', () => { + before(async () => { + await startTestServer({ lws: true }); + }); + + after(async () => { + await stopTestServer(); + }); + + it('GET returns 200 with application/lws+json', async () => { + const res = await request(LWS_PATH, { + headers: { Accept: 'application/lws+json' } + }); + assertStatus(res, 200); + assertHeaderContains(res, 'content-type', 'application/lws+json'); + }); + + it('body has correct @context, type, and StorageDescription service', async () => { + const res = await request(LWS_PATH, { + headers: { Accept: 'application/lws+json' } + }); + const body = await res.json(); + assert.strictEqual(body['@context'], 'https://www.w3.org/ns/lws/v1', '@context mismatch'); + assert.strictEqual(body.type, 'Storage', 'type mismatch'); + assert.ok(Array.isArray(body.service), 'service must be an array'); + const sd = body.service.find(s => s.type === 'StorageDescription'); + assert.ok(sd, 'service must contain a StorageDescription entry'); + assert.ok(sd.serviceEndpoint.endsWith('/.well-known/lws-storage'), + `StorageDescription serviceEndpoint should end with /.well-known/lws-storage, got: ${sd.serviceEndpoint}`); + }); + + it('storage id ends with /', async () => { + const res = await request(LWS_PATH, { + headers: { Accept: 'application/lws+json' } + }); + const body = await res.json(); + assert.ok(body.id.endsWith('/'), `id should end with /, got: ${body.id}`); + }); + + it('PUT returns 405', async () => { + const res = await request(LWS_PATH, { method: 'PUT' }); + assertStatus(res, 405); + }); + + it('POST returns 405', async () => { + const res = await request(LWS_PATH, { method: 'POST' }); + assertStatus(res, 405); + }); + + it('PATCH returns 405', async () => { + const res = await request(LWS_PATH, { method: 'PATCH' }); + assertStatus(res, 405); + }); + + it('DELETE returns 405', async () => { + const res = await request(LWS_PATH, { method: 'DELETE' }); + assertStatus(res, 405); + }); +}); + +describe('LWS Storage Description Route (--lws OFF)', () => { + before(async () => { + // Default — lws not set (off) + await startTestServer({}); + }); + + after(async () => { + await stopTestServer(); + }); + + it('GET /.well-known/lws-storage returns 404 when lws is off', async () => { + const res = await request(LWS_PATH, { + headers: { Accept: 'application/lws+json' } + }); + assertStatus(res, 404); + }); +}); From 51ca531058a517b9c0e8bec53f9eb96846c0eede Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 09:36:35 -0400 Subject: [PATCH 3/8] feat(lws): emit rel=storageDescription Link on all storage GET/HEAD (--lws) Co-Authored-By: Claude Opus 4.8 --- src/handlers/resource.js | 33 ++++++++++++++++++++++----------- src/ldp/headers.js | 9 ++++++++- src/lws/storage-description.js | 10 ++++++++++ test/lws-storage-link.test.js | 27 +++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 test/lws-storage-link.test.js diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 9143f12e..d9839870 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -238,7 +238,8 @@ export async function handleGet(request, reply) { contentType: 'text/turtle', origin, resourceUrl, - connegEnabled + connegEnabled, + lwsEnabled: request.lwsEnabled }); headers['Cache-Control'] = RDF_CACHE_CONTROL; @@ -252,7 +253,8 @@ export async function handleGet(request, reply) { contentType: 'application/ld+json', origin, resourceUrl, - connegEnabled + connegEnabled, + lwsEnabled: request.lwsEnabled }); headers['Cache-Control'] = RDF_CACHE_CONTROL; @@ -272,7 +274,8 @@ export async function handleGet(request, reply) { contentType: 'text/html', origin, resourceUrl, - connegEnabled + connegEnabled, + lwsEnabled: request.lwsEnabled }); Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); @@ -323,7 +326,8 @@ export async function handleGet(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); headers['X-Frame-Options'] = 'DENY'; headers['Content-Security-Policy'] = "frame-ancestors 'none'"; @@ -385,7 +389,8 @@ export async function handleGet(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); headers['Cache-Control'] = RDF_CACHE_CONTROL; @@ -487,7 +492,8 @@ export async function handleGet(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); headers['X-Frame-Options'] = 'DENY'; headers['Content-Security-Policy'] = "frame-ancestors 'none'"; @@ -513,7 +519,8 @@ export async function handleGet(request, reply) { contentType: storedContentType, origin, resourceUrl, - connegEnabled + connegEnabled, + lwsEnabled: request.lwsEnabled }); headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`; headers['Content-Length'] = chunkSize; @@ -572,7 +579,8 @@ export async function handleGet(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); headers['Cache-Control'] = RDF_CACHE_CONTROL; @@ -603,7 +611,8 @@ export async function handleGet(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); headers['Cache-Control'] = RDF_CACHE_CONTROL; @@ -632,7 +641,8 @@ export async function handleGet(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); if (isRdfContentType(actualContentType)) { headers['Cache-Control'] = RDF_CACHE_CONTROL; @@ -910,7 +920,8 @@ export async function handleHead(request, reply) { origin, resourceUrl, connegEnabled, - mashlibEnabled: request.mashlibEnabled + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled }); // Mirror GET's Cache-Control for RDF responses (#552 header parity). diff --git a/src/ldp/headers.js b/src/ldp/headers.js index fa6e1f76..f3aace5d 100644 --- a/src/ldp/headers.js +++ b/src/ldp/headers.js @@ -3,8 +3,10 @@ */ import { getAcceptHeaders, getVaryHeader } from '../rdf/conneg.js'; +import { storageDescriptionUrl } from '../lws/storage-description.js'; const LDP = 'http://www.w3.org/ns/ldp#'; +const LWS_STORAGE_DESC_REL = 'https://www.w3.org/ns/lws#storageDescription'; /** * Get Link headers for a resource @@ -108,10 +110,15 @@ export function getCorsHeaders(origin) { * @returns {object} */ export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, lwsEnabled = false, updatesVia = null }) { - return { + const headers = { ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, lwsEnabled, updatesVia }), ...getCorsHeaders(origin) }; + if (lwsEnabled && resourceUrl) { + const sd = `<${storageDescriptionUrl(resourceUrl)}>; rel="${LWS_STORAGE_DESC_REL}"`; + headers['Link'] = headers['Link'] ? `${headers['Link']}, ${sd}` : sd; + } + return headers; } /** diff --git a/src/lws/storage-description.js b/src/lws/storage-description.js index 36339b7d..0a68b059 100644 --- a/src/lws/storage-description.js +++ b/src/lws/storage-description.js @@ -1,5 +1,15 @@ const LWS_CONTEXT = 'https://www.w3.org/ns/lws/v1'; +/** + * Derive the storage description URL from any resource URL in that storage. + * Single-storage assumption (L2): always {origin}/.well-known/lws-storage. + * @param {string} resourceUrl + * @returns {string} + */ +export function storageDescriptionUrl(resourceUrl) { + return `${new URL(resourceUrl).origin}/.well-known/lws-storage`; +} + /** * Generate the W3C LWS Storage Description resource (application/lws+json). * Spec: Discovery.html — @context/id/type/service all REQUIRED; each service diff --git a/test/lws-storage-link.test.js b/test/lws-storage-link.test.js new file mode 100644 index 00000000..250e70cb --- /dev/null +++ b/test/lws-storage-link.test.js @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getAllHeaders } from '../src/ldp/headers.js'; +import { storageDescriptionUrl } from '../src/lws/storage-description.js'; + +const REL = 'https://www.w3.org/ns/lws#storageDescription'; +const R = 'http://localhost:3000/alice/note.ttl'; + +test('storageDescriptionUrl derives {origin}/.well-known/lws-storage', () => { + assert.equal(storageDescriptionUrl(R), 'http://localhost:3000/.well-known/lws-storage'); +}); + +test('storageDescription rel present when lwsEnabled + resourceUrl', () => { + const h = getAllHeaders({ + isContainer: false, etag: '"x"', contentType: 'text/turtle', + origin: 'http://localhost:3000', resourceUrl: R, lwsEnabled: true, + }); + assert.match(h['Link'], new RegExp(`; rel="${REL}"`)); +}); + +test('storageDescription rel ABSENT when lwsEnabled is false', () => { + const h = getAllHeaders({ + isContainer: false, etag: '"x"', contentType: 'text/turtle', + origin: 'http://localhost:3000', resourceUrl: R, lwsEnabled: false, + }); + assert.equal((h['Link'] || '').includes('storageDescription'), false); +}); From 072fe29278e63eefa07e6bfd75f8062f43e453cc Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 09:41:32 -0400 Subject: [PATCH 4/8] feat(lws): generateLinkset (RFC 9264 anchor/up/type/describedby) Co-Authored-By: Claude Opus 4.8 --- src/lws/linkset.js | 16 ++++++++++++++++ test/lws-linkset.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/lws/linkset.js create mode 100644 test/lws-linkset.test.js diff --git a/src/lws/linkset.js b/src/lws/linkset.js new file mode 100644 index 00000000..e8374af7 --- /dev/null +++ b/src/lws/linkset.js @@ -0,0 +1,16 @@ +const LWS = 'https://www.w3.org/ns/lws#'; + +/** + * Generate an RFC 9264 linkset (application/linkset+json) for a resource. + * Read-only discovery slice — mutation/concurrency (If-Match/412/428) deferred. + * @param {string} resourceUrl + * @param {{parentUrl?:string|null, isContainer:boolean, describedByUrl?:string}} opts + * @returns {object} + */ +export function generateLinkset(resourceUrl, { parentUrl = null, isContainer = false, describedByUrl } = {}) { + const link = { anchor: resourceUrl }; + if (parentUrl) link.up = [{ href: parentUrl }]; + link.type = [{ href: LWS + (isContainer ? 'Container' : 'DataResource') }]; + if (describedByUrl) link.describedby = [{ href: describedByUrl }]; + return { linkset: [link] }; +} diff --git a/test/lws-linkset.test.js b/test/lws-linkset.test.js new file mode 100644 index 00000000..2e07ae01 --- /dev/null +++ b/test/lws-linkset.test.js @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateLinkset } from '../src/lws/linkset.js'; + +const R = 'http://localhost:3000/alice/note.ttl'; +const P = 'http://localhost:3000/alice/'; +const DESC = 'http://localhost:3000/.well-known/lws-storage'; +const LWS = 'https://www.w3.org/ns/lws#'; + +test('linkset: RFC 9264 shape with anchor/up/type/describedby', () => { + const ls = generateLinkset(R, { parentUrl: P, isContainer: false, describedByUrl: DESC }); + assert.ok(Array.isArray(ls.linkset)); + const link = ls.linkset[0]; + assert.equal(link.anchor, R); + assert.deepEqual(link.up, [{ href: P }]); + assert.deepEqual(link.type, [{ href: LWS + 'DataResource' }]); + assert.deepEqual(link.describedby, [{ href: DESC }]); +}); + +test('linkset: container type + no up at storage root', () => { + const ls = generateLinkset(P, { parentUrl: null, isContainer: true, describedByUrl: DESC }); + assert.deepEqual(ls.linkset[0].type, [{ href: LWS + 'Container' }]); + assert.equal('up' in ls.linkset[0], false); +}); From 97f7251cd8497ee9399b8b0a0b8a3aaf662788da Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 09:50:07 -0400 Subject: [PATCH 5/8] feat(lws): serve per-resource linkset via conneg + rel=linkset (--lws) - Add RDF_TYPES.LINKSET = 'application/linkset+json' to conneg.js - Add early-exit branch in selectContentType for LINKSET (independent of Turtle conneg flag, same pattern as LWS_JSON) - Add container-GET linkset branch in resource.js (after LWS_JSON branch) serving generateLinkset with isContainer:true under --lws + explicit Accept - Add file-GET linkset branch in resource.js (before conneg block) serving generateLinkset with isContainer:false under --lws + explicit Accept - Advertise ; rel="linkset" in getAllHeaders (headers.js) alongside rel=storageDescription, both gated by lwsEnabled - Extend test/lws-conneg.test.js: LINKSET type + negotiability - Extend test/lws-storage-link.test.js: rel=linkset present/absent assertions Co-Authored-By: Claude Opus 4.8 --- src/handlers/resource.js | 46 +++++++++++++++++++++++++++++++++++ src/ldp/headers.js | 4 ++- src/rdf/conneg.js | 9 ++++++- test/lws-conneg.test.js | 5 ++++ test/lws-storage-link.test.js | 26 ++++++++++++++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/handlers/resource.js b/src/handlers/resource.js index d9839870..2c4acc94 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -2,6 +2,8 @@ import * as storage from '../storage/filesystem.js'; import { checkQuota, updateQuotaUsage } from '../storage/quota.js'; import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js'; import { generateContainerJsonLd, generateLwsContainer, serializeJsonLd } from '../ldp/container.js'; +import { generateLinkset } from '../lws/linkset.js'; +import { storageDescriptionUrl } from '../lws/storage-description.js'; import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse, getPodName, parentContainerUrl } from '../utils/url.js'; import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js'; import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js'; @@ -372,6 +374,28 @@ export async function handleGet(request, reply) { return reply.send(JSON.stringify(lws, null, 2)); } + // LWS per-resource linkset — only when enabled AND explicitly negotiated. + if (request.lwsEnabled && negotiated === RDF_TYPES.LINKSET) { + const ls = generateLinkset(resourceUrl, { + parentUrl: parentContainerUrl(resourceUrl), + isContainer: true, + describedByUrl: storageDescriptionUrl(resourceUrl), + }); + const headers = getAllHeaders({ + isContainer: true, + etag: stats.etag, + contentType: RDF_TYPES.LINKSET, + origin, + resourceUrl, + connegEnabled, + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled, + }); + headers['Cache-Control'] = RDF_CACHE_CONTROL; + Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); + return reply.send(JSON.stringify(ls, null, 2)); + } + if (wantsTurtle) { // Convert container JSON-LD to Turtle try { @@ -542,6 +566,28 @@ export async function handleGet(request, reply) { // If range is null (unsupported format or multi-range), fall through to serve full content } + // LWS per-resource linkset for files — only when enabled AND explicitly negotiated. + if (request.lwsEnabled && selectContentType(request.headers.accept || '', connegEnabled) === RDF_TYPES.LINKSET) { + const ls = generateLinkset(resourceUrl, { + parentUrl: parentContainerUrl(resourceUrl), + isContainer: false, + describedByUrl: storageDescriptionUrl(resourceUrl), + }); + const headers = getAllHeaders({ + isContainer: false, + etag: stats.etag, + contentType: RDF_TYPES.LINKSET, + origin, + resourceUrl, + connegEnabled, + mashlibEnabled: request.mashlibEnabled, + lwsEnabled: request.lwsEnabled, + }); + headers['Cache-Control'] = RDF_CACHE_CONTROL; + Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); + return reply.send(JSON.stringify(ls, null, 2)); + } + const content = await storage.read(storagePath); if (content === null) { return reply.code(500).send({ error: 'Read error' }); diff --git a/src/ldp/headers.js b/src/ldp/headers.js index f3aace5d..d5b6ded6 100644 --- a/src/ldp/headers.js +++ b/src/ldp/headers.js @@ -116,7 +116,9 @@ export function getAllHeaders({ isContainer = false, etag = null, contentType = }; if (lwsEnabled && resourceUrl) { const sd = `<${storageDescriptionUrl(resourceUrl)}>; rel="${LWS_STORAGE_DESC_REL}"`; - headers['Link'] = headers['Link'] ? `${headers['Link']}, ${sd}` : sd; + const ls = `<${resourceUrl}>; rel="linkset"; type="application/linkset+json"`; + const extra = `${sd}, ${ls}`; + headers['Link'] = headers['Link'] ? `${headers['Link']}, ${extra}` : extra; } return headers; } diff --git a/src/rdf/conneg.js b/src/rdf/conneg.js index ae14547e..02e52591 100644 --- a/src/rdf/conneg.js +++ b/src/rdf/conneg.js @@ -16,7 +16,8 @@ export const RDF_TYPES = { N3: 'text/n3', NTRIPLES: 'application/n-triples', RDF_XML: 'application/rdf+xml', // Not supported, but recognized - LWS_JSON: 'application/lws+json' + LWS_JSON: 'application/lws+json', + LINKSET: 'application/linkset+json' }; // Content types we can serve (when conneg enabled) @@ -38,6 +39,12 @@ export function selectContentType(acceptHeader, connegEnabled = false) { return RDF_TYPES.LWS_JSON; } + // RFC 9264 linkset is always negotiable when explicitly requested, + // independent of the Turtle conneg flag. + if (acceptHeader && acceptHeader.toLowerCase().includes(RDF_TYPES.LINKSET)) { + return RDF_TYPES.LINKSET; + } + // If conneg disabled, always return JSON-LD if (!connegEnabled) { return RDF_TYPES.JSON_LD; diff --git a/test/lws-conneg.test.js b/test/lws-conneg.test.js index 5288bae6..a8bccb49 100644 --- a/test/lws-conneg.test.js +++ b/test/lws-conneg.test.js @@ -14,3 +14,8 @@ test('absent lws+json, behavior is unchanged (defaults to JSON-LD)', () => { assert.equal(selectContentType('text/turtle', false), RDF_TYPES.JSON_LD); // conneg off assert.equal(selectContentType('application/ld+json', false), RDF_TYPES.JSON_LD); }); + +test('linkset+json is a known RDF type and is negotiable', () => { + assert.equal(RDF_TYPES.LINKSET, 'application/linkset+json'); + assert.equal(selectContentType('application/linkset+json', false), 'application/linkset+json'); +}); diff --git a/test/lws-storage-link.test.js b/test/lws-storage-link.test.js index 250e70cb..0d4b7831 100644 --- a/test/lws-storage-link.test.js +++ b/test/lws-storage-link.test.js @@ -25,3 +25,29 @@ test('storageDescription rel ABSENT when lwsEnabled is false', () => { }); assert.equal((h['Link'] || '').includes('storageDescription'), false); }); + +test('linkset rel present when lwsEnabled + resourceUrl', () => { + const h = getAllHeaders({ + isContainer: false, etag: '"x"', contentType: 'text/turtle', + origin: 'http://localhost:3000', resourceUrl: R, lwsEnabled: true, + }); + assert.match(h['Link'], /rel="linkset"/); + assert.match(h['Link'], /type="application\/linkset\+json"/); +}); + +test('storageDescription and linkset both present when lwsEnabled', () => { + const h = getAllHeaders({ + isContainer: false, etag: '"x"', contentType: 'text/turtle', + origin: 'http://localhost:3000', resourceUrl: R, lwsEnabled: true, + }); + assert.match(h['Link'], new RegExp(`rel="${REL}"`)); + assert.match(h['Link'], /rel="linkset"/); +}); + +test('linkset rel ABSENT when lwsEnabled is false', () => { + const h = getAllHeaders({ + isContainer: false, etag: '"x"', contentType: 'text/turtle', + origin: 'http://localhost:3000', resourceUrl: R, lwsEnabled: false, + }); + assert.equal((h['Link'] || '').includes('linkset'), false); +}); From 3c221ab63ba78c8e4451cb44603ace2cfa933e6b Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 10:04:58 -0400 Subject: [PATCH 6/8] feat(lws): HEAD parity for lws+json/linkset negotiation - Container branch: after existing conneg, apply LWS override when lwsEnabled so Accept: application/lws+json or application/linkset+json sets the matching Content-Type (mirrors GET's early-return for these types, resolves the TODO(lws-head-parity) marker). - File branch: after negotiateHeadFileContentType, override contentType to LINKSET when lwsEnabled + explicitly negotiated; mark negotiationConverted=true to suppress the on-disk Content-Length (generated linkset differs in size from the stored file). - HEAD already emitted storageDescription + linkset Link rels via the existing lwsEnabled path in getAllHeaders (Task 3); no change needed there. Co-Authored-By: Claude Opus 4.8 --- src/handlers/resource.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 2c4acc94..fa259a18 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -907,7 +907,14 @@ export async function handleHead(request, reply) { } else { contentType = 'application/ld+json'; } - // TODO(lws-head-parity): mirror the GET lws+json negotiation here (L2) + // Mirror GET's LWS negotiation for containers: when lwsEnabled, + // lws+json and linkset override whatever conneg chose above. + // GET checks (connegEnabled || lwsEnabled); HEAD must do the same. + if (request.lwsEnabled) { + const lwsNeg = selectContentType(acceptHeader, connegEnabled); + if (lwsNeg === RDF_TYPES.LWS_JSON) contentType = RDF_TYPES.LWS_JSON; + else if (lwsNeg === RDF_TYPES.LINKSET) contentType = RDF_TYPES.LINKSET; + } if (indexExists) { // Mirror GET: containers with index.html use the index file's ETag @@ -957,6 +964,14 @@ export async function handleHead(request, reply) { contentType = negotiation.contentType; negotiationConverted = negotiation.converted; } + // LWS linkset HEAD parity for files: when enabled and explicitly + // negotiated, set Content-Type to linkset+json (no body on HEAD). + // Generated representation differs in size from stored file, so + // mark converted=true to suppress the on-disk Content-Length. + if (request.lwsEnabled && selectContentType(request.headers.accept || '', connegEnabled) === RDF_TYPES.LINKSET) { + contentType = RDF_TYPES.LINKSET; + negotiationConverted = true; + } } const headers = getAllHeaders({ From 7c4eae48c7fd962bb15d1f36944e3fe66e5917de Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 10:05:09 -0400 Subject: [PATCH 7/8] test(lws): e2e storage-description + linkset conformance + HEAD parity + negative controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-suite e2e covering the L2 discovery slice: - --lws ON: storage description body shape (@context/type/service[]), storageDescription + linkset Link rels on GET, per-resource linkset via conneg (anchor/type/up), HEAD Link-rel parity, HEAD content-type parity for application/linkset+json (no body), LDP additivity control. - --lws OFF: /.well-known/lws-storage → 404; storageDescription and linkset rels absent on resource GET (negative controls). - --lws + --notifications: service[] contains NotificationService entry with serviceEndpoint ending /notification/api (Task 2 gap). Co-Authored-By: Claude Opus 4.8 --- test/lws-discovery-conformance.test.js | 235 +++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 test/lws-discovery-conformance.test.js diff --git a/test/lws-discovery-conformance.test.js b/test/lws-discovery-conformance.test.js new file mode 100644 index 00000000..a8d6e0c7 --- /dev/null +++ b/test/lws-discovery-conformance.test.js @@ -0,0 +1,235 @@ +/** + * LWS storage-description + per-resource linkset end-to-end conformance test. + * + * Covers: + * 1. GET /.well-known/lws-storage body shape (Task 1) + * 2. storageDescription + linkset Link rels on GET and HEAD (Tasks 3/6) + * 3. Per-resource linkset via conneg (Tasks 4/5) + * 4. HEAD content-type parity for linkset+json (Task 6) + * 5. Negative controls: lws OFF → no rels/route; lws ON + default Accept → LDP + * 6. NotificationService entry in storage description (Task 2 gap) + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { + startTestServer, + stopTestServer, + request, + createTestPod, + assertStatus, +} from './helpers.js'; + +const SD_PATH = '/.well-known/lws-storage'; +const SD_REL = 'https://www.w3.org/ns/lws#storageDescription'; +const LWS_CONTEXT = 'https://www.w3.org/ns/lws/v1'; +const NOTE_PATH = '/alice/notes/note.ttl'; + +// --------------------------------------------------------------------------- +// Main conformance suite: --lws ON +// public:true bypasses WAC so unauthenticated reads work without the pod's +// /public/ ACL path — keeps NOTE_PATH at /alice/notes/note.ttl as in brief. +// --------------------------------------------------------------------------- +describe('LWS discovery conformance (--lws ON)', () => { + before(async () => { + await startTestServer({ lws: true, public: true }); + await createTestPod('alice'); + + // Create container + await request('/alice/notes/', { method: 'PUT', auth: 'alice' }); + + // Create a data resource (JSON-LD — .ttl extension storage, same as lws-conformance.test.js) + await request(NOTE_PATH, { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ '@context': { dc: 'http://purl.org/dc/terms/' }, '@id': '#n', 'dc:title': 'Conformance note' }), + auth: 'alice', + }); + }); + + after(async () => { + await stopTestServer(); + }); + + // (a) Storage Description resource + it('GET /.well-known/lws-storage → 200, application/lws+json', async () => { + const res = await request(SD_PATH, { headers: { Accept: 'application/lws+json' } }); + assertStatus(res, 200); + const ct = (res.headers.get('content-type') || '').split(';')[0].trim(); + assert.equal(ct, 'application/lws+json'); + }); + + it('Storage Description body: @context / type / StorageDescription service', async () => { + const res = await request(SD_PATH, { headers: { Accept: 'application/lws+json' } }); + assertStatus(res, 200); + const body = await res.json(); + assert.equal(body['@context'], LWS_CONTEXT, '@context must be lws/v1'); + assert.equal(body.type, 'Storage', 'type must be Storage'); + assert.ok(Array.isArray(body.service), 'service must be an array'); + const sd = body.service.find(s => s.type === 'StorageDescription'); + assert.ok(sd, 'service must contain a StorageDescription entry'); + assert.ok(sd.serviceEndpoint, 'StorageDescription must have serviceEndpoint'); + assert.ok( + sd.serviceEndpoint.endsWith('/.well-known/lws-storage'), + `serviceEndpoint should end with /.well-known/lws-storage, got: ${sd.serviceEndpoint}` + ); + }); + + // (b) storageDescription + linkset Link rels on a regular resource GET + it('GET resource → Link: rel="storageDescription" present', async () => { + const res = await request(NOTE_PATH, { headers: { Accept: 'text/turtle' } }); + assertStatus(res, 200); + const link = res.headers.get('link') || ''; + assert.match(link, new RegExp(`rel="${SD_REL.replace(/\//g, '\\/')}"`), + `Link header must contain rel="${SD_REL}", got: ${link}`); + }); + + it('GET resource → Link: rel="linkset" present', async () => { + const res = await request(NOTE_PATH, { headers: { Accept: 'text/turtle' } }); + assertStatus(res, 200); + const link = res.headers.get('link') || ''; + assert.match(link, /rel="linkset"/, `Link header must contain rel="linkset", got: ${link}`); + }); + + // (c) per-resource linkset via conneg + it('GET resource with Accept: application/linkset+json → linkset+json content-type', async () => { + const res = await request(NOTE_PATH, { headers: { Accept: 'application/linkset+json' } }); + assertStatus(res, 200); + const ct = (res.headers.get('content-type') || '').split(';')[0].trim(); + assert.equal(ct, 'application/linkset+json'); + }); + + it('linkset body: anchor ends with note path', async () => { + const res = await request(NOTE_PATH, { headers: { Accept: 'application/linkset+json' } }); + const body = await res.json(); + assert.ok(Array.isArray(body.linkset), 'linkset must be an array'); + const link = body.linkset[0]; + assert.ok(link.anchor.endsWith(NOTE_PATH), + `anchor should end with ${NOTE_PATH}, got: ${link.anchor}`); + }); + + it('linkset body: type[0].href is LWS DataResource', async () => { + const res = await request(NOTE_PATH, { headers: { Accept: 'application/linkset+json' } }); + const body = await res.json(); + const link = body.linkset[0]; + assert.equal(link.type[0].href, 'https://www.w3.org/ns/lws#DataResource'); + }); + + it('linkset body: up is present', async () => { + const res = await request(NOTE_PATH, { headers: { Accept: 'application/linkset+json' } }); + const body = await res.json(); + const link = body.linkset[0]; + assert.ok(link.up, 'up must be present in linkset'); + }); + + // (d) HEAD parity: discovery rels + it('HEAD resource → Link: rel="storageDescription" present (HEAD parity)', async () => { + const res = await request(NOTE_PATH, { method: 'HEAD' }); + assertStatus(res, 200); + const link = res.headers.get('link') || ''; + assert.match(link, new RegExp(`rel="${SD_REL.replace(/\//g, '\\/')}"`), + `HEAD Link header must contain rel="${SD_REL}", got: ${link}`); + }); + + it('HEAD resource → Link: rel="linkset" present (HEAD parity)', async () => { + const res = await request(NOTE_PATH, { method: 'HEAD' }); + assertStatus(res, 200); + const link = res.headers.get('link') || ''; + assert.match(link, /rel="linkset"/, `HEAD Link header must contain rel="linkset", got: ${link}`); + }); + + it('HEAD with Accept: application/linkset+json → content-type parity', async () => { + const res = await request(NOTE_PATH, { + method: 'HEAD', + headers: { Accept: 'application/linkset+json' }, + }); + assertStatus(res, 200); + const ct = (res.headers.get('content-type') || '').split(';')[0].trim(); + assert.equal(ct, 'application/linkset+json', 'HEAD content-type must match GET for linkset+json'); + // HEAD must not return a body (Content-Length should be absent or 0) + const cl = res.headers.get('content-length'); + assert.ok(!cl || cl === '0', `HEAD must have no body; Content-Length=${cl}`); + }); + + // (e/b) Negative control: with --lws ON but default Accept, body is LDP + it('GET container with no Accept → body is LDP (not linkset), additivity intact', async () => { + const res = await request('/alice/notes/'); + assertStatus(res, 200); + const ct = (res.headers.get('content-type') || '').split(';')[0].trim(); + assert.equal(ct, 'application/ld+json', + 'default GET without Accept: lws+json must remain LDP (application/ld+json)'); + }); +}); + +// --------------------------------------------------------------------------- +// Negative controls: --lws OFF +// --------------------------------------------------------------------------- +describe('LWS discovery negative controls (--lws OFF)', () => { + before(async () => { + await startTestServer({ public: true }); + await createTestPod('bob'); + await request('/bob/notes/', { method: 'PUT', auth: 'bob' }); + await request('/bob/notes/note.ttl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ '@context': { dc: 'http://purl.org/dc/terms/' }, '@id': '#n', 'dc:title': 'Bob note' }), + auth: 'bob', + }); + }); + + after(async () => { + await stopTestServer(); + }); + + it('GET /.well-known/lws-storage → 404 when lws is off', async () => { + const res = await request(SD_PATH, { headers: { Accept: 'application/lws+json' } }); + assertStatus(res, 404); + }); + + it('GET resource → Link header does NOT contain storageDescription rel when lws is off', async () => { + const res = await request('/bob/notes/note.ttl', { headers: { Accept: 'text/turtle' } }); + assertStatus(res, 200); + const link = res.headers.get('link') || ''; + assert.ok( + !link.includes('storageDescription'), + `Link header must NOT contain storageDescription when lws is off, got: ${link}` + ); + }); + + it('GET resource → Link header does NOT contain linkset rel when lws is off', async () => { + const res = await request('/bob/notes/note.ttl', { headers: { Accept: 'text/turtle' } }); + assertStatus(res, 200); + const link = res.headers.get('link') || ''; + assert.ok( + !link.includes('rel="linkset"'), + `Link header must NOT contain rel="linkset" when lws is off, got: ${link}` + ); + }); +}); + +// --------------------------------------------------------------------------- +// NotificationService in storage description (Task 2 gap) +// --------------------------------------------------------------------------- +describe('LWS NotificationService in storage description (--lws + --notifications)', () => { + before(async () => { + await startTestServer({ lws: true, notifications: true }); + }); + + after(async () => { + await stopTestServer(); + }); + + it('service array contains a NotificationService entry', async () => { + const res = await request(SD_PATH, { headers: { Accept: 'application/lws+json' } }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.service), 'service must be an array'); + const ns = body.service.find(s => s.type === 'NotificationService'); + assert.ok(ns, 'service must contain a NotificationService entry'); + assert.ok(ns.serviceEndpoint, 'NotificationService must have serviceEndpoint'); + assert.ok( + ns.serviceEndpoint.endsWith('/notification/api'), + `NotificationService serviceEndpoint should end with /notification/api, got: ${ns.serviceEndpoint}` + ); + }); +}); From 8927adac5fc7c8301d3e072db346ae1e2c79f3e5 Mon Sep 17 00:00:00 2001 From: CharlesVardeman Date: Tue, 30 Jun 2026 10:19:06 -0400 Subject: [PATCH 8/8] fix(lws): storage-description route uses request.protocol (proxy scheme parity) + guard/test tightening - src/server.js: replace `options.ssl ? 'https' : 'http'` with `request.protocol` in the GET /.well-known/lws-storage handler so the body's id and serviceEndpoints honor X-Forwarded-Proto (trustProxy:true), matching the scheme used by storageDescriptionUrl() and Updates-Via - src/lws/storage-description.js: add guard that throws a clear Error on relative/non-URL args before new URL() can throw a raw TypeError - test/lws-storage-description-route.test.js: add proxy-scheme-parity test that sends X-Forwarded-Proto: https and asserts id + StorageDescription serviceEndpoint both start with https:// - test/lws-storage-link.test.js: add test asserting the guard throws with /requires an absolute URL/ on '/alice/note.ttl' - test/lws-conneg.test.js: add assertion that selectContentType with connegEnabled=true also returns 'application/linkset+json' (fires before the connegEnabled guard) Co-Authored-By: Claude Opus 4.8 --- src/lws/storage-description.js | 1 + src/server.js | 2 +- test/lws-conneg.test.js | 4 ++++ test/lws-storage-description-route.test.js | 19 +++++++++++++++++++ test/lws-storage-link.test.js | 7 +++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/lws/storage-description.js b/src/lws/storage-description.js index 0a68b059..74559102 100644 --- a/src/lws/storage-description.js +++ b/src/lws/storage-description.js @@ -7,6 +7,7 @@ const LWS_CONTEXT = 'https://www.w3.org/ns/lws/v1'; * @returns {string} */ export function storageDescriptionUrl(resourceUrl) { + if (!resourceUrl || !resourceUrl.includes('://')) throw new Error(`storageDescriptionUrl requires an absolute URL, got: ${resourceUrl}`); return `${new URL(resourceUrl).origin}/.well-known/lws-storage`; } diff --git a/src/server.js b/src/server.js index f3f02709..ba9affef 100644 --- a/src/server.js +++ b/src/server.js @@ -877,7 +877,7 @@ export function createServer(options = {}) { if (lwsEnabled) { const lwsStoragePath = '/.well-known/lws-storage'; fastify.get(lwsStoragePath, async (request, reply) => { - const proto = options.ssl ? 'https' : 'http'; + const proto = request.protocol; const host = request.hostname; const root = `${proto}://${host}/`; const services = [{ type: 'StorageDescription', serviceEndpoint: `${proto}://${host}${lwsStoragePath}` }]; diff --git a/test/lws-conneg.test.js b/test/lws-conneg.test.js index a8bccb49..66c8ffa7 100644 --- a/test/lws-conneg.test.js +++ b/test/lws-conneg.test.js @@ -19,3 +19,7 @@ test('linkset+json is a known RDF type and is negotiable', () => { assert.equal(RDF_TYPES.LINKSET, 'application/linkset+json'); assert.equal(selectContentType('application/linkset+json', false), 'application/linkset+json'); }); + +test('linkset+json fires before connegEnabled guard — true flag also returns linkset+json', () => { + assert.equal(selectContentType('application/linkset+json', true), 'application/linkset+json'); +}); diff --git a/test/lws-storage-description-route.test.js b/test/lws-storage-description-route.test.js index 9b5857c3..c0a086ba 100644 --- a/test/lws-storage-description-route.test.js +++ b/test/lws-storage-description-route.test.js @@ -75,6 +75,25 @@ describe('LWS Storage Description Route (--lws ON)', () => { const res = await request(LWS_PATH, { method: 'DELETE' }); assertStatus(res, 405); }); + + it('body scheme matches X-Forwarded-Proto (proxy scheme parity)', async () => { + // Fastify trustProxy:true honors X-Forwarded-Proto — request.protocol becomes 'https' + // so the route's proto = request.protocol produces https:// in id and serviceEndpoints. + const res = await request(LWS_PATH, { + headers: { + Accept: 'application/lws+json', + 'X-Forwarded-Proto': 'https', + } + }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(body.id.startsWith('https://'), + `id should start with https:// when X-Forwarded-Proto: https, got: ${body.id}`); + const sd = body.service.find(s => s.type === 'StorageDescription'); + assert.ok(sd, 'StorageDescription service must exist'); + assert.ok(sd.serviceEndpoint.startsWith('https://'), + `StorageDescription serviceEndpoint should start with https://, got: ${sd.serviceEndpoint}`); + }); }); describe('LWS Storage Description Route (--lws OFF)', () => { diff --git a/test/lws-storage-link.test.js b/test/lws-storage-link.test.js index 0d4b7831..e177cd45 100644 --- a/test/lws-storage-link.test.js +++ b/test/lws-storage-link.test.js @@ -10,6 +10,13 @@ test('storageDescriptionUrl derives {origin}/.well-known/lws-storage', () => { assert.equal(storageDescriptionUrl(R), 'http://localhost:3000/.well-known/lws-storage'); }); +test('storageDescriptionUrl throws a clear error on a relative URL', () => { + assert.throws( + () => storageDescriptionUrl('/alice/note.ttl'), + { message: /requires an absolute URL/ } + ); +}); + test('storageDescription rel present when lwsEnabled + resourceUrl', () => { const h = getAllHeaders({ isContainer: false, etag: '"x"', contentType: 'text/turtle',