From 25dc3cfd182cc3bca0a66ca770d4973554c46791 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 28 Apr 2026 16:51:51 +0200 Subject: [PATCH 01/18] fix --conneg issue ACl --- src/rdf/turtle.js | 16 ++++--- test/conneg.test.js | 107 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/rdf/turtle.js b/src/rdf/turtle.js index e5d1b89..81fd42e 100644 --- a/src/rdf/turtle.js +++ b/src/rdf/turtle.js @@ -181,8 +181,13 @@ function jsonLdToQuads(jsonLd, baseUri) { if (doc['@context']) { mergedContext = { ...mergedContext, ...doc['@context'] }; } - // Each document with @id is a node (no @graph needed) - if (doc['@id']) { + if (doc['@graph']) { + // JSON-LD @graph container (e.g. ACL files produced by serializeAcl) + // The @context is already merged above so prefix expansion will work. + for (const node of doc['@graph']) { + if (node['@id']) nodes.push(node); + } + } else if (doc['@id']) { nodes.push(doc); } } @@ -301,7 +306,7 @@ function valueToTerm(value, baseUri, context, isIdType = false) { if (typeof value === 'string') { // If context says this should be a URI, treat it as a named node if (isIdType) { - const uri = resolveUri(value, baseUri); + const uri = resolveUri(expandUri(value, context), baseUri); return namedNode(uri); } return literal(value); @@ -318,9 +323,10 @@ function valueToTerm(value, baseUri, context, isIdType = false) { // Object values if (typeof value === 'object') { - // @id reference + // @id reference — expand CURIEs (e.g. "acl:Read") before resolving if (value['@id']) { - const uri = resolveUri(value['@id'], baseUri); + const expanded = expandUri(value['@id'], context); + const uri = resolveUri(expanded, baseUri); return uri.startsWith('_:') ? blankNode(uri.slice(2)) : namedNode(uri); diff --git a/test/conneg.test.js b/test/conneg.test.js index 51468de..50a8f82 100644 --- a/test/conneg.test.js +++ b/test/conneg.test.js @@ -12,16 +12,19 @@ import { stopTestServer, request, createTestPod, + getBaseUrl, assertStatus, assertHeader, assertHeaderContains } from './helpers.js'; +let connegPod; + describe('Content Negotiation (conneg enabled)', () => { before(async () => { // Start server with conneg ENABLED await startTestServer({ conneg: true }); - await createTestPod('connegtest'); + connegPod = await createTestPod('connegtest'); }); after(async () => { @@ -194,6 +197,108 @@ describe('Content Negotiation (conneg enabled)', () => { 'Accept-Post should include text/turtle'); }); }); + + describe('ACL conneg (#327 regression)', () => { + before(async () => { + const baseUrl = getBaseUrl(); + const acl = { + '@context': { + 'acl': 'http://www.w3.org/ns/auth/acl#', + 'foaf': 'http://xmlns.com/foaf/0.1/' + }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { + '@id': connegPod.webId + }, + 'acl:accessTo': { + '@id': `${baseUrl}/connegtest/` + }, + 'acl:mode': [ + { + '@id': 'acl:Read' + }, + { + '@id': 'acl:Write' + }, + { + '@id': 'acl:Control' + } + ], + 'acl:default': { + '@id': `${baseUrl}/connegtest/` + } + }, + { + '@id': '#public', + '@type': 'acl:Authorization', + 'acl:agentClass': { + '@id': 'foaf:Agent' + }, + 'acl:accessTo': { + '@id': `${baseUrl}/connegtest/` + }, + 'acl:mode': [ + { + '@id': 'acl:Read' + } + ] + } + ] + }; + + const putRes = await request('/connegtest/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'connegtest' + }); + + assert.ok(putRes.status < 300, `PUT .acl should succeed, got ${putRes.status}`); + }); + + it('should return Turtle for .acl files with Accept: text/turtle', async () => { + const res = await request('/connegtest/.acl', { + headers: { 'Accept': 'text/turtle' }, + auth: 'connegtest' + }); + + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'text/turtle'); + + const turtle = await res.text(); + + assert.ok(turtle.includes('acl:'), 'Should have acl: prefix'); + assert.ok(turtle.includes('Authorization'), 'Should have Authorization nodes'); + assert.ok(turtle.includes('accessTo') || turtle.includes('acl:accessTo'), + 'Should have acl:accessTo predicate'); + assert.ok(turtle.includes('mode') || turtle.includes('acl:mode'), + 'Should have acl:mode predicate'); + assert.ok(turtle.includes('foaf:Agent') || turtle.includes('http://xmlns.com/foaf/0.1/Agent'), + 'Should preserve foaf:Agent CURIE/object @id values'); + }); + + it('should return JSON-LD for .acl with Accept: application/ld+json', async () => { + const res = await request('/connegtest/.acl', { + headers: { 'Accept': 'application/ld+json' }, + auth: 'connegtest' + }); + + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'application/ld+json'); + + const data = await res.json(); + assert.ok(data['@graph'], 'ACL should have @graph'); + assert.ok(Array.isArray(data['@graph']), '@graph should be array'); + assert.ok(data['@graph'].length > 0, '@graph should have authorization nodes'); + assert.ok(data['@graph'].some(node => + node['@type']?.includes('Authorization') || + node['@type'] === 'acl:Authorization' + ), 'Should have Authorization nodes'); + }); + }); }); describe('Content Negotiation (conneg disabled - default)', () => { From 4e1b2ce50bcdd187687cec8dc1229133d8a08ff4 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 28 Apr 2026 17:59:51 +0200 Subject: [PATCH 02/18] update test/conneg.test.js --- test/conneg.test.js | 132 +++++++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 64 deletions(-) diff --git a/test/conneg.test.js b/test/conneg.test.js index a566026..a31918a 100644 --- a/test/conneg.test.js +++ b/test/conneg.test.js @@ -198,6 +198,73 @@ describe('Content Negotiation (conneg enabled)', () => { }); }); + // Regression coverage for #294 — Solid convention dotfiles (.acl, .meta) + // were excluded from conneg because getContentType() returned + // application/octet-stream for them. Turtle-native clients (umai etc.) + // fetching /.meta got JSON-LD back and errored on parse. + describe('Solid convention dotfiles (#294)', () => { + const metaData = { + '@context': { 'ldp': 'http://www.w3.org/ns/ldp#' }, + '@id': '', + '@type': 'ldp:BasicContainer' + }; + + before(async () => { + // Write a JSON-LD .meta file (the format JSS writes internally). + await request('/connegtest/public/.meta', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(metaData), + auth: 'connegtest' + }); + }); + + it('serves .meta as JSON-LD by default', async () => { + const res = await request('/connegtest/public/.meta', { auth: 'connegtest' }); + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'application/ld+json'); + }); + + it('serves .meta as Turtle when Accept: text/turtle (the umai case)', async () => { + const res = await request('/connegtest/public/.meta', { + headers: { 'Accept': 'text/turtle' }, + auth: 'connegtest' + }); + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'text/turtle'); + const turtle = await res.text(); + // First byte after the `@prefix` block must parse as Turtle, + // not '{' (the bug signature umai hit). + assert.ok(!turtle.trimStart().startsWith('{'), + `response looks like JSON, not Turtle: ${turtle.slice(0, 60)}`); + }); + + it('accepts Turtle PUT to .meta and round-trips to JSON-LD', async () => { + const turtle = ` + @prefix ldp: . + <> a ldp:BasicContainer. + `; + const putRes = await request('/connegtest/public/.meta', { + method: 'PUT', + headers: { 'Content-Type': 'text/turtle' }, + body: turtle, + auth: 'connegtest' + }); + assert.ok(putRes.status < 300, `PUT turtle should succeed, got ${putRes.status}`); + + // Default GET now serves the converted-and-stored JSON-LD. + const getRes = await request('/connegtest/public/.meta', { + headers: { 'Accept': 'application/ld+json' }, + auth: 'connegtest' + }); + assertStatus(getRes, 200); + assertHeaderContains(getRes, 'Content-Type', 'application/ld+json'); + const body = await getRes.json(); + assert.ok(body['@context'] || body['@graph'] || body['@type'] || body['@id'], + 'round-tripped JSON-LD should have at least one @-keyword'); + }); + }); + describe('ACL conneg (#327 regression)', () => { before(async () => { const baseUrl = getBaseUrl(); @@ -297,70 +364,6 @@ describe('Content Negotiation (conneg enabled)', () => { node['@type']?.includes('Authorization') || node['@type'] === 'acl:Authorization' ), 'Should have Authorization nodes'); - // Regression coverage for #294 — Solid convention dotfiles (.acl, .meta) - // were excluded from conneg because getContentType() returned - // application/octet-stream for them. Turtle-native clients (umai etc.) - // fetching /.meta got JSON-LD back and errored on parse. - describe('Solid convention dotfiles (#294)', () => { - const metaData = { - '@context': { 'ldp': 'http://www.w3.org/ns/ldp#' }, - '@id': '', - '@type': 'ldp:BasicContainer' - }; - - before(async () => { - // Write a JSON-LD .meta file (the format JSS writes internally). - await request('/connegtest/public/.meta', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(metaData), - auth: 'connegtest' - }); - }); - - it('serves .meta as JSON-LD by default', async () => { - const res = await request('/connegtest/public/.meta', { auth: 'connegtest' }); - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'application/ld+json'); - }); - - it('serves .meta as Turtle when Accept: text/turtle (the umai case)', async () => { - const res = await request('/connegtest/public/.meta', { - headers: { 'Accept': 'text/turtle' }, - auth: 'connegtest' - }); - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'text/turtle'); - const turtle = await res.text(); - // First byte after the `@prefix` block must parse as Turtle, - // not '{' (the bug signature umai hit). - assert.ok(!turtle.trimStart().startsWith('{'), - `response looks like JSON, not Turtle: ${turtle.slice(0, 60)}`); - }); - - it('accepts Turtle PUT to .meta and round-trips to JSON-LD', async () => { - const turtle = ` - @prefix ldp: . - <> a ldp:BasicContainer. - `; - const putRes = await request('/connegtest/public/.meta', { - method: 'PUT', - headers: { 'Content-Type': 'text/turtle' }, - body: turtle, - auth: 'connegtest' - }); - assert.ok(putRes.status < 300, `PUT turtle should succeed, got ${putRes.status}`); - - // Default GET now serves the converted-and-stored JSON-LD. - const getRes = await request('/connegtest/public/.meta', { - headers: { 'Accept': 'application/ld+json' }, - auth: 'connegtest' - }); - assertStatus(getRes, 200); - assertHeaderContains(getRes, 'Content-Type', 'application/ld+json'); - const body = await getRes.json(); - assert.ok(body['@context'] || body['@graph'] || body['@type'] || body['@id'], - 'round-tripped JSON-LD should have at least one @-keyword'); }); }); }); @@ -548,3 +551,4 @@ describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => { }); }); }); + From 103cf313de5457bc5a8f2a788646ee16f8a4d4eb Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 29 Apr 2026 11:25:43 +0200 Subject: [PATCH 03/18] add WAC PATCH Append --- src/auth/middleware.js | 40 +++++++++++- test/auth.test.js | 142 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 31dee4d..399f44a 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -7,6 +7,8 @@ import { getWebIdFromRequestAsync } from './token.js'; import { checkAccess, getRequiredMode } from '../wac/checker.js'; import { AccessMode } from '../wac/parser.js'; +import { parseN3Patch } from '../patch/n3-patch.js'; +import { parseSparqlUpdate } from '../patch/sparql-update.js'; import * as storage from '../storage/filesystem.js'; import { getEffectiveUrlPath } from '../utils/url.js'; import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js'; @@ -91,7 +93,13 @@ export async function authorize(request, reply, options = {}) { const resourceUrl = buildResourceUrl(request, urlPath); // Get required access mode - use override if provided, otherwise derive from method - const requiredMode = options.requiredMode || getRequiredMode(method); + let requiredMode = options.requiredMode || getRequiredMode(method); + + // PATCH can be authorized as Append when it is insert-only. + // Any delete operation (or parse ambiguity) stays Write. + if (!options.requiredMode && method === 'PATCH') { + requiredMode = getPatchRequiredMode(request, resourceUrl); + } // For write operations on non-existent resources, check parent container let checkPath = storagePath; @@ -120,6 +128,36 @@ export async function authorize(request, reply, options = {}) { return { authorized: allowed, webId, wacAllow, authError, paymentRequired, paid, balance, currency }; } +/** + * Determine PATCH required mode from patch payload semantics. + * Insert-only patches require Append; delete-capable patches require Write. + */ +function getPatchRequiredMode(request, baseUri) { + const contentType = (request.headers['content-type'] || '').toLowerCase(); + const rawBody = Buffer.isBuffer(request.body) ? request.body.toString() : request.body; + + if (typeof rawBody !== 'string') { + return AccessMode.WRITE; + } + + try { + if (contentType.includes('application/sparql-update')) { + const update = parseSparqlUpdate(rawBody, baseUri); + return update.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE; + } + + if (contentType.includes('text/n3') || contentType.includes('application/n3')) { + const patch = parseN3Patch(rawBody, baseUri); + return patch.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE; + } + } catch { + // Fail closed to Write when patch parsing is invalid/ambiguous. + return AccessMode.WRITE; + } + + return AccessMode.WRITE; +} + /** * Get parent container path */ diff --git a/test/auth.test.js b/test/auth.test.js index d34e760..cadd2c4 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -142,6 +142,148 @@ describe('Authentication', () => { assertStatus(res, 201); }); + it('should allow append-only PATCH for insert-only patch', async () => { + await createTestPod('appendpatch1'); + await createTestPod('appendwriter1'); + + const baseUrl = getBaseUrl(); + + // Create target resource and container first. + await request('/appendpatch1/public/item.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { ex: 'http://example.org/' }, + '@id': '#it', + 'ex:name': 'initial' + }), + auth: 'appendpatch1' + }); + + // Set container ACL: owner full control + authenticated append only. + const acl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/appendpatch1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + }, + { + '@id': '#authenticated-append', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Append' } + ] + } + ] + }; + + await request('/appendpatch1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'appendpatch1' + }); + + const insertOnlyPatch = ` + @prefix solid: . + @prefix ex: . + _:patch a solid:InsertDeletePatch; + solid:inserts { <#it> ex:added "yes" }. + `; + + const res = await request('/appendpatch1/public/item.json', { + method: 'PATCH', + headers: { 'Content-Type': 'text/n3' }, + body: insertOnlyPatch, + auth: 'appendwriter1' + }); + + assertStatus(res, 204); + }); + + it('should deny append-only PATCH when patch includes deletes', async () => { + await createTestPod('appendpatch2'); + await createTestPod('appendwriter2'); + + const baseUrl = getBaseUrl(); + + await request('/appendpatch2/public/item.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { ex: 'http://example.org/' }, + '@id': '#it', + 'ex:name': 'initial' + }), + auth: 'appendpatch2' + }); + + const acl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/appendpatch2/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + }, + { + '@id': '#authenticated-append', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Append' } + ] + } + ] + }; + + await request('/appendpatch2/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'appendpatch2' + }); + + const deletePatch = ` + @prefix solid: . + @prefix ex: . + _:patch a solid:InsertDeletePatch; + solid:deletes { <#it> ex:name "initial" }. + `; + + const res = await request('/appendpatch2/public/item.json', { + method: 'PATCH', + headers: { 'Content-Type': 'text/n3' }, + body: deletePatch, + auth: 'appendwriter2' + }); + + assertStatus(res, 403); + }); + it('should deny public read on inbox', async () => { await createTestPod('inboxread'); From 5c884505c92878d7758dd2fe8866b25f7122d99f Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 29 Apr 2026 19:30:36 +0200 Subject: [PATCH 04/18] update databrowser --- src/mashlib/index.js | 238 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 216 insertions(+), 22 deletions(-) diff --git a/src/mashlib/index.js b/src/mashlib/index.js index c1defe2..33b9bc0 100644 --- a/src/mashlib/index.js +++ b/src/mashlib/index.js @@ -6,38 +6,232 @@ * we return this wrapper which then fetches and renders the data. */ -/** - * Generate Mashlib databrowser HTML - * - * @param {string} resourceUrl - The URL of the resource being viewed (unused, kept for API compatibility) - * @param {string} cdnVersion - If provided, load mashlib from unpkg CDN (e.g., "2.0.0") - * @returns {string} HTML content - */ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null) { if (cdnVersion) { - // CDN mode - use script.onload to ensure mashlib is fully loaded before init - // This avoids race conditions with defer + DOMContentLoaded + // CDN mode: load the matching mashlib databrowser shell template from CDN, + // then load CSS/JS from the same version while staying on this origin. const cdnBase = `https://unpkg.com/mashlib@${cdnVersion}/dist`; - return `SolidOS Web App - - -
-
+ return `SolidOS Web App +

Loading Mashlib…

`; } // Local mode - use defer (reliable when served locally) - return `SolidOS Web App
`; + return `SolidOS Web App
`; } /** From 8a0593ea28b7a61190d3272c486d76bc1d1fa0c5 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 30 Apr 2026 21:34:57 +0200 Subject: [PATCH 05/18] change user --- src/idp/index.js | 6 +++ src/idp/interactions.js | 115 ++++++++++++++++------------------------ src/idp/views.js | 2 + 3 files changed, 54 insertions(+), 69 deletions(-) diff --git a/src/idp/index.js b/src/idp/index.js index c5ba22c..0250ea6 100644 --- a/src/idp/index.js +++ b/src/idp/index.js @@ -11,6 +11,7 @@ import { handleLogin, handleConsent, handleAbort, + handleRelogin, handleRegisterGet, handleRegisterPost, handlePasskeyComplete, @@ -310,6 +311,11 @@ export async function idpPlugin(fastify, options) { return handleAbort(request, reply, provider); }); + // GET relogin - switch to a different account before consent + fastify.get('/idp/interaction/:uid/relogin', async (request, reply) => { + return handleRelogin(request, reply, provider); + }); + // Registration routes (disabled in single-user mode) if (singleUser) { // Single-user mode: registration disabled diff --git a/src/idp/interactions.js b/src/idp/interactions.js index 6ed20e5..f43c98d 100644 --- a/src/idp/interactions.js +++ b/src/idp/interactions.js @@ -28,8 +28,8 @@ export async function handleInteractionGet(request, reply, provider) { const { prompt, params, session } = interaction; - // If we need login - if (prompt.name === 'login') { + // If we need login, or consent has no account bound (after relogin) + if (prompt.name === 'login' || (prompt.name === 'consent' && !session?.accountId)) { return reply.type('text/html').send(loginPage(uid, params.client_id, interaction.lastError)); } @@ -155,81 +155,27 @@ export async function handleLogin(request, reply, provider) { }, }; - // Save the login result to the interaction + // Save the login result to the interaction. + // The result is stored here; oidc-provider will read it when we redirect + // back to the authorization endpoint via returnTo. + const returnTo = interaction.returnTo; interaction.result = result; await interaction.save(interaction.exp - Math.floor(Date.now() / 1000)); - // For browsers (mashlib, etc): do a proper HTTP redirect + // For browsers: redirect to the authorization endpoint so oidc-provider + // can complete the flow. Avoids reply.hijack() + interactionFinished() + // which could hang the response if the provider throws internally. if (wantsBrowserRedirect) { - reply.hijack(); - return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false }); + return reply.redirect(returnTo); } // For CTH and programmatic clients: return JSON with location // CTH expects a 200 response with "location" in body (CSS v3+ style) - try { - reply.hijack(); - - // Create a mock response that captures the redirect and returns JSON - let capturedLocation = null; - let headersSent = false; - const mockRes = { - statusCode: 200, - headersSent: false, - setHeader: (name, value) => { - if (name.toLowerCase() === 'location') { - capturedLocation = value; - } - return mockRes; - }, - getHeader: (name) => { - if (name.toLowerCase() === 'location') return capturedLocation; - return undefined; - }, - removeHeader: () => mockRes, - writeHead: (status, headers) => { - if (headers) { - if (typeof headers === 'object' && !Array.isArray(headers)) { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'location') { - capturedLocation = value; - } - } - } - } - return mockRes; - }, - write: () => mockRes, - end: (body) => { - if (!headersSent) { - headersSent = true; - const location = capturedLocation || `/idp/auth/${uid}`; - reply.raw.writeHead(200, { - 'Content-Type': 'application/json', - 'Location': location, - }); - reply.raw.end(JSON.stringify({ location })); - } - }, - finished: false, - on: () => mockRes, - once: () => mockRes, - emit: () => mockRes, - }; - - await provider.interactionFinished(request.raw, mockRes, result, { mergeWithLastSubmission: false }); - return; - } catch (err) { - request.log.warn({ err: err.message, errName: err.name, uid }, 'interactionFinished failed, using fallback'); - - // Fallback: return the redirect URL for manual following - const redirectTo = `/idp/auth/${uid}`; - return reply - .code(200) - .header('Location', redirectTo) - .type('application/json') - .send({ location: redirectTo }); - } + return reply + .code(200) + .header('Location', returnTo) + .type('application/json') + .send({ location: returnTo }); } catch (err) { request.log.error(err, 'Login error'); return reply.code(500).type('text/html').send(errorPage('Login failed', err.message)); @@ -325,6 +271,37 @@ export async function handleAbort(request, reply, provider) { } } +/** + * Handle GET /idp/interaction/:uid/relogin + * Clears the selected account for this interaction to allow switching WebID. + */ +export async function handleRelogin(request, reply, provider) { + const { uid } = request.params; + + try { + const interaction = await provider.Interaction.find(uid); + if (!interaction) { + return reply.code(404).type('text/html').send(errorPage('Interaction not found', 'This login session has expired. Please try again.')); + } + + if (interaction.session && interaction.session.accountId) { + delete interaction.session.accountId; + } + + if (interaction.result && interaction.result.login) { + delete interaction.result.login; + } + + interaction.lastError = null; + await interaction.save(interaction.exp - Math.floor(Date.now() / 1000)); + + return reply.redirect(`/idp/interaction/${uid}`); + } catch (err) { + request.log.error(err, 'Relogin error'); + return reply.code(500).type('text/html').send(errorPage('Error', err.message)); + } +} + /** * Handle GET /idp/register * Shows registration page diff --git a/src/idp/views.js b/src/idp/views.js index 6cc96e1..3c80d15 100644 --- a/src/idp/views.js +++ b/src/idp/views.js @@ -515,6 +515,8 @@ export function consentPage(uid, client, params, account) { + Sign in as different user +
From 699422361b6b348f00d69855aa40735168d9c9ea Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 30 Apr 2026 21:42:48 +0200 Subject: [PATCH 06/18] add port to baseDomain --- src/auth/middleware.js | 4 ++-- src/handlers/container.js | 3 ++- src/server.js | 9 +++++---- src/utils/url.js | 27 +++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 916b059..6469cf2 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -10,7 +10,7 @@ import { AccessMode } from '../wac/parser.js'; import { parseN3Patch } from '../patch/n3-patch.js'; import { parseSparqlUpdate } from '../patch/sparql-update.js'; import * as storage from '../storage/filesystem.js'; -import { getEffectiveUrlPath } from '../utils/url.js'; +import { getEffectiveUrlPath, getBaseDomainHost } from '../utils/url.js'; import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js'; /** @@ -29,7 +29,7 @@ export function buildResourceUrl(request, urlPath) { // Use request.headers.host (includes port) instead of request.hostname (strips port) const host = request.headers.host || request.hostname; if (request.subdomainsEnabled && request.baseDomain && - request.hostname === request.baseDomain && !request.podName) { + request.hostname === getBaseDomainHost(request.baseDomain) && !request.podName) { const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/); // Treat a path segment as a pod name only if it looks like one: // - not a dotfile (.well-known, .acl, .meta, ...) diff --git a/src/handlers/container.js b/src/handlers/container.js index e252be6..a9a8b77 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -282,7 +282,8 @@ export async function handleCreatePod(request, reply) { let baseUri, podUri, webId; if (subdomainsEnabled && baseDomain) { - // Subdomain mode: alice.example.com/profile/card.jsonld#me + // Subdomain mode: alice.example.com:port/profile/card.jsonld#me + // baseDomain may include port (e.g. "example.com:3100") const podHost = `${name}.${baseDomain}`; baseUri = `${request.protocol}://${baseDomain}`; podUri = `${request.protocol}://${podHost}/`; diff --git a/src/server.js b/src/server.js index 56dfd9c..d06100d 100644 --- a/src/server.js +++ b/src/server.js @@ -205,11 +205,12 @@ export function createServer(options = {}) { // Extract pod name from subdomain if enabled if (subdomainsEnabled && baseDomain) { - const host = request.hostname; - // Check if host is a subdomain of baseDomain - if (host !== baseDomain && host.endsWith('.' + baseDomain)) { + const host = request.hostname; // hostname never includes port + const baseDomainHost = baseDomain.includes(':') ? baseDomain.slice(0, baseDomain.lastIndexOf(':')) : baseDomain; + // Check if host is a subdomain of baseDomain (hostname part only) + if (host !== baseDomainHost && host.endsWith('.' + baseDomainHost)) { // Extract subdomain (e.g., "alice.example.com" -> "alice") - const subdomain = host.slice(0, -(baseDomain.length + 1)); + const subdomain = host.slice(0, -(baseDomainHost.length + 1)); // Only single-level subdomains (no dots) if (!subdomain.includes('.')) { request.podName = subdomain; diff --git a/src/utils/url.js b/src/utils/url.js index b4b4728..c6cd7dd 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -148,6 +148,33 @@ export function getResourceName(urlPath) { return parts[parts.length - 1]; } +/** + * Extract the hostname-only part of a baseDomain that may include a port. + * Used for routing comparisons against request.hostname (which never has port). + * + * Examples: + * 'example.com' → 'example.com' + * 'example.com:3100' → 'example.com' + * '[::1]:3100' → '[::1]' + * + * @param {string} baseDomain - The configured baseDomain (may include :port) + * @returns {string} + */ +export function getBaseDomainHost(baseDomain) { + if (!baseDomain) return baseDomain; + // Bracketed IPv6 + if (baseDomain.startsWith('[')) { + const end = baseDomain.indexOf(']'); + return end === -1 ? baseDomain : baseDomain.slice(0, end + 1); + } + const colon = baseDomain.lastIndexOf(':'); + if (colon === -1) return baseDomain; + // Only strip if what follows is a port number + const maybePort = baseDomain.slice(colon + 1); + return /^\d+$/.test(maybePort) ? baseDomain.slice(0, colon) : baseDomain; +} + + /** * Extract pod name from URL path or request * From b3f4d2bc03ba5275c969f2d6f692c1086dcc59b3 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 1 May 2026 13:04:47 +0200 Subject: [PATCH 07/18] update workflow change-login --- src/idp/interactions.js | 95 ++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/src/idp/interactions.js b/src/idp/interactions.js index f43c98d..4781da9 100644 --- a/src/idp/interactions.js +++ b/src/idp/interactions.js @@ -5,6 +5,7 @@ import { authenticate, findById, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js'; import { loginPage, consentPage, errorPage, registerPage, passkeyPromptPage } from './views.js'; +import { createAdapter } from './adapter.js'; import * as storage from '../storage/filesystem.js'; import { createPodStructure } from '../handlers/container.js'; import { validateInvite } from './invites.js'; @@ -13,6 +14,42 @@ import { verifyNostrAuth } from '../auth/nostr.js'; // Security: Maximum body size for IdP form submissions (1MB) const MAX_BODY_SIZE = 1024 * 1024; +async function finishInteractionOrError(request, reply, provider, result, options = { mergeWithLastSubmission: false }) { + try { + reply.hijack(); + await provider.interactionFinished(request.raw, reply.raw, result, options); + } catch (err) { + request.log.error({ err: err.message, uid: request.params?.uid }, 'interactionFinished failed'); + if (!reply.raw.writableEnded) { + reply.raw.statusCode = 500; + reply.raw.setHeader('Content-Type', 'text/html; charset=utf-8'); + reply.raw.end(errorPage('Error', 'Could not complete interaction. Please try again.')); + } + } +} + +async function clearInteractionSession(interaction) { + const sessionCookie = interaction.session?.cookie; + if (!sessionCookie) return; + + const sessionAdapter = createAdapter('Session'); + const sessionData = await sessionAdapter.find(sessionCookie); + if (!sessionData) return; + + delete sessionData.accountId; + delete sessionData.loginTs; + delete sessionData.amr; + delete sessionData.acr; + delete sessionData.transient; + sessionData.authorizations = {}; + + const expiresIn = sessionData._expiresAt + ? Math.max(1, Math.ceil((sessionData._expiresAt - Date.now()) / 1000)) + : undefined; + + await sessionAdapter.upsert(sessionCookie, sessionData, expiresIn); +} + /** * Handle GET /idp/interaction/:uid * Shows login or consent page based on interaction state @@ -156,17 +193,15 @@ export async function handleLogin(request, reply, provider) { }; // Save the login result to the interaction. - // The result is stored here; oidc-provider will read it when we redirect - // back to the authorization endpoint via returnTo. const returnTo = interaction.returnTo; interaction.result = result; await interaction.save(interaction.exp - Math.floor(Date.now() / 1000)); - // For browsers: redirect to the authorization endpoint so oidc-provider - // can complete the flow. Avoids reply.hijack() + interactionFinished() - // which could hang the response if the provider throws internally. + // For browsers: let oidc-provider finalize the interaction. + // This binds the authenticated account to the OIDC session. if (wantsBrowserRedirect) { - return reply.redirect(returnTo); + await finishInteractionOrError(request, reply, provider, result, { mergeWithLastSubmission: false }); + return; } // For CTH and programmatic clients: return JSON with location @@ -227,16 +262,8 @@ export async function handleConsent(request, reply, provider) { }, }; - // Mark reply as sent since interactionFinished will handle the response - reply.hijack(); - - // Use interactionFinished which handles the redirect directly - return provider.interactionFinished( - request.raw, - reply.raw, - result, - { mergeWithLastSubmission: true } - ); + await finishInteractionOrError(request, reply, provider, result, { mergeWithLastSubmission: true }); + return; } catch (err) { request.log.error(err, 'Consent error'); return reply.code(500).type('text/html').send(errorPage('Consent failed', err.message)); @@ -257,14 +284,8 @@ export async function handleAbort(request, reply, provider) { }; // oidc-provider is configured with /idp routes, so redirectTo will have correct path - const redirectTo = await provider.interactionResult( - request.raw, - reply.raw, - result, - { mergeWithLastSubmission: false } - ); - - return reply.redirect(redirectTo); + await finishInteractionOrError(request, reply, provider, result, { mergeWithLastSubmission: false }); + return; } catch (err) { request.log.error(err, 'Abort error'); return reply.code(500).type('text/html').send(errorPage('Error', err.message)); @@ -284,6 +305,8 @@ export async function handleRelogin(request, reply, provider) { return reply.code(404).type('text/html').send(errorPage('Interaction not found', 'This login session has expired. Please try again.')); } + await clearInteractionSession(interaction); + if (interaction.session && interaction.session.accountId) { delete interaction.session.accountId; } @@ -506,8 +529,8 @@ export async function handlePasskeyComplete(request, reply, provider) { request.log.info({ accountId: account.id, uid }, 'Passkey login completed'); - reply.hijack(); - return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false }); + await finishInteractionOrError(request, reply, provider, result, { mergeWithLastSubmission: false }); + return; } catch (err) { request.log.error(err, 'Passkey complete error'); return reply.code(500).type('text/html').send(errorPage('Error', err.message)); @@ -533,19 +556,23 @@ export async function handlePasskeySkip(request, reply, provider) { } // Get the pending login result - const result = interaction.result; - if (!result?.login?.accountId) { + const pending = interaction.result; + if (!pending?.login?.accountId) { return reply.code(400).type('text/html').send(errorPage('Invalid state', 'No pending login found.')); } // Mark passkey prompt as dismissed so we don't nag again - await setPasskeyPromptDismissed(result.login.accountId, true); + await setPasskeyPromptDismissed(pending.login.accountId, true); - request.log.info({ accountId: result.login.accountId, uid }, 'Passkey prompt skipped'); + request.log.info({ accountId: pending.login.accountId, uid }, 'Passkey prompt skipped'); // Complete the OIDC interaction - reply.hijack(); - return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false }); + const result = { + login: pending.login, + }; + + await finishInteractionOrError(request, reply, provider, result, { mergeWithLastSubmission: false }); + return; } catch (err) { request.log.error(err, 'Passkey skip error'); return reply.code(500).type('text/html').send(errorPage('Error', err.message)); @@ -661,8 +688,8 @@ export async function handleSchnorrComplete(request, reply, provider) { request.log.info({ accountId: account.id, uid }, 'Schnorr login completed'); - reply.hijack(); - return provider.interactionFinished(request.raw, reply.raw, interaction.result, { mergeWithLastSubmission: false }); + await finishInteractionOrError(request, reply, provider, interaction.result, { mergeWithLastSubmission: false }); + return; } catch (err) { request.log.error(err, 'Schnorr complete error'); return reply.code(500).type('text/html').send(errorPage('Error', err.message)); From f7054c5fac5f178dcbad629ed7b80a057871a204 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 2 May 2026 12:53:35 +0200 Subject: [PATCH 08/18] subdomain and change user --- src/auth/middleware.js | 12 +++++- src/auth/solid-oidc.js | 30 +++++++++++++++ src/idp/adapter.js | 11 +++++- src/idp/interactions.js | 30 ++++++++++++++- src/idp/provider.js | 1 + src/server.js | 7 +++- src/utils/url.js | 35 ++++++++++++----- test/adapter.test.js | 57 +++++++++++++++++++++++++++ test/solid-oidc.test.js | 64 +++++++++++++++++++++++++++++++ test/subdomain-base-files.test.js | 53 +++++++++++++++++++++++++ test/url.test.js | 37 +++++++++++++++++- 11 files changed, 319 insertions(+), 18 deletions(-) create mode 100644 test/adapter.test.js diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 6469cf2..0d09bb0 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -28,8 +28,10 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashl export function buildResourceUrl(request, urlPath) { // Use request.headers.host (includes port) instead of request.hostname (strips port) const host = request.headers.host || request.hostname; + // request.hostname may include port — strip it for comparison + const hostnameOnly = request.hostname.includes(':') ? request.hostname.split(':')[0] : request.hostname; if (request.subdomainsEnabled && request.baseDomain && - request.hostname === getBaseDomainHost(request.baseDomain) && !request.podName) { + hostnameOnly === getBaseDomainHost(request.baseDomain) && !request.podName) { const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/); // Treat a path segment as a pod name only if it looks like one: // - not a dotfile (.well-known, .acl, .meta, ...) @@ -193,10 +195,16 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au // If mashlib is enabled, serve mashlib instead of static error page // Mashlib has built-in login functionality via panes.runDataBrowser() if (request.mashlibEnabled) { + // OIDC code-flow callbacks often land back on protected resources with + // ?code=...&state=...; return 200 so the browser shell can process the + // callback instead of getting stuck on an HTTP 401 page. + const isOidcCallback = request.method === 'GET' && + typeof request.query?.code === 'string' && + typeof request.query?.state === 'string'; const html = request.mashlibModule ? generateModuleDatabrowserHtml(request.mashlibModule) : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null); - return reply.code(statusCode).type('text/html').send(html); + return reply.code(isOidcCallback ? 200 : statusCode).type('text/html').send(html); } return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request)); } diff --git a/src/auth/solid-oidc.js b/src/auth/solid-oidc.js index ec7c4a4..18919e8 100644 --- a/src/auth/solid-oidc.js +++ b/src/auth/solid-oidc.js @@ -12,6 +12,7 @@ import * as jose from 'jose'; import { validateExternalUrl } from '../utils/ssrf.js'; +import { getPublicJwks } from '../idp/keys.js'; // Cache for OIDC configurations and JWKS const oidcConfigCache = new Map(); @@ -285,6 +286,26 @@ async function getOidcConfig(issuer) { } } + // For the server's own trusted issuer, avoid an outbound HTTPS fetch. + // This prevents local/self-signed certificate problems during resource-token + // verification and guarantees consistency with our served discovery doc. + if (isTrusted) { + const baseUrl = issuer.replace(/\/$/, ''); + const config = { + issuer: baseUrl + '/', + authorization_endpoint: `${baseUrl}/idp/auth`, + token_endpoint: `${baseUrl}/idp/token`, + userinfo_endpoint: `${baseUrl}/idp/me`, + jwks_uri: `${baseUrl}/.well-known/jwks.json`, + registration_endpoint: `${baseUrl}/idp/reg`, + introspection_endpoint: `${baseUrl}/idp/token/introspection`, + revocation_endpoint: `${baseUrl}/idp/token/revocation`, + end_session_endpoint: `${baseUrl}/idp/session/end`, + }; + oidcConfigCache.set(issuer, { config, timestamp: Date.now() }); + return config; + } + const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; try { @@ -312,6 +333,15 @@ async function getJwks(issuer) { return cached.jwks; } + const normalizedIssuer = issuer.replace(/\/$/, ''); + const isTrusted = trustedIssuers.has(normalizedIssuer) || trustedIssuers.has(normalizedIssuer + '/'); + + if (isTrusted) { + const localJwks = jose.createLocalJWKSet(await getPublicJwks()); + jwksCache.set(issuer, { jwks: localJwks, timestamp: Date.now() }); + return localJwks; + } + // Get OIDC config to find JWKS URI const config = await getOidcConfig(issuer); const jwksUri = config.jwks_uri; diff --git a/src/idp/adapter.js b/src/idp/adapter.js index 4a10262..486c6a0 100644 --- a/src/idp/adapter.js +++ b/src/idp/adapter.js @@ -22,6 +22,13 @@ function modelToDir(model) { return model.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); } +// Some models (e.g., accounts) store index files that are not oidc entities. +// Do not skip every file starting with '_' because oidc IDs can legitimately +// begin with '_' (nanoid alphabet includes '_'). +function isIndexFile(file) { + return /^_[^\\/]+_index\.json$/i.test(file); +} + /** * Filesystem adapter for oidc-provider * Implements the adapter interface required by oidc-provider @@ -101,7 +108,7 @@ class FilesystemAdapter { try { const files = await fs.readdir(this.dir); for (const file of files) { - if (file.startsWith('_')) continue; // Skip index files + if (isIndexFile(file)) continue; const data = await fs.readJson(path.join(this.dir, file)); if (data.userCode === userCode) { // Check expiry @@ -126,7 +133,7 @@ class FilesystemAdapter { try { const files = await fs.readdir(this.dir); for (const file of files) { - if (file.startsWith('_')) continue; // Skip index files + if (isIndexFile(file)) continue; const data = await fs.readJson(path.join(this.dir, file)); if (data.uid === uid) { // Check expiry diff --git a/src/idp/interactions.js b/src/idp/interactions.js index 4781da9..e6a6590 100644 --- a/src/idp/interactions.js +++ b/src/idp/interactions.js @@ -19,7 +19,11 @@ async function finishInteractionOrError(request, reply, provider, result, option reply.hijack(); await provider.interactionFinished(request.raw, reply.raw, result, options); } catch (err) { - request.log.error({ err: err.message, uid: request.params?.uid }, 'interactionFinished failed'); + request.log.error({ + err: err.message, + error_description: err.error_description, + uid: request.params?.uid, + }, 'interactionFinished failed'); if (!reply.raw.writableEnded) { reply.raw.statusCode = 500; reply.raw.setHeader('Content-Type', 'text/html; charset=utf-8'); @@ -231,10 +235,34 @@ export async function handleConsent(request, reply, provider) { } const { prompt, params, session } = interaction; + if (prompt.name !== 'consent') { return reply.code(400).type('text/html').send(errorPage('Invalid state', 'Not in consent stage.')); } + let liveSession = null; + if (session?.uid) { + liveSession = await provider.Session.findByUid(session.uid); + } + + // Fallback: when UID lookup fails but we still have a session cookie binding, + // recover the live session by cookie (jti) and repair the interaction snapshot. + // This prevents oidc-provider from failing with "session not found" at + // interactionFinished when interaction.session.uid is stale/missing. + if (!liveSession && session?.cookie) { + const byCookieSession = await provider.Session.find(session.cookie); + + if (byCookieSession?.uid) { + interaction.session = { + ...(interaction.session || {}), + uid: byCookieSession.uid, + accountId: byCookieSession.accountId || interaction.session?.accountId, + cookie: byCookieSession.jti || interaction.session?.cookie, + }; + await interaction.save(interaction.exp - Math.floor(Date.now() / 1000)); + } + } + // Grant consent const grant = new provider.Grant({ accountId: session.accountId, diff --git a/src/idp/provider.js b/src/idp/provider.js index 0488d7f..32a3c7b 100644 --- a/src/idp/provider.js +++ b/src/idp/provider.js @@ -215,6 +215,7 @@ export async function createProvider(issuer) { `; }, }, + }, // Token format - JWT for Solid-OIDC diff --git a/src/server.js b/src/server.js index d06100d..e857f68 100644 --- a/src/server.js +++ b/src/server.js @@ -22,6 +22,7 @@ import { webrtcPlugin } from './webrtc/index.js'; import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; import { registerErrorHandler } from './utils/error-handler.js'; +import { getBaseDomainHost } from './utils/url.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -205,8 +206,10 @@ export function createServer(options = {}) { // Extract pod name from subdomain if enabled if (subdomainsEnabled && baseDomain) { - const host = request.hostname; // hostname never includes port - const baseDomainHost = baseDomain.includes(':') ? baseDomain.slice(0, baseDomain.lastIndexOf(':')) : baseDomain; + // request.hostname may include port in some Fastify versions — strip it + const rawHost = request.hostname; + const host = rawHost.includes(':') ? rawHost.split(':')[0] : rawHost; + const baseDomainHost = getBaseDomainHost(baseDomain); // Check if host is a subdomain of baseDomain (hostname part only) if (host !== baseDomainHost && host.endsWith('.' + baseDomainHost)) { // Extract subdomain (e.g., "alice.example.com" -> "alice") diff --git a/src/utils/url.js b/src/utils/url.js index c6cd7dd..dc26ecc 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -162,16 +162,33 @@ export function getResourceName(urlPath) { */ export function getBaseDomainHost(baseDomain) { if (!baseDomain) return baseDomain; - // Bracketed IPv6 - if (baseDomain.startsWith('[')) { - const end = baseDomain.indexOf(']'); - return end === -1 ? baseDomain : baseDomain.slice(0, end + 1); + + let value = String(baseDomain).trim(); + if (!value) return value; + + // Accept defensive forms like "https://example.com:3100/". + if (!value.includes('://')) { + value = `http://${value}`; + } + + try { + const parsed = new URL(value); + return parsed.hostname; + } catch { + // Fallback for malformed values: best-effort host extraction. + const candidate = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/.*$/, ''); + + // Bracketed IPv6 + if (candidate.startsWith('[')) { + const end = candidate.indexOf(']'); + return end === -1 ? candidate : candidate.slice(0, end + 1); + } + + const colon = candidate.lastIndexOf(':'); + if (colon === -1) return candidate; + const maybePort = candidate.slice(colon + 1); + return /^\d+$/.test(maybePort) ? candidate.slice(0, colon) : candidate; } - const colon = baseDomain.lastIndexOf(':'); - if (colon === -1) return baseDomain; - // Only strip if what follows is a port number - const maybePort = baseDomain.slice(colon + 1); - return /^\d+$/.test(maybePort) ? baseDomain.slice(0, colon) : baseDomain; } diff --git a/test/adapter.test.js b/test/adapter.test.js new file mode 100644 index 0000000..ea3caa8 --- /dev/null +++ b/test/adapter.test.js @@ -0,0 +1,57 @@ +import { beforeEach, afterEach, describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +import { createAdapter } from '../src/idp/adapter.js'; + +describe('FilesystemAdapter', () => { + let tmpRoot; + let originalDataRoot; + + beforeEach(async () => { + originalDataRoot = process.env.DATA_ROOT; + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-adapter-')); + process.env.DATA_ROOT = tmpRoot; + }); + + afterEach(async () => { + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + await fs.remove(tmpRoot); + }); + + it('findByUid returns a valid session whose storage id starts with underscore', async () => { + const adapter = createAdapter('Session'); + const id = '_leadingUnderscoreSessionId'; + const uid = 'session-uid-123'; + + await adapter.upsert(id, { + uid, + accountId: 'acct-1', + kind: 'Session', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }, 3600); + + const found = await adapter.findByUid(uid); + + assert.ok(found, 'expected findByUid to find underscore-prefixed session file'); + assert.strictEqual(found._id, id); + assert.strictEqual(found.uid, uid); + }); + + it('findByUid still ignores index files', async () => { + const adapter = createAdapter('Session'); + await fs.ensureDir(adapter.dir); + await fs.writeJson(path.join(adapter.dir, '_session_index.json'), { + uid: 'wrong-uid', + _id: '_session_index', + }); + + const found = await adapter.findByUid('wrong-uid'); + + assert.strictEqual(found, undefined); + }); +}); \ No newline at end of file diff --git a/test/solid-oidc.test.js b/test/solid-oidc.test.js index e29c35e..c83afd6 100644 --- a/test/solid-oidc.test.js +++ b/test/solid-oidc.test.js @@ -6,6 +6,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import * as jose from 'jose'; +import crypto from 'crypto'; import { startTestServer, stopTestServer, @@ -14,6 +15,8 @@ import { getBaseUrl, assertStatus } from './helpers.js'; +import { addTrustedIssuer, clearCaches, verifySolidOidc } from '../src/auth/solid-oidc.js'; +import { getJwks } from '../src/idp/keys.js'; describe('Solid-OIDC', () => { let keyPair; @@ -178,6 +181,67 @@ describe('Solid-OIDC', () => { assertStatus(res, 401); }); + + it('verifies tokens from a trusted self issuer without remote discovery fetch', async () => { + clearCaches(); + + const issuer = 'https://pivot-test.local:4443'; + addTrustedIssuer(issuer); + + const dpopKeyPair = await jose.generateKeyPair('ES256'); + const dpopPublicJwk = await jose.exportJWK(dpopKeyPair.publicKey); + dpopPublicJwk.alg = 'ES256'; + const thumbprint = await jose.calculateJwkThumbprint(dpopPublicJwk, 'sha256'); + + const serverJwks = await getJwks(); + const signingKey = serverJwks.keys.find((key) => key.alg === 'RS256') || serverJwks.keys[0]; + const privateKey = await jose.importJWK(signingKey, signingKey.alg); + + const accessToken = await new jose.SignJWT({ + webid: 'https://alice.pivot-test.local:4443/profile/card#me', + sub: 'https://alice.pivot-test.local:4443/profile/card#me', + iss: issuer, + aud: 'solid', + cnf: { jkt: thumbprint }, + }) + .setProtectedHeader({ alg: signingKey.alg, kid: signingKey.kid }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + + const dpopProof = await new jose.SignJWT({ + htm: 'GET', + htu: 'https://alice.pivot-test.local:4443/private/', + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID(), + }) + .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: dpopPublicJwk }) + .sign(dpopKeyPair.privateKey); + + const originalFetch = global.fetch; + global.fetch = async () => { + throw new Error('trusted self issuer should not use fetch'); + }; + + try { + const result = await verifySolidOidc({ + method: 'GET', + protocol: 'https', + hostname: 'alice.pivot-test.local:4443', + url: '/private/', + headers: { + authorization: `DPoP ${accessToken}`, + dpop: dpopProof, + }, + }); + + assert.strictEqual(result.error, null); + assert.strictEqual(result.webId, 'https://alice.pivot-test.local:4443/profile/card#me'); + } finally { + global.fetch = originalFetch; + clearCaches(); + } + }); }); describe('Bearer Token Fallback', () => { diff --git a/test/subdomain-base-files.test.js b/test/subdomain-base-files.test.js index a0fa55a..85b3b9f 100644 --- a/test/subdomain-base-files.test.js +++ b/test/subdomain-base-files.test.js @@ -107,3 +107,56 @@ describe('buildResourceUrl — subdomain mode disabled', () => { assert.strictEqual(buildResourceUrl(req, '/alice/'), 'https://example.com/alice/'); }); }); + +// Regression: Fastify sets request.hostname to host:port when a non-default +// port is in use. Previously getBaseDomainHost was not called, so +// 'alice.pivot-test.local:4443' never matched '.pivot-test.local', making +// request.podName stay null and subdomain routing break entirely. +describe('buildResourceUrl — port-bearing hostname (subdomain mode)', () => { + const baseDomain = 'pivot-test.local:4443'; + + it('subdomain request with port — uses headers.host verbatim', () => { + // Simulate Fastify: hostname includes port, headers.host also has port + const req = makeRequest({ + hostname: 'alice.pivot-test.local:4443', + baseDomain, + podName: 'alice', + }); + // Already on subdomain — buildResourceUrl uses headers.host directly + assert.strictEqual( + buildResourceUrl(req, '/'), + 'https://alice.pivot-test.local:4443/' + ); + }); + + it('base-domain with port — rewrites pod path to subdomain URL', () => { + // hostname matches baseDomain host after stripping port + const req = { + protocol: 'https', + hostname: 'pivot-test.local:4443', + headers: { host: 'pivot-test.local:4443' }, + subdomainsEnabled: true, + baseDomain, + podName: null, + }; + assert.strictEqual( + buildResourceUrl(req, '/alice/'), + 'https://alice.pivot-test.local:4443/' + ); + }); + + it('base-domain with port — no rewrite for file with extension', () => { + const req = { + protocol: 'https', + hostname: 'pivot-test.local:4443', + headers: { host: 'pivot-test.local:4443' }, + subdomainsEnabled: true, + baseDomain, + podName: null, + }; + assert.strictEqual( + buildResourceUrl(req, '/mashlib.js'), + 'https://pivot-test.local:4443/mashlib.js' + ); + }); +}); diff --git a/test/url.test.js b/test/url.test.js index ddbf285..937dd63 100644 --- a/test/url.test.js +++ b/test/url.test.js @@ -7,7 +7,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { getPodName, getContentType } from '../src/utils/url.js'; +import { getPodName, getContentType, getBaseDomainHost } from '../src/utils/url.js'; describe('getPodName', () => { describe('subdomain mode', () => { @@ -74,8 +74,41 @@ describe('getPodName', () => { }); }); +// Regression coverage for getBaseDomainHost — must strip port from baseDomain +// before comparing against request.hostname. +// Fastify sets request.hostname to the full host:port string, so the subdomain +// detection in server.js was failing for non-default ports (e.g. :4443). +describe('getBaseDomainHost', () => { + it('plain hostname — returned as-is', () => { + assert.strictEqual(getBaseDomainHost('example.com'), 'example.com'); + }); + + it('hostname:port — strips the port', () => { + assert.strictEqual(getBaseDomainHost('example.com:4443'), 'example.com'); + }); + + it('hostname:80 — strips even well-known port', () => { + assert.strictEqual(getBaseDomainHost('example.com:80'), 'example.com'); + }); + + it('full https URL form — extracts hostname only', () => { + assert.strictEqual(getBaseDomainHost('https://example.com:3100/'), 'example.com'); + }); + + it('full http URL without port — extracts hostname', () => { + assert.strictEqual(getBaseDomainHost('http://example.com/'), 'example.com'); + }); + + it('localhost:4443 — strips port', () => { + assert.strictEqual(getBaseDomainHost('localhost:4443'), 'localhost'); + }); + + it('pivot-test.local:4443 — strips port (real regression case)', () => { + assert.strictEqual(getBaseDomainHost('pivot-test.local:4443'), 'pivot-test.local'); + }); +}); + // Regression coverage for #294 — .acl and .meta must be recognised as RDF -// resources so content negotiation kicks in for Turtle-native clients. describe('getContentType', () => { describe('extension-based mapping (existing)', () => { it('maps .jsonld → application/ld+json', () => { From 24b03f9c7ed00624376b6cfb4b078349bfbb5941 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 3 May 2026 19:17:36 +0200 Subject: [PATCH 09/18] owner can edit any ACL --- src/auth/middleware.js | 55 ++++++++++++++++++++++ test/auth.test.js | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 0d09bb0..753879d 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -472,5 +472,60 @@ async function authorizeAclAccess(request, urlPath, method, webId, authError) { requiredMode: AccessMode.CONTROL }); + // Owner fallback: allow ACL read/edit even if acl:Control is missing + // or the ACL document is invalid/unparseable. + if (!allowed && isAclOwnerAclMethod(method) && isAclOwner(request, protectedUrl, webId)) { + return { + authorized: true, + webId, + wacAllow: 'user="read write append control", public=""', + authError + }; + } + return { authorized: allowed, webId, wacAllow, authError }; } + +function isAclMutationMethod(method) { + const m = (method || '').toUpperCase(); + return m === 'PUT' || m === 'PATCH' || m === 'DELETE' || m === 'POST'; +} + +function isAclOwnerAclMethod(method) { + const m = (method || '').toUpperCase(); + return m === 'GET' || m === 'HEAD' || isAclMutationMethod(m); +} + +function isAclOwner(request, protectedUrl, webId) { + if (!webId) return false; + + const candidates = getOwnerWebIdCandidates(request, protectedUrl); + return candidates.includes(webId); +} + +function getOwnerWebIdCandidates(request, protectedUrl) { + let parsed; + try { + parsed = new URL(protectedUrl); + } catch { + return []; + } + + const origin = parsed.origin; + const pathSegments = parsed.pathname.split('/').filter(Boolean); + + // Path-based multi-user mode: first path segment is pod name. + if (!request.subdomainsEnabled && !request.singleUser && pathSegments.length > 0 && !pathSegments[0].startsWith('.')) { + const podName = pathSegments[0]; + return [ + `${origin}/${podName}/profile/card.jsonld#me`, + `${origin}/${podName}/profile/card#me` + ]; + } + + // Subdomain mode and single-user mode use an origin-scoped profile. + return [ + `${origin}/profile/card.jsonld#me`, + `${origin}/profile/card#me` + ]; +} diff --git a/test/auth.test.js b/test/auth.test.js index cadd2c4..622b7f4 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -4,6 +4,8 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; +import fs from 'fs-extra'; +import path from 'path'; import { startTestServer, stopTestServer, @@ -354,6 +356,106 @@ describe('Authentication', () => { const res3 = await request('/authuser1/authenticated-only/test.txt', { auth: 'authuser2' }); assertStatus(res3, 200); }); + + it('should allow owner to edit ACL even without acl:Control', async () => { + await createTestPod('aclowner1'); + const baseUrl = getBaseUrl(); + + // Initial ACL update while owner still has Control via inherited defaults. + const noControlAcl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner-no-control', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/aclowner1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:default': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' } + ] + } + ] + }; + + const setNoControl = await request('/aclowner1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(noControlAcl), + auth: 'aclowner1' + }); + assert.ok(setNoControl.status < 300, `Initial ACL write failed: ${setNoControl.status}`); + + // Second edit would normally fail (no acl:Control), but owner fallback should allow it. + const updatedAcl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner-updated', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/aclowner1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:default': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' } + ] + } + ] + }; + + const secondEdit = await request('/aclowner1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(updatedAcl), + auth: 'aclowner1' + }); + assert.ok(secondEdit.status < 300, `Owner should edit ACL without Control, got ${secondEdit.status}`); + + // Owner should also be able to read ACL even without Control. + const readAcl = await request('/aclowner1/public/.acl', { + method: 'GET', + auth: 'aclowner1' + }); + assert.ok(readAcl.status < 300, `Owner should read ACL without Control, got ${readAcl.status}`); + }); + + it('should allow owner to repair a broken ACL document', async () => { + await createTestPod('aclowner2'); + const baseUrl = getBaseUrl(); + + // Corrupt the ACL on disk to simulate an invalid/unparseable ACL document. + const aclPath = path.join('data', 'aclowner2', 'public', '.acl'); + await fs.writeFile(aclPath, 'this is not valid ACL content', 'utf8'); + + // Owner must still be able to repair the ACL afterwards. + const repairedAcl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/aclowner2/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/aclowner2/public/` }, + 'acl:default': { '@id': `${baseUrl}/aclowner2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + } + ] + }; + + const repair = await request('/aclowner2/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(repairedAcl), + auth: 'aclowner2' + }); + assert.ok(repair.status < 300, `Owner should repair broken ACL, got ${repair.status}`); + }); }); describe('WAC-Allow Header', () => { From 853bc400274fb531e695dda7ecc4d8da4b172fcf Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 3 May 2026 23:12:21 +0200 Subject: [PATCH 10/18] guard ACL updated for conneg on --- src/handlers/resource.js | 35 ++++++++++++++++++-------------- test/conneg.test.js | 44 +++++++++++++++++++++++----------------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 1ec3b1b..073cea6 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -683,22 +683,27 @@ export async function handlePut(request, reply) { } const contentType = request.headers['content-type'] || ''; - - // ACL resources require a JSON-LD payload (application/ld+json or - // application/json). Round-trip serialization between JSON-LD and - // Turtle representations has limitations that can cause data loss - // when a client PUTs Turtle and later requests Turtle. - // Other RDF resources are unaffected. The guard fires regardless - // of conneg setting and also when Content-Type is missing. const ctMain = contentType.split(';')[0].trim().toLowerCase(); - const isJsonLd = ctMain === 'application/ld+json' || ctMain === 'application/json'; - if (urlPath.endsWith('.acl') && !isJsonLd) { - reply.header('Accept', 'application/ld+json, application/json'); - reply.header('Accept-Put', 'application/ld+json, application/json'); - return reply.code(415).send({ - error: 'Unsupported Media Type', - message: 'ACL resources must be sent as application/ld+json or application/json.' - }); + + // ACL resources are RDF and follow conneg input rules. With conneg on, + // allow Turtle/N3 and convert to JSON-LD before write; with conneg off, + // allow only JSON-LD/JSON. Missing or unsupported types are rejected. + if (urlPath.endsWith('.acl')) { + const isJsonLd = ctMain === 'application/ld+json' || ctMain === 'application/json'; + const isTurtleLike = ctMain === RDF_TYPES.TURTLE || ctMain === RDF_TYPES.N3; + const aclAcceptValue = connegEnabled + ? 'application/ld+json, application/json, text/turtle, text/n3' + : 'application/ld+json, application/json'; + if (!ctMain || (!isJsonLd && !(connegEnabled && isTurtleLike))) { + reply.header('Accept', aclAcceptValue); + reply.header('Accept-Put', aclAcceptValue); + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: connegEnabled + ? 'ACL resources require application/ld+json, application/json, text/turtle, or text/n3.' + : 'ACL resources require application/ld+json or application/json (enable conneg for Turtle/N3 support).' + }); + } } // Check if we can accept this input type diff --git a/test/conneg.test.js b/test/conneg.test.js index 3411731..87df271 100644 --- a/test/conneg.test.js +++ b/test/conneg.test.js @@ -290,11 +290,10 @@ describe('Content Negotiation (conneg enabled)', () => { }); }); - // ACL resources require a JSON-LD payload (application/ld+json or - // application/json) on PUT regardless of conneg setting: round-trip - // serialization between JSON-LD and Turtle has known limitations - // that can cause data loss. See #295. - describe('ACL content-type guard (#295)', () => { + // ACL resources follow conneg write rules: + // - conneg enabled: accept JSON-LD/JSON/Turtle/N3 and convert Turtle/N3 to JSON-LD + // - conneg disabled: accept JSON-LD/JSON only + describe('ACL content-type guard', () => { const aclJsonLd = { '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, '@graph': [ @@ -308,37 +307,36 @@ describe('Content Negotiation (conneg enabled)', () => { ] }; - it('rejects text/turtle PUT to .acl with 415', async () => { + it('accepts text/turtle PUT to .acl when conneg is enabled', async () => { const turtle = ` @prefix acl: . <#owner> a acl:Authorization; acl:mode acl:Read. `; - const res = await request('/connegtest/public/turtle-reject.acl', { + const res = await request('/connegtest/public/turtle-accept.acl', { method: 'PUT', headers: { 'Content-Type': 'text/turtle' }, body: turtle, auth: 'connegtest' }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); + assert.ok(res.status < 300, `text/turtle PUT to .acl should succeed with conneg, got ${res.status}`); + + const getRes = await request('/connegtest/public/turtle-accept.acl', { + headers: { 'Accept': 'application/ld+json' }, + auth: 'connegtest' + }); + assertStatus(getRes, 200); + assertHeaderContains(getRes, 'Content-Type', 'application/ld+json'); }); - it('rejects text/n3 PUT to .acl with 415', async () => { - const res = await request('/connegtest/public/n3-reject.acl', { + it('accepts text/n3 PUT to .acl when conneg is enabled', async () => { + const res = await request('/connegtest/public/n3-accept.acl', { method: 'PUT', headers: { 'Content-Type': 'text/n3' }, body: '@prefix acl: . <#x> a acl:Authorization.', auth: 'connegtest' }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); + assert.ok(res.status < 300, `text/n3 PUT to .acl should succeed with conneg, got ${res.status}`); }); it('rejects text/plain PUT to .acl with 415 (URL-extension protection)', async () => { @@ -351,8 +349,12 @@ describe('Content Negotiation (conneg enabled)', () => { assertStatus(res, 415); assertHeaderContains(res, 'Accept', 'application/ld+json'); assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept', 'text/turtle'); + assertHeaderContains(res, 'Accept', 'text/n3'); assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); assertHeaderContains(res, 'Accept-Put', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'text/turtle'); + assertHeaderContains(res, 'Accept-Put', 'text/n3'); }); it('rejects PUT to .acl with no Content-Type with 415', async () => { @@ -366,8 +368,12 @@ describe('Content Negotiation (conneg enabled)', () => { assertStatus(res, 415); assertHeaderContains(res, 'Accept', 'application/ld+json'); assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept', 'text/turtle'); + assertHeaderContains(res, 'Accept', 'text/n3'); assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); assertHeaderContains(res, 'Accept-Put', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'text/turtle'); + assertHeaderContains(res, 'Accept-Put', 'text/n3'); }); it('accepts application/ld+json PUT to .acl', async () => { From fa804958feff2f9f8a42495221bd80ba413d954e Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 3 May 2026 23:20:43 +0200 Subject: [PATCH 11/18] force reload dataBrowser --- src/mashlib/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/mashlib/index.js b/src/mashlib/index.js index 33016e7..1c8a9fd 100644 --- a/src/mashlib/index.js +++ b/src/mashlib/index.js @@ -287,6 +287,12 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); + // bfcache: mobile Chrome restores frozen page state instead of re-running + // scripts — panes.runDataBrowser() never fires. Force a reload on restore. + window.addEventListener('pageshow', function(event) { + if (event.persisted) { window.location.reload(); } + }); + function showError(message) { document.body.innerHTML = '

' + message + '

'; } @@ -434,6 +440,12 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); + // bfcache: mobile Chrome restores frozen page state instead of re-running + // scripts — panes.runDataBrowser() never fires. Force a reload on restore. + window.addEventListener('pageshow', function(event) { + if (event.persisted) { window.location.reload(); } + }); + function installAuthReloadFallback() { if (window.__jssAuthReloadInstalled) return; From 80e15d9b42a418a2efe5ef08aa78cdcf3a34d897 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 3 May 2026 23:38:14 +0200 Subject: [PATCH 12/18] fix bfcache android chrome --- src/mashlib/index.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/mashlib/index.js b/src/mashlib/index.js index 1c8a9fd..3ef4f1c 100644 --- a/src/mashlib/index.js +++ b/src/mashlib/index.js @@ -287,10 +287,13 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); - // bfcache: mobile Chrome restores frozen page state instead of re-running - // scripts — panes.runDataBrowser() never fires. Force a reload on restore. + // bfcache: Android Chrome can restore frozen page state instead of + // re-running scripts, so panes.runDataBrowser() never fires. + // Keep this workaround scoped to Android Chrome to avoid desktop side-effects. window.addEventListener('pageshow', function(event) { - if (event.persisted) { window.location.reload(); } + var ua = navigator.userAgent || ''; + var isAndroidChrome = /Android/i.test(ua) && /Chrome\//i.test(ua) && !/EdgA\//i.test(ua); + if (event.persisted && isAndroidChrome) { window.location.reload(); } }); function showError(message) { @@ -440,10 +443,13 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); - // bfcache: mobile Chrome restores frozen page state instead of re-running - // scripts — panes.runDataBrowser() never fires. Force a reload on restore. + // bfcache: Android Chrome can restore frozen page state instead of + // re-running scripts, so panes.runDataBrowser() never fires. + // Keep this workaround scoped to Android Chrome to avoid desktop side-effects. window.addEventListener('pageshow', function(event) { - if (event.persisted) { window.location.reload(); } + var ua = navigator.userAgent || ''; + var isAndroidChrome = /Android/i.test(ua) && /Chrome\//i.test(ua) && !/EdgA\//i.test(ua); + if (event.persisted && isAndroidChrome) { window.location.reload(); } }); function installAuthReloadFallback() { From d4ea06f7fa8bc390aab5dfc0c03fe91c0828bf89 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 3 May 2026 23:45:03 +0200 Subject: [PATCH 13/18] fix bfcache android chrome --- src/mashlib/index.js | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/mashlib/index.js b/src/mashlib/index.js index 3ef4f1c..ef274f2 100644 --- a/src/mashlib/index.js +++ b/src/mashlib/index.js @@ -287,13 +287,20 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); - // bfcache: Android Chrome can restore frozen page state instead of - // re-running scripts, so panes.runDataBrowser() never fires. - // Keep this workaround scoped to Android Chrome to avoid desktop side-effects. + // bfcache can restore a stale/frozen shell where mashlib is not ready. + // On persisted restores, reload only when the shell looks uninitialized. window.addEventListener('pageshow', function(event) { - var ua = navigator.userAgent || ''; - var isAndroidChrome = /Android/i.test(ua) && /Chrome\//i.test(ua) && !/EdgA\//i.test(ua); - if (event.persisted && isAndroidChrome) { window.location.reload(); } + if (!event.persisted) return; + try { + var outline = document.getElementById('outline'); + var hasRows = !!(outline && outline.querySelector('tr')); + var canRun = !!(window.panes && typeof window.panes.runDataBrowser === 'function'); + if (!hasRows || !canRun) { + window.location.reload(); + } + } catch (_) { + window.location.reload(); + } }); function showError(message) { @@ -443,13 +450,20 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); - // bfcache: Android Chrome can restore frozen page state instead of - // re-running scripts, so panes.runDataBrowser() never fires. - // Keep this workaround scoped to Android Chrome to avoid desktop side-effects. + // bfcache can restore a stale/frozen shell where mashlib is not ready. + // On persisted restores, reload only when the shell looks uninitialized. window.addEventListener('pageshow', function(event) { - var ua = navigator.userAgent || ''; - var isAndroidChrome = /Android/i.test(ua) && /Chrome\//i.test(ua) && !/EdgA\//i.test(ua); - if (event.persisted && isAndroidChrome) { window.location.reload(); } + if (!event.persisted) return; + try { + var outline = document.getElementById('outline'); + var hasRows = !!(outline && outline.querySelector('tr')); + var canRun = !!(window.panes && typeof window.panes.runDataBrowser === 'function'); + if (!hasRows || !canRun) { + window.location.reload(); + } + } catch (_) { + window.location.reload(); + } }); function installAuthReloadFallback() { From a83ba57548959efce664f1b42e3db660cb3709d5 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Mon, 4 May 2026 11:14:38 +0200 Subject: [PATCH 14/18] try fix reload --- src/mashlib/index.js | 49 ++++++++++++-------------------------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/src/mashlib/index.js b/src/mashlib/index.js index ef274f2..54c686e 100644 --- a/src/mashlib/index.js +++ b/src/mashlib/index.js @@ -287,22 +287,6 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); - // bfcache can restore a stale/frozen shell where mashlib is not ready. - // On persisted restores, reload only when the shell looks uninitialized. - window.addEventListener('pageshow', function(event) { - if (!event.persisted) return; - try { - var outline = document.getElementById('outline'); - var hasRows = !!(outline && outline.querySelector('tr')); - var canRun = !!(window.panes && typeof window.panes.runDataBrowser === 'function'); - if (!hasRows || !canRun) { - window.location.reload(); - } - } catch (_) { - window.location.reload(); - } - }); - function showError(message) { document.body.innerHTML = '

' + message + '

'; } @@ -450,22 +434,6 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = { } catch {} })(); - // bfcache can restore a stale/frozen shell where mashlib is not ready. - // On persisted restores, reload only when the shell looks uninitialized. - window.addEventListener('pageshow', function(event) { - if (!event.persisted) return; - try { - var outline = document.getElementById('outline'); - var hasRows = !!(outline && outline.querySelector('tr')); - var canRun = !!(window.panes && typeof window.panes.runDataBrowser === 'function'); - if (!hasRows || !canRun) { - window.location.reload(); - } - } catch (_) { - window.location.reload(); - } - }); - function installAuthReloadFallback() { if (window.__jssAuthReloadInstalled) return; @@ -564,10 +532,19 @@ export function shouldServeMashlib(request, mashlibEnabled, contentType) { return false; } - // Only serve mashlib for top-level document navigation - // sec-fetch-dest: 'document' = browser navigation (serve mashlib) - // sec-fetch-dest: 'empty' = JavaScript fetch/XHR (serve RDF data) - if (secFetchDest && secFetchDest !== 'document') { + // Block non-navigation sub-resource fetches (XHR, fetch API, scripts, etc.) + // sec-fetch-dest values that indicate non-document fetches are blocked. + // We do NOT require 'document' because on Android Chrome back navigation + // the header may be absent or differ from a fresh forward navigation. + // The Accept: text/html check below is the primary discriminator since + // mashlib XHR never includes text/html in its Accept header. + const nonDocumentDests = new Set([ + 'empty', 'script', 'worker', 'sharedworker', 'serviceworker', + 'style', 'image', 'font', 'media', 'manifest', 'object', 'embed', + 'report', 'xslt', 'audioworklet', 'paintworklet', 'track', 'video', + 'audio', 'fetch' + ]); + if (secFetchDest && nonDocumentDests.has(secFetchDest)) { return false; } From f904bad39eb11a9c4f175284b00afd7e49bab437 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Mon, 4 May 2026 11:42:05 +0200 Subject: [PATCH 15/18] debug --- src/handlers/resource.js | 10 +++++-- src/mashlib/index.js | 64 +++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 073cea6..d643523 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -14,7 +14,7 @@ import { } from '../rdf/conneg.js'; import { emitChange } from '../notifications/events.js'; import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js'; -import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js'; +import { generateDatabrowserHtml, generateModuleDatabrowserHtml, getMashlibDecision, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js'; import { turtleToJsonLd } from '../rdf/turtle.js'; /** @@ -238,7 +238,9 @@ export async function handleGet(request, reply) { const jsonLd = generateContainerJsonLd(resourceUrl, entries || []); // Check if we should serve Mashlib data browser for containers - if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) { + const containerMashlibDecision = getMashlibDecision(request, request.mashlibEnabled, 'application/ld+json'); + reply.header('X-JSS-Mashlib-Decision', containerMashlibDecision.reason); + if (containerMashlibDecision.serve) { // Phase 1 of #7: also embed the container's JSON-LD listing as a // data island so consumers that look for `