From 3c33785a1f00a98e122ea32f3c3131f4a3ebe5ab Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Wed, 14 Jan 2026 15:46:38 +0100 Subject: [PATCH 1/2] feat: add LWS protocol mode (draft implementation) Add optional --lws-mode flag to support W3C Linked Web Storage protocol semantics alongside current Solid/LDP implementation. Changes: - Add lwsMode config flag (CLI, env var, config file) - Implement PUT restriction in LWS mode (updates only, not creation) - Add request.lwsMode decoration in server - Add basic LWS tests demonstrating PUT/POST behavior - Document LWS mode in README with examples Key difference: In LWS mode, PUT returns 404 for non-existent resources and suggests using POST for creation (server-assigned URIs with Slug). Status: DRAFT - LWS spec is in early stages. This captures the basic infrastructure for future development. See issue #87 for full plan. Related: #87 Spec: https://github.com/w3c/lws-protocol/pull/37 --- README.md | 44 ++++++++++++++ bin/jss.js | 2 + src/config.js | 3 + src/handlers/resource.js | 15 ++++- src/server.js | 4 ++ test/lws.test.js | 127 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 test/lws.test.js diff --git a/README.md b/README.md index 937170e..a22994c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A minimal, fast, JSON-LD native Solid server. - **Passkey Authentication** - WebAuthn/FIDO2 passwordless login with Touch ID, Face ID, or security keys - **HTTP Range Requests** - Partial content delivery for large files and media streaming +- **LWS Protocol Mode (DRAFT)** - Optional W3C Linked Web Storage semantics (`--lws-mode` flag, see #87) - **Single-User Mode** - Simplified setup for personal pod servers - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD @@ -301,6 +302,7 @@ createServer({ logger: true, // Enable Fastify logging (default: true) conneg: false, // Enable content negotiation (default: false) notifications: false, // Enable WebSocket notifications (default: false) + lwsMode: false, // Enable LWS protocol mode - DRAFT (default: false) subdomains: false, // Enable subdomain-based pods (default: false) baseDomain: null, // Base domain for subdomains (e.g., "example.com") mashlib: false, // Enable Mashlib data browser - local mode (default: false) @@ -349,6 +351,48 @@ Serves a modern Nextcloud-style UI shell while reusing mashlib's data layer. The Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [solidos-ui](https://github.com/solidos/solidos/tree/main/workspaces/solidos-ui) for details. +### LWS Protocol Mode (DRAFT) + +⚠️ **Experimental Feature** - See [issue #87](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/87) for full details. + +Enable W3C Linked Web Storage protocol semantics (alternative to Solid/LDP): + +```bash +jss start --lws-mode +``` + +**Key Differences from Solid/LDP:** + +| Aspect | Solid/LDP (Default) | LWS Mode | +|--------|-------------------|----------| +| Resource Creation | PUT or POST | POST only | +| PUT Semantics | Create or update | Update only (404 if not exists) | +| Container Detection | Trailing slash `/` | Link header `rel="type"` | + +**Example - LWS Mode:** +```bash +# PUT fails for non-existent resource +curl -X PUT http://localhost:3000/alice/public/new.json \ + -H "Content-Type: application/json" \ + -d '{"test": true}' +# 404: Use POST to create resources + +# POST required for creation +curl -X POST http://localhost:3000/alice/public/ \ + -H "Content-Type: application/json" \ + -H "Slug: new-resource" \ + -d '{"test": true}' +# 201 Created + +# PUT works for updates +curl -X PUT http://localhost:3000/alice/public/new-resource.json \ + -H "Content-Type: application/json" \ + -d '{"test": "updated"}' +# 204 No Content +``` + +**Status:** Early draft implementation. LWS spec is evolving - monitor ecosystem adoption before production use. + ### Profile Pages Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using: diff --git a/bin/jss.js b/bin/jss.js index d55f90d..7c584b5 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -47,6 +47,8 @@ program .option('--no-conneg', 'Disable content negotiation') .option('--notifications', 'Enable WebSocket notifications') .option('--no-notifications', 'Disable WebSocket notifications') + .option('--lws-mode', 'Enable LWS protocol mode (DRAFT - see issue #87)') + .option('--no-lws-mode', 'Use Solid/LDP protocol mode (default)') .option('--idp', 'Enable built-in Identity Provider') .option('--no-idp', 'Disable built-in Identity Provider') .option('--idp-issuer ', 'IdP issuer URL (defaults to server URL)') diff --git a/src/config.js b/src/config.js index 84c055d..d400b5b 100644 --- a/src/config.js +++ b/src/config.js @@ -28,6 +28,7 @@ export const defaults = { multiuser: true, conneg: false, notifications: false, + lwsMode: false, // LWS protocol mode (draft) // Identity Provider idp: false, @@ -89,6 +90,7 @@ const envMap = { JSS_MULTIUSER: 'multiuser', JSS_CONNEG: 'conneg', JSS_NOTIFICATIONS: 'notifications', + JSS_LWS_MODE: 'lwsMode', JSS_QUIET: 'quiet', JSS_CONFIG_PATH: 'configPath', JSS_IDP: 'idp', @@ -259,6 +261,7 @@ export function printConfig(config) { console.log(` Multi-user: ${config.multiuser}`); console.log(` Conneg: ${config.conneg}`); console.log(` Notifications: ${config.notifications}`); + console.log(` LWS Mode: ${config.lwsMode ? 'enabled (DRAFT)' : 'disabled'}`); console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`); console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`); console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`); diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 5353612..0a5912d 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -513,6 +513,7 @@ export async function handleHead(request, reply) { export async function handlePut(request, reply) { const { urlPath, storagePath, resourceUrl } = getRequestPaths(request); const connegEnabled = request.connegEnabled || false; + const lwsMode = request.lwsMode || false; // Handle container creation via PUT if (isContainer(urlPath)) { @@ -540,6 +541,17 @@ export async function handlePut(request, reply) { return reply.code(201).send(); } + // LWS Mode: PUT is for updates only, not creation + // Check if resource exists first + const stats = await storage.stat(storagePath); + + if (lwsMode && !stats) { + return reply.code(404).send({ + error: 'Resource not found', + message: 'LWS mode: Use POST to create resources. PUT is for updates only.' + }); + } + const contentType = request.headers['content-type'] || ''; // Check if we can accept this input type @@ -552,8 +564,7 @@ export async function handlePut(request, reply) { }); } - // Check if resource already exists and get current ETag - const stats = await storage.stat(storagePath); + // stats already fetched above for LWS check const existed = stats !== null; const currentEtag = stats?.etag || null; diff --git a/src/server.js b/src/server.js index 125ab26..e20351a 100644 --- a/src/server.js +++ b/src/server.js @@ -44,6 +44,8 @@ export function createServer(options = {}) { const connegEnabled = options.conneg ?? false; // WebSocket notifications are OFF by default const notificationsEnabled = options.notifications ?? false; + // LWS protocol mode is OFF by default - use Solid/LDP semantics + const lwsMode = options.lwsMode ?? false; // Identity Provider is OFF by default const idpEnabled = options.idp ?? false; const idpIssuer = options.idpIssuer; @@ -122,6 +124,7 @@ export function createServer(options = {}) { // Attach server config to requests fastify.decorateRequest('connegEnabled', null); fastify.decorateRequest('notificationsEnabled', null); + fastify.decorateRequest('lwsMode', null); fastify.decorateRequest('idpEnabled', null); fastify.decorateRequest('subdomainsEnabled', null); fastify.decorateRequest('baseDomain', null); @@ -134,6 +137,7 @@ export function createServer(options = {}) { fastify.addHook('onRequest', async (request) => { request.connegEnabled = connegEnabled; request.notificationsEnabled = notificationsEnabled; + request.lwsMode = lwsMode; request.idpEnabled = idpEnabled; request.subdomainsEnabled = subdomainsEnabled; request.baseDomain = baseDomain; diff --git a/test/lws.test.js b/test/lws.test.js new file mode 100644 index 0000000..a84f798 --- /dev/null +++ b/test/lws.test.js @@ -0,0 +1,127 @@ +/** + * LWS Protocol Mode Tests (DRAFT) + * + * Tests for W3C Linked Web Storage protocol semantics. + * See issue #87 for full specification and implementation plan. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { + startTestServer, + stopTestServer, + request, + createTestPod, + assertStatus +} from './helpers.js'; + +describe('LWS Protocol Mode (DRAFT)', () => { + let baseUrl; + + before(async () => { + // Start server with LWS mode enabled + const result = await startTestServer({ lwsMode: true }); + baseUrl = result.baseUrl; + await createTestPod('lwstest'); + }); + + after(async () => { + await stopTestServer(); + }); + + describe('PUT Semantics (Updates Only)', () => { + it('should reject PUT for non-existent resource in LWS mode', async () => { + const res = await request('/lwstest/public/new-resource.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test: true }), + auth: 'lwstest' + }); + + assertStatus(res, 404); + const body = await res.json(); + assert.ok(body.message.includes('POST to create'), 'Error should suggest POST'); + }); + + it('should allow PUT to update existing resource in LWS mode', async () => { + // First create via POST + const createRes = await request('/lwstest/public/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Slug': 'existing-resource' + }, + body: JSON.stringify({ version: 1 }), + auth: 'lwstest' + }); + + assertStatus(createRes, 201); + const location = createRes.headers.get('Location'); + + // Now update via PUT (should work) + const updateRes = await request(location, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: 2 }), + auth: 'lwstest' + }); + + assertStatus(updateRes, 204); + + // Verify updated + const verify = await request(location); + const data = await verify.json(); + assert.strictEqual(data.version, 2); + }); + }); + + describe('POST Creation (LWS Standard)', () => { + it('should create resource via POST with Slug header', async () => { + const res = await request('/lwstest/public/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Slug': 'lws-created' + }, + body: JSON.stringify({ created: 'via POST' }), + auth: 'lwstest' + }); + + assertStatus(res, 201); + const location = res.headers.get('Location'); + assert.ok(location, 'Should return Location header'); + assert.ok(location.includes('lws-created'), 'Should use Slug in filename'); + }); + + it('should create container via POST with Link header', async () => { + const res = await request('/lwstest/public/', { + method: 'POST', + headers: { + 'Slug': 'lws-container', + 'Link': '; rel="type"' + }, + auth: 'lwstest' + }); + + assertStatus(res, 201); + const location = res.headers.get('Location'); + assert.ok(location.endsWith('/'), 'Container should end with /'); + }); + }); + + describe('Documentation', () => { + it('should document LWS mode differences', () => { + // This test serves as documentation + const differences = { + PUT: 'Updates only (404 if not exists)', + POST: 'Required for creation', + containerDetection: 'Link header (future: remove slash semantics)', + metadataUpdates: 'Future: JSON Merge Patch on linkset resources', + etagRequirement: 'Mandatory (already implemented)' + }; + + // Test demonstrates PUT restriction is implemented + assert.ok(differences.PUT.includes('404'), 'PUT rejects non-existent'); + }); + }); +}); From 0faadb36dfc0b8513f43c6c1024a8fbabbb89b6d Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Wed, 14 Jan 2026 15:57:32 +0100 Subject: [PATCH 2/2] fix: remove PUT restriction in LWS mode Per Eric's clarification on Solid CG call, LWS protocol allows PUT for resource creation - it's not forbidden. POST with Slug is the emphasized pattern, but PUT creation is still valid. Changes: - Remove PUT 404 rejection in LWS mode (src/handlers/resource.js) - Update tests to verify PUT creation works (test/lws.test.js) - Update README to reflect correct LWS semantics - Document that current LWS mode is infrastructure-only LWS mode now primarily serves as foundation for future features: - Link header-based container detection - Linkset metadata endpoints - JSON Merge Patch for metadata Related: #87 (comment added with CG clarification) --- README.md | 34 ++++++++++++++++++---------------- src/handlers/resource.js | 15 ++------------- test/lws.test.js | 29 +++++++++++++++++------------ 3 files changed, 37 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index a22994c..e88e7e1 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [sol ⚠️ **Experimental Feature** - See [issue #87](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/87) for full details. -Enable W3C Linked Web Storage protocol semantics (alternative to Solid/LDP): +Enable W3C Linked Web Storage protocol semantics: ```bash jss start --lws-mode @@ -365,33 +365,35 @@ jss start --lws-mode | Aspect | Solid/LDP (Default) | LWS Mode | |--------|-------------------|----------| -| Resource Creation | PUT or POST | POST only | -| PUT Semantics | Create or update | Update only (404 if not exists) | -| Container Detection | Trailing slash `/` | Link header `rel="type"` | +| Resource Creation | PUT or POST | PUT or POST (POST+Slug emphasized) | +| Container Detection | Trailing slash `/` | Link header `rel="type"` (planned) | +| Metadata Updates | N3 Patch, SPARQL | JSON Merge Patch on linkset (planned) | -**Example - LWS Mode:** +**Current Status:** + +Currently, `--lws-mode` is primarily infrastructure. The PUT/POST semantics are **the same** as Solid/LDP (PUT can create or update). + +Future LWS-specific features (when implemented): +- Link header-based container detection (alternative to trailing slash) +- Linkset metadata endpoints (`/resource;linkset`) +- JSON Merge Patch for metadata updates + +**Example:** ```bash -# PUT fails for non-existent resource +# Both work in LWS mode (same as default) curl -X PUT http://localhost:3000/alice/public/new.json \ -H "Content-Type: application/json" \ -d '{"test": true}' -# 404: Use POST to create resources +# 201 Created -# POST required for creation curl -X POST http://localhost:3000/alice/public/ \ -H "Content-Type: application/json" \ - -H "Slug: new-resource" \ + -H "Slug: another-resource" \ -d '{"test": true}' # 201 Created - -# PUT works for updates -curl -X PUT http://localhost:3000/alice/public/new-resource.json \ - -H "Content-Type: application/json" \ - -d '{"test": "updated"}' -# 204 No Content ``` -**Status:** Early draft implementation. LWS spec is evolving - monitor ecosystem adoption before production use. +**Status:** Early draft implementation. LWS spec is evolving - monitor ecosystem adoption before production use. Per Solid CG clarification, PUT creation is allowed in LWS. ### Profile Pages diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 0a5912d..5353612 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -513,7 +513,6 @@ export async function handleHead(request, reply) { export async function handlePut(request, reply) { const { urlPath, storagePath, resourceUrl } = getRequestPaths(request); const connegEnabled = request.connegEnabled || false; - const lwsMode = request.lwsMode || false; // Handle container creation via PUT if (isContainer(urlPath)) { @@ -541,17 +540,6 @@ export async function handlePut(request, reply) { return reply.code(201).send(); } - // LWS Mode: PUT is for updates only, not creation - // Check if resource exists first - const stats = await storage.stat(storagePath); - - if (lwsMode && !stats) { - return reply.code(404).send({ - error: 'Resource not found', - message: 'LWS mode: Use POST to create resources. PUT is for updates only.' - }); - } - const contentType = request.headers['content-type'] || ''; // Check if we can accept this input type @@ -564,7 +552,8 @@ export async function handlePut(request, reply) { }); } - // stats already fetched above for LWS check + // Check if resource already exists and get current ETag + const stats = await storage.stat(storagePath); const existed = stats !== null; const currentEtag = stats?.etag || null; diff --git a/test/lws.test.js b/test/lws.test.js index a84f798..ae5c597 100644 --- a/test/lws.test.js +++ b/test/lws.test.js @@ -29,18 +29,22 @@ describe('LWS Protocol Mode (DRAFT)', () => { await stopTestServer(); }); - describe('PUT Semantics (Updates Only)', () => { - it('should reject PUT for non-existent resource in LWS mode', async () => { - const res = await request('/lwstest/public/new-resource.json', { + describe('PUT Semantics (Creation and Updates)', () => { + it('should allow PUT for resource creation in LWS mode', async () => { + // LWS allows PUT for creation (clarified by Eric on Solid CG call) + const res = await request('/lwstest/public/new-via-put.json', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ test: true }), + body: JSON.stringify({ created: 'via PUT' }), auth: 'lwstest' }); - assertStatus(res, 404); - const body = await res.json(); - assert.ok(body.message.includes('POST to create'), 'Error should suggest POST'); + assertStatus(res, 201); + + // Verify created + const verify = await request('/lwstest/public/new-via-put.json'); + const data = await verify.json(); + assert.strictEqual(data.created, 'via PUT'); }); it('should allow PUT to update existing resource in LWS mode', async () => { @@ -113,15 +117,16 @@ describe('LWS Protocol Mode (DRAFT)', () => { it('should document LWS mode differences', () => { // This test serves as documentation const differences = { - PUT: 'Updates only (404 if not exists)', - POST: 'Required for creation', - containerDetection: 'Link header (future: remove slash semantics)', + PUT: 'Allowed for creation and updates (same as LDP)', + POST: 'Emphasized pattern with Slug for server-assigned URIs', + containerDetection: 'Link header (future: may remove slash semantics)', metadataUpdates: 'Future: JSON Merge Patch on linkset resources', etagRequirement: 'Mandatory (already implemented)' }; - // Test demonstrates PUT restriction is implemented - assert.ok(differences.PUT.includes('404'), 'PUT rejects non-existent'); + // Note: Per Eric on Solid CG call, LWS allows PUT creation + // The main difference is POST+Slug is the emphasized pattern + assert.ok(differences.POST.includes('Slug'), 'POST with Slug emphasized'); }); }); });