Skip to content
Merged
96 changes: 84 additions & 12 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -238,7 +240,8 @@ export async function handleGet(request, reply) {
contentType: 'text/turtle',
origin,
resourceUrl,
connegEnabled
connegEnabled,
lwsEnabled: request.lwsEnabled
});
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Expand All @@ -252,7 +255,8 @@ export async function handleGet(request, reply) {
contentType: 'application/ld+json',
origin,
resourceUrl,
connegEnabled
connegEnabled,
lwsEnabled: request.lwsEnabled
});
headers['Cache-Control'] = RDF_CACHE_CONTROL;

Expand All @@ -272,7 +276,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));
Expand Down Expand Up @@ -323,7 +328,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'";
Expand Down Expand Up @@ -368,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 {
Expand All @@ -385,7 +413,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;

Expand Down Expand Up @@ -487,7 +516,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'";
Expand All @@ -513,7 +543,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;
Expand All @@ -535,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' });
Expand Down Expand Up @@ -572,7 +625,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;

Expand Down Expand Up @@ -603,7 +657,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;

Expand Down Expand Up @@ -632,7 +687,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;
Expand Down Expand Up @@ -851,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
Expand Down Expand Up @@ -901,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({
Expand All @@ -910,7 +981,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).
Expand Down
11 changes: 10 additions & 1 deletion src/ldp/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,10 +110,17 @@ 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}"`;
const ls = `<${resourceUrl}>; rel="linkset"; type="application/linkset+json"`;
const extra = `${sd}, ${ls}`;
headers['Link'] = headers['Link'] ? `${headers['Link']}, ${extra}` : extra;
}
return headers;
}

/**
Expand Down
16 changes: 16 additions & 0 deletions src/lws/linkset.js
Original file line number Diff line number Diff line change
@@ -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] };
}
29 changes: 29 additions & 0 deletions src/lws/storage-description.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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) {
if (!resourceUrl || !resourceUrl.includes('://')) throw new Error(`storageDescriptionUrl requires an absolute URL, got: ${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
* 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,
};
}
9 changes: 8 additions & 1 deletion src/rdf/conneg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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 = request.protocol;
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);
Expand Down
9 changes: 9 additions & 0 deletions test/lws-conneg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ 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');
});

test('linkset+json fires before connegEnabled guard — true flag also returns linkset+json', () => {
assert.equal(selectContentType('application/linkset+json', true), 'application/linkset+json');
});
Loading