diff --git a/src/handlers/container.js b/src/handlers/container.js index e7908f2..698ad8e 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -3,7 +3,7 @@ import { initializeQuota, checkQuota, updateQuotaUsage } from '../storage/quota. import { getAllHeaders } from '../ldp/headers.js'; import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js'; import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js'; -import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js'; +import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } from '../wac/parser.js'; import { createToken } from '../auth/token.js'; import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js'; import { emitChange } from '../notifications/events.js'; @@ -194,32 +194,41 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo await storage.write(`${podPath}settings/privateTypeIndex.jsonld`, serialize(privateTypeIndex)); // Create default ACL files. Each .acl is written inside the container it - // protects, so the resource it refers to is always './' (resolved against - // the .acl's own URL by the parser — see #428). This keeps pods portable - // across hostnames; the absolute podUri is no longer baked in. - const rootAcl = generateOwnerAcl('./', webId, true); + // protects, so `acl:accessTo` is always './' (resolved against the .acl's + // own URL by the parser — see #428). + // + // The owner WebID is also written relatively (#430), derived from the + // absolute `webId` and the .acl's location within the pod by + // `relativizeOwnerWebId`. This works for any profile layout (modern + // `profile/card.jsonld#me`, legacy `profile/card#me`, custom shapes) and + // falls back to the absolute WebID for foreign owners. Together this + // keeps the on-disk pod portable across hostnames. + const owner = aclBase => relativizeOwnerWebId(webId, podUri, aclBase); + + const rootAcl = generateOwnerAcl('./', owner(''), true); await storage.write(`${podPath}.acl`, serializeAcl(rootAcl)); - const privateAcl = generatePrivateAcl('./', webId); + const privateAcl = generatePrivateAcl('./', owner('private/')); await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl('./', webId); + const settingsAcl = generatePrivateAcl('./', owner('settings/')); await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl)); // publicTypeIndex: public read, overrides the private default inherited // from /settings/. This is a resource ACL (lives at .../publicTypeIndex.jsonld.acl), - // so the resource is './publicTypeIndex.jsonld' relative to the parent. - const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', webId, false); + // whose base URL is /settings/ — same depth as `settings/.acl` for the + // owner reference. + const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', owner('settings/'), false); await storage.write(`${podPath}settings/publicTypeIndex.jsonld.acl`, serializeAcl(publicTypeIndexAcl)); - const inboxAcl = generateInboxAcl('./', webId); + const inboxAcl = generateInboxAcl('./', owner('inbox/')); await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl('./', webId); + const publicAcl = generatePublicFolderAcl('./', owner('public/')); await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl)); // Profile documents must be publicly readable for WebID verification - const profileAcl = generatePublicFolderAcl('./', webId); + const profileAcl = generatePublicFolderAcl('./', owner('profile/')); await storage.write(`${podPath}profile/.acl`, serializeAcl(profileAcl)); // Initialize storage quota if configured diff --git a/src/server.js b/src/server.js index fa38702..548cf94 100644 --- a/src/server.js +++ b/src/server.js @@ -941,7 +941,7 @@ export function createServer(options = {}) { */ async function createRootPodStructure(webId, podUri, issuer, displayName) { const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js'); - const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } = await import('./wac/parser.js'); + const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } = await import('./wac/parser.js'); // Create directories at root await storage.createContainer('/inbox/'); @@ -964,30 +964,35 @@ export function createServer(options = {}) { const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false }); await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex)); - // ACL files. Each .acl uses './' (or a relative basename for resource - // ACLs) so the pod isn't host-locked to whichever interface the server - // happened to bind on first start. The parser resolves these against - // the .acl's own request URL — see #428. - const rootAcl = generateOwnerAcl('./', webId, true); + // ACL files. Both `accessTo` (#428) and `acl:agent` (#430) are written + // relatively so the on-disk pod isn't host-locked to whichever interface + // the server happened to bind on first start. The owner WebID is + // derived from the absolute `webId` and each .acl's location by + // `relativizeOwnerWebId`, so any current or future profile layout + // (modern `profile/card.jsonld#me`, legacy `profile/card#me`, etc.) + // produces the correct relative IRI without hardcoding. + const owner = aclBase => relativizeOwnerWebId(webId, podUri, aclBase); + + const rootAcl = generateOwnerAcl('./', owner(''), true); await storage.write('/.acl', serializeAcl(rootAcl)); - const privateAcl = generatePrivateAcl('./', webId); + const privateAcl = generatePrivateAcl('./', owner('private/')); await storage.write('/private/.acl', serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl('./', webId); + const settingsAcl = generatePrivateAcl('./', owner('settings/')); await storage.write('/settings/.acl', serializeAcl(settingsAcl)); // publicTypeIndex: public read, overrides the private default inherited from /settings/ - const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', webId, false); + const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', owner('settings/'), false); await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl)); - const inboxAcl = generateInboxAcl('./', webId); + const inboxAcl = generateInboxAcl('./', owner('inbox/')); await storage.write('/inbox/.acl', serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl('./', webId); + const publicAcl = generatePublicFolderAcl('./', owner('public/')); await storage.write('/public/.acl', serializeAcl(publicAcl)); - const profileAcl = generatePublicFolderAcl('./', webId); + const profileAcl = generatePublicFolderAcl('./', owner('profile/')); await storage.write('/profile/.acl', serializeAcl(profileAcl)); // Note: Quota not initialized for root-level pods (no user directory) diff --git a/src/wac/parser.js b/src/wac/parser.js index d2ede5c..dde244d 100644 --- a/src/wac/parser.js +++ b/src/wac/parser.js @@ -210,6 +210,38 @@ function normalizeMode(mode) { return modeMap[mode] || mode; } +/** + * Express an absolute owner WebID as a path relative to a given .acl + * file's container, so the in-ACL `acl:agent` reference is host-portable. + * The parser resolves the relative IRI against the .acl URL at check + * time. See #430. + * + * If the WebID isn't hosted under this pod (foreign owner), the absolute + * URI is returned unchanged — there's no in-pod path to resolve to. This + * also means the helper degrades gracefully for any current or future + * profile layout (modern `profile/card.jsonld#me`, legacy `profile/card#me`, + * single-file `me#me`, etc.) — whatever path the WebID actually has under + * the pod is what gets emitted. + * + * @param {string} webId - Owner WebID, typically absolute. + * @param {string} podUri - Pod root URI (must end with `/`). + * @param {string} aclBaseInPod - The .acl file's container, expressed + * relative to the pod root, e.g. `''` for `/.acl`, `'private/'` + * for `/private/.acl`, `'settings/'` for the resource ACL + * `/settings/publicTypeIndex.jsonld.acl`. + * @returns {string} Relative IRI for use as `acl:agent`, or the original + * absolute WebID if it isn't hosted under `podUri`. + */ +export function relativizeOwnerWebId(webId, podUri, aclBaseInPod = '') { + if (typeof webId !== 'string' || !webId) return webId; + if (typeof podUri !== 'string' || !podUri) return webId; + if (!webId.startsWith(podUri)) return webId; + const tail = webId.slice(podUri.length); + const depth = aclBaseInPod ? aclBaseInPod.split('/').filter(Boolean).length : 0; + const ups = depth === 0 ? './' : '../'.repeat(depth); + return ups + tail; +} + /** * Generate a default public read ACL * @param {string} resourceUrl - URL of the resource. May be relative (e.g. @@ -242,8 +274,10 @@ export function generatePublicReadAcl(resourceUrl) { * Generate a full owner ACL (owner has full control, public read) * @param {string} resourceUrl - URL of the resource. May be relative (see * `generatePublicReadAcl`). - * @param {string} ownerWebId - WebID of the owner. Currently absolute; - * relative agent WebIDs are tracked in #427 Phase 2. + * @param {string} ownerWebId - WebID of the owner. May also be relative + * (e.g. './profile/card.jsonld#me' for an in-pod owner) — the parser + * resolves it against the .acl URL at check time. The profile document + * itself still publishes the absolute WebID. See #430. * @param {boolean} isContainer - Whether this is a container * @returns {object} JSON-LD ACL document */ @@ -290,8 +324,10 @@ export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) { /** * Generate a private ACL (owner only, no public access) - * @param {string} resourceUrl - URL of the resource - * @param {string} ownerWebId - WebID of the owner + * @param {string} resourceUrl - URL of the resource (may be relative — see + * `generatePublicReadAcl`). + * @param {string} ownerWebId - WebID of the owner (may be relative — see + * `generateOwnerAcl`). * @param {boolean} isContainer - Whether this is a container * @returns {object} JSON-LD ACL document */ @@ -323,8 +359,8 @@ export function generatePrivateAcl(resourceUrl, ownerWebId, isContainer = true) /** * Generate an inbox ACL (owner full control, public append) - * @param {string} resourceUrl - URL of the inbox - * @param {string} ownerWebId - WebID of the owner + * @param {string} resourceUrl - URL of the inbox (may be relative). + * @param {string} ownerWebId - WebID of the owner (may be relative). * @returns {object} JSON-LD ACL document */ export function generateInboxAcl(resourceUrl, ownerWebId) { @@ -363,8 +399,8 @@ export function generateInboxAcl(resourceUrl, ownerWebId) { /** * Generate a public folder ACL (owner full control, public read with inheritance) * Used for /public/ folders where content should be publicly readable - * @param {string} resourceUrl - URL of the folder - * @param {string} ownerWebId - WebID of the owner + * @param {string} resourceUrl - URL of the folder (may be relative). + * @param {string} ownerWebId - WebID of the owner (may be relative). * @returns {object} JSON-LD ACL document */ export function generatePublicFolderAcl(resourceUrl, ownerWebId) { diff --git a/test/wac.test.js b/test/wac.test.js index b0fa77c..f036f0e 100644 --- a/test/wac.test.js +++ b/test/wac.test.js @@ -21,7 +21,8 @@ import { generateInboxAcl, generatePublicFolderAcl, generatePublicReadAcl, - serializeAcl + serializeAcl, + relativizeOwnerWebId } from '../src/wac/parser.js'; import { checkAccess, getRequiredMode } from '../src/wac/checker.js'; @@ -296,6 +297,141 @@ describe('WAC Parser', () => { ); }); + // Phase 2 of #427 (#430): generators should also preserve a relative + // ownerWebId verbatim, so the on-disk pod is host-portable for the + // owner half of the rule too. The parser already resolves relative + // agents (PR #65 / #64) — this just exercises the writer side. + it('generateOwnerAcl preserves a relative ownerWebId (#430)', () => { + const acl = generateOwnerAcl('./', './profile/card.jsonld#me', true); + const owner = acl['@graph'].find(a => a['@id'] === '#owner'); + assert.strictEqual(owner['acl:agent']['@id'], './profile/card.jsonld#me'); + }); + + it('generatePrivateAcl preserves a relative ownerWebId (#430)', () => { + const acl = generatePrivateAcl('./', '../profile/card.jsonld#me'); + assert.strictEqual(acl['@graph'][0]['acl:agent']['@id'], '../profile/card.jsonld#me'); + }); + + it('generateInboxAcl preserves a relative ownerWebId (#430)', () => { + const acl = generateInboxAcl('./', '../profile/card.jsonld#me'); + const owner = acl['@graph'].find(a => a['@id'] === '#owner'); + assert.strictEqual(owner['acl:agent']['@id'], '../profile/card.jsonld#me'); + }); + + it('generatePublicFolderAcl preserves a relative ownerWebId (#430)', () => { + const acl = generatePublicFolderAcl('./', './card.jsonld#me'); + const owner = acl['@graph'].find(a => a['@id'] === '#owner'); + assert.strictEqual(owner['acl:agent']['@id'], './card.jsonld#me'); + }); + + it('round-trip: relative ownerWebId resolves to .acl base URL on parse (#430)', async () => { + // Same ACL document, two hosts — agent should resolve to whichever + // host asked, just like accessTo. This is what makes the on-disk + // pod portable for the owner half. + const generated = generateOwnerAcl('./', './profile/card.jsonld#me', true); + const wire = serializeAcl(generated); + const auths1 = await parseAcl(wire, 'http://localhost:4444/.acl'); + const auths2 = await parseAcl(wire, 'http://0.0.0.0:4444/.acl'); + const owner1 = auths1.find(a => a.id === '#owner'); + const owner2 = auths2.find(a => a.id === '#owner'); + assert.ok(owner1.agents.includes('http://localhost:4444/profile/card.jsonld#me'), + `Expected localhost agent, got: ${JSON.stringify(owner1.agents)}`); + assert.ok(owner2.agents.includes('http://0.0.0.0:4444/profile/card.jsonld#me'), + `Expected 0.0.0.0 agent, got: ${JSON.stringify(owner2.agents)}`); + }); + + // The relativizeOwnerWebId helper drives the Phase 2 callers. Cover + // the layouts Copilot asked about so callers don't need to hardcode. + describe('relativizeOwnerWebId helper', () => { + const podUri = 'http://h/alice/'; + + it('emits "./" from the pod root', () => { + assert.strictEqual( + relativizeOwnerWebId(`${podUri}profile/card.jsonld#me`, podUri, ''), + './profile/card.jsonld#me' + ); + }); + + it('emits "../" from an immediate child folder', () => { + assert.strictEqual( + relativizeOwnerWebId(`${podUri}profile/card.jsonld#me`, podUri, 'private/'), + '../profile/card.jsonld#me' + ); + }); + + it('handles legacy /profile/card#me layout', () => { + // Pre-#282 pods used extensionless `profile/card`. The helper just + // slices the tail, so any layout works. + assert.strictEqual( + relativizeOwnerWebId(`${podUri}profile/card#me`, podUri, ''), + './profile/card#me' + ); + assert.strictEqual( + relativizeOwnerWebId(`${podUri}profile/card#me`, podUri, 'private/'), + '../profile/card#me' + ); + }); + + it('handles a custom (non-profile/) WebID shape', () => { + assert.strictEqual( + relativizeOwnerWebId(`${podUri}me#me`, podUri, ''), + './me#me' + ); + assert.strictEqual( + relativizeOwnerWebId(`${podUri}me#me`, podUri, 'public/'), + '../me#me' + ); + }); + + it('returns the absolute WebID unchanged for foreign owners', () => { + const foreign = 'https://other.example/profile/card.jsonld#me'; + assert.strictEqual( + relativizeOwnerWebId(foreign, podUri, ''), + foreign + ); + assert.strictEqual( + relativizeOwnerWebId(foreign, podUri, 'private/'), + foreign + ); + }); + + it('round-trips through the parser back to the absolute WebID', async () => { + // Helper output is correct iff parsing it under the same pod URI + // yields the original absolute WebID. Covers both modern and + // legacy layouts, from root and from a child folder. + const cases = [ + { web: `${podUri}profile/card.jsonld#me`, base: '', acl: `${podUri}.acl` }, + { web: `${podUri}profile/card.jsonld#me`, base: 'private/', acl: `${podUri}private/.acl` }, + { web: `${podUri}profile/card#me`, base: '', acl: `${podUri}.acl` }, + { web: `${podUri}profile/card#me`, base: 'private/', acl: `${podUri}private/.acl` }, + { web: `${podUri}me#me`, base: 'public/', acl: `${podUri}public/.acl` } + ]; + for (const { web, base, acl } of cases) { + const rel = relativizeOwnerWebId(web, podUri, base); + const generated = generateOwnerAcl('./', rel, true); + const wire = serializeAcl(generated); + const auths = await parseAcl(wire, acl); + const owner = auths.find(a => a.id === '#owner'); + assert.ok( + owner.agents.includes(web), + `Round-trip failed for ${web} from ${base}: relative=${rel}, resolved=${JSON.stringify(owner.agents)}` + ); + } + }); + }); + + it('round-trip: relative ownerWebId from a child folder resolves correctly (#430)', async () => { + // /pod/private/.acl with agent '../profile/card.jsonld#me' + // should resolve to /pod/profile/card.jsonld#me, not into the + // pod root or escape it. + const generated = generatePrivateAcl('./', '../profile/card.jsonld#me'); + const wire = serializeAcl(generated); + const auths = await parseAcl(wire, 'http://localhost:4444/alice/private/.acl'); + const owner = auths.find(a => a.id === '#owner'); + assert.ok(owner.agents.includes('http://localhost:4444/alice/profile/card.jsonld#me'), + `Expected resolution to /alice/profile/card.jsonld#me, got: ${JSON.stringify(owner.agents)}`); + }); + it('round-trip: relative "./" resolves to the .acl base URL on parse', async () => { const generated = generateOwnerAcl('./', webId, true); const wire = serializeAcl(generated);