Skip to content
Merged
3 changes: 3 additions & 0 deletions bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ program
.option('--no-multi-user', 'Disable multi-user mode')
.option('--conneg', 'Enable content negotiation (Turtle support)')
.option('--no-conneg', 'Disable content negotiation')
.option('--lws', 'Enable the W3C Linked Web Storage surface (application/lws+json containers)')
.option('--notifications', 'Enable WebSocket notifications')
.option('--no-notifications', 'Disable WebSocket notifications')
.option('--idp', 'Enable built-in Identity Provider')
Expand Down Expand Up @@ -216,6 +217,7 @@ program
bodyLimit: config.bodyLimit,
logger: config.logger,
conneg: config.conneg,
lws: config.lws,
notifications: config.notifications,
idp: config.idp,
idpIssuer: idpIssuer,
Expand Down Expand Up @@ -278,6 +280,7 @@ program
console.log(`\n Data: ${path.resolve(config.root)}`);
if (config.ssl) console.log(' SSL: enabled');
if (config.conneg) console.log(' Conneg: enabled');
if (config.lws) console.log(' LWS: enabled');
if (config.notifications) console.log(' WebSocket: enabled');
if (config.idp) console.log(` IdP: ${idpIssuer}`);
if (config.subdomains) console.log(` Subdomains: ${config.baseDomain} (XSS protection enabled)`);
Expand Down
4 changes: 4 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const defaults = {
// Features
multiuser: true,
conneg: false,
lws: false,
notifications: false,

// Identity Provider
Expand Down Expand Up @@ -157,6 +158,7 @@ const envMap = {
JSS_SSL_CERT: 'sslCert',
JSS_MULTIUSER: 'multiuser',
JSS_CONNEG: 'conneg',
JSS_LWS: 'lws',
JSS_NOTIFICATIONS: 'notifications',
JSS_QUIET: 'quiet',
JSS_LOG_LEVEL: 'logLevel',
Expand Down Expand Up @@ -234,6 +236,7 @@ export function parseSize(str) {
const BOOLEAN_KEYS = new Set([
'ssl',
'conneg',
'lws',
'subdomains',
'mashlib',
'mashlibCdn',
Expand Down Expand Up @@ -457,6 +460,7 @@ export function printConfig(config) {
console.log(` Single-user: ${details}`);
}
console.log(` Conneg: ${config.conneg}`);
console.log(` LWS: ${config.lws}`);
console.log(` Notifications: ${config.notifications}`);
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
Expand Down
40 changes: 34 additions & 6 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as storage from '../storage/filesystem.js';
import { checkQuota, updateQuotaUsage } from '../storage/quota.js';
import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse, getPodName } from '../utils/url.js';
import { generateContainerJsonLd, generateLwsContainer, serializeJsonLd } from '../ldp/container.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';
import {
Expand Down Expand Up @@ -291,7 +291,7 @@ export async function handleGet(request, reply) {
const check = checkIfNoneMatchForGet(ifNoneMatch, effectiveEtag);
if (!check.ok && check.notModified) {
reply.header('ETag', effectiveEtag);
reply.header('Vary', getVaryHeader(connegEnabled, request.mashlibEnabled));
reply.header('Vary', getVaryHeader(connegEnabled, request.mashlibEnabled, request.lwsEnabled));
return reply.code(304).send();
}
}
Expand Down Expand Up @@ -334,14 +334,40 @@ export async function handleGet(request, reply) {
}

// Pick the negotiated RDF type using q-aware Accept parsing (#325).
// LWS media type negotiation is always active when lwsEnabled, even
// without full conneg — selectContentType handles it independently.
const acceptHeader = request.headers.accept || '';
const negotiated = connegEnabled
? selectContentType(acceptHeader, true)
const negotiated = (connegEnabled || request.lwsEnabled)
? selectContentType(acceptHeader, connegEnabled)
: null;
const wantsTurtle = negotiated === RDF_TYPES.TURTLE
|| negotiated === RDF_TYPES.N3
|| negotiated === 'application/n-triples';

// LWS container representation — only when enabled AND explicitly negotiated.
if (request.lwsEnabled && negotiated === RDF_TYPES.LWS_JSON) {
const lws = generateLwsContainer(resourceUrl, entries || []);
const headers = getAllHeaders({
isContainer: true,
etag: stats.etag,
contentType: RDF_TYPES.LWS_JSON,
origin,
resourceUrl,
connegEnabled,
mashlibEnabled: request.mashlibEnabled,
lwsEnabled: request.lwsEnabled
});
headers['Cache-Control'] = RDF_CACHE_CONTROL;
const parent = parentContainerUrl(resourceUrl);
if (parent) {
headers['Link'] = headers['Link']
? `${headers['Link']}, <${parent}>; rel="up"`
: `<${parent}>; rel="up"`;
}
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
return reply.send(JSON.stringify(lws, null, 2));
}

if (wantsTurtle) {
// Convert container JSON-LD to Turtle
try {
Expand Down Expand Up @@ -378,7 +404,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 @@ -824,6 +851,7 @@ export async function handleHead(request, reply) {
} else {
contentType = 'application/ld+json';
}
// TODO(lws-head-parity): mirror the GET lws+json negotiation here (L2)

if (indexExists) {
// Mirror GET: containers with index.html use the index file's ETag
Expand Down
28 changes: 28 additions & 0 deletions src/ldp/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Generate container representation as JSON-LD
*/

import mime from 'mime-types';

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

// Dotfiles allowed to appear in ldp:contains. Anything else starting with '.'
Expand Down Expand Up @@ -74,3 +76,29 @@ export function generateContainerJsonLd(containerUrl, entries) {
export function serializeJsonLd(jsonLd) {
return JSON.stringify(jsonLd, null, 2);
}

const LWS_CONTEXT = 'https://www.w3.org/ns/lws/v1';

/**
* Generate the W3C LWS container representation (application/lws+json).
* Additive sibling of generateContainerJsonLd — items[] instead of ldp:contains.
* Pagination deferred: emits the full membership as a single page.
* @param {string} containerUrl
* @param {Array<{name:string,isDirectory:boolean,size?:number,modified?:string}>} entries
* @returns {object}
*/
export function generateLwsContainer(containerUrl, entries) {
const baseUrl = containerUrl.endsWith('/') ? containerUrl : containerUrl + '/';
// LWS excludes all dotfiles (including sidecars like .acl, .meta) from listing
// Deliberately excludes all dotfiles (unlike isHiddenEntry which allows .acl/.meta/.well-known) — LWS hides sidecars
const items = entries.filter(e => !e.name.startsWith('.')).map(e => {
const id = baseUrl + e.name + (e.isDirectory ? '/' : '');
const item = { id, type: e.isDirectory ? 'Container' : 'DataResource' };
if (!e.isDirectory) item.mediaType = mime.lookup(e.name) || 'application/octet-stream';
if (e.size != null) item.size = e.size;
if (e.modified) item.modified = e.modified;
return item;
});
// TODO(lws-pagination): emit ContainerPage with first/next/prev/last when membership is large.
return { '@context': LWS_CONTEXT, id: baseUrl, type: 'Container', totalItems: items.length, items };
}
8 changes: 4 additions & 4 deletions src/ldp/headers.js
Original file line number Diff line number Diff line change
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, mashlibEnabled = false, updatesVia = null }) {
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, lwsEnabled = 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': getVaryHeader(connegEnabled, mashlibEnabled)
'Vary': getVaryHeader(connegEnabled, mashlibEnabled, lwsEnabled)
};

// 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, mashlibEnabled = false, updatesVia = null }) {
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, lwsEnabled = false, updatesVia = null }) {
return {
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, updatesVia }),
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, lwsEnabled, updatesVia }),
...getCorsHeaders(origin)
};
}
Expand Down
13 changes: 10 additions & 3 deletions src/rdf/conneg.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const RDF_TYPES = {
TURTLE: 'text/turtle',
N3: 'text/n3',
NTRIPLES: 'application/n-triples',
RDF_XML: 'application/rdf+xml' // Not supported, but recognized
RDF_XML: 'application/rdf+xml', // Not supported, but recognized
LWS_JSON: 'application/lws+json'
};

// Content types we can serve (when conneg enabled)
Expand All @@ -31,6 +32,12 @@ const SUPPORTED_INPUT = [RDF_TYPES.JSON_LD, RDF_TYPES.TURTLE, RDF_TYPES.N3];
* @returns {string} Selected content type
*/
export function selectContentType(acceptHeader, connegEnabled = false) {
// LWS container media type is always negotiable when explicitly requested
// (it is JSON-LD with the lws/v1 context — no Turtle conneg required).
if (acceptHeader && acceptHeader.toLowerCase().includes(RDF_TYPES.LWS_JSON)) {
return RDF_TYPES.LWS_JSON;
}

// If conneg disabled, always return JSON-LD
if (!connegEnabled) {
return RDF_TYPES.JSON_LD;
Expand Down Expand Up @@ -198,8 +205,8 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa
* - `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)
export function getVaryHeader(connegEnabled, mashlibEnabled = false, lwsEnabled = false) {
return (connegEnabled || mashlibEnabled || lwsEnabled)
? 'Accept, Authorization, Origin'
: 'Authorization, Origin';
}
Expand Down
4 changes: 4 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
export function createServer(options = {}) {
// Content negotiation is OFF by default - we're a JSON-LD native server
const connegEnabled = options.conneg ?? false;
// Linked Web Storage surface is OFF by default
const lwsEnabled = options.lws ?? false;
// WebSocket notifications are OFF by default
const notificationsEnabled = options.notifications ?? false;
// Identity Provider is OFF by default
Expand Down Expand Up @@ -308,6 +310,7 @@ export function createServer(options = {}) {
// Raw request body for the application/json parser to stash (#565).
fastify.decorateRequest('rawBody', null);
fastify.decorateRequest('connegEnabled', null);
fastify.decorateRequest('lwsEnabled', null);
fastify.decorateRequest('notificationsEnabled', null);
fastify.decorateRequest('idpEnabled', null);
fastify.decorateRequest('subdomainsEnabled', null);
Expand All @@ -325,6 +328,7 @@ export function createServer(options = {}) {
fastify.decorateRequest('singleUserName', null);
fastify.addHook('onRequest', async (request) => {
request.connegEnabled = connegEnabled;
request.lwsEnabled = lwsEnabled;
request.notificationsEnabled = notificationsEnabled || liveReloadEnabled;
request.idpEnabled = idpEnabled;
request.subdomainsEnabled = subdomainsEnabled;
Expand Down
13 changes: 13 additions & 0 deletions src/utils/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ export function getParentContainer(urlPath) {
return parts.join('/') + '/';
}

/**
* Get the parent container URL from a full resource URL.
* Returns null when called on the storage root (no parent above the origin).
* @param {string} url - full URL including scheme (e.g. http://localhost:3000/foo/)
* @returns {string|null}
*/
export function parentContainerUrl(url) {
const u = url.endsWith('/') ? url.slice(0, -1) : url;
const i = u.lastIndexOf('/');
if (i <= u.indexOf('://') + 2) return null; // at/above origin root
return u.slice(0, i + 1);
}

/**
* Get resource name from URL path
* @param {string} urlPath
Expand Down
105 changes: 105 additions & 0 deletions test/lws-conformance.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import {
startTestServer,
stopTestServer,
request,
createTestPod,
assertStatus,
} from './helpers.js';

describe('LWS container conformance (e2e)', () => {
before(async () => {
await startTestServer({ lws: true });
await createTestPod('alice');

// Create sub-container and one data member under the public path
await request('/alice/public/notes/', {
method: 'PUT',
auth: 'alice'
});
// PUT JSON-LD to a .ttl URL (always accepted; extension → mediaType in LWS listing)
await request('/alice/public/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': 'Test note' }),
auth: 'alice'
});
});

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

it('GET with Accept: application/lws+json — LWS contract (headers)', async () => {
const res = await request('/alice/public/notes/', {
headers: { Accept: 'application/lws+json' }
});
assertStatus(res, 200);

const ct = (res.headers.get('content-type') || '').split(';')[0].trim();
assert.equal(ct, 'application/lws+json', 'Content-Type must be application/lws+json');

const link = res.headers.get('link') || '';
assert.match(link, /rel="up"/, 'Link header must contain rel="up"');

const etag = res.headers.get('etag');
assert.ok(etag, 'ETag header must be present');
});

it('GET with Accept: application/lws+json — LWS contract (body shape)', async () => {
const res = await request('/alice/public/notes/', {
headers: { Accept: 'application/lws+json' }
});
assertStatus(res, 200);

const body = await res.json();
assert.equal(body['@context'], 'https://www.w3.org/ns/lws/v1', '@context must be lws/v1');
assert.equal(body.type, 'Container', 'type must be Container');
assert.equal(typeof body.totalItems, 'number', 'totalItems must be a number');
assert.ok(Array.isArray(body.items), 'items must be an array');
assert.ok(
body.items.some(i => i.id.endsWith('note.ttl') && i.type === 'DataResource' && i.mediaType),
'items must include note.ttl as a DataResource with a mediaType'
);
});

it('GET with Accept: application/ld+json — LDP negative control', async () => {
const res = await request('/alice/public/notes/', {
headers: { Accept: 'application/ld+json' }
});
assertStatus(res, 200);

const ct = (res.headers.get('content-type') || '').split(';')[0].trim();
assert.equal(ct, 'application/ld+json', 'negative control must return LDP (application/ld+json)');

const body = await res.json();
assert.ok(body.contains !== undefined, 'LDP response must have ldp:contains (mapped as "contains")');
});

it('GET with no Accept — LDP negative control (additivity)', async () => {
const res = await request('/alice/public/notes/');
assertStatus(res, 200);

const ct = (res.headers.get('content-type') || '').split(';')[0].trim();
assert.equal(ct, 'application/ld+json', 'default GET without lws+json Accept must remain LDP');
});

it('Vary header includes Accept for lws+json response when --lws on (cache correctness)', async () => {
const res = await request('/alice/public/notes/', {
headers: { Accept: 'application/lws+json' }
});
assertStatus(res, 200);
const vary = res.headers.get('vary') || '';
assert.ok(vary.toLowerCase().includes('accept'), `Vary must include Accept for lws+json response, got: ${vary}`);
});

it('Vary header includes Accept for ld+json response when --lws on (cache correctness)', async () => {
const res = await request('/alice/public/notes/', {
headers: { Accept: 'application/ld+json' }
});
assertStatus(res, 200);
const vary = res.headers.get('vary') || '';
assert.ok(vary.toLowerCase().includes('accept'), `Vary must include Accept for ld+json response when lws enabled, got: ${vary}`);
});
});
Loading