From fa368e382e295e292fabde37a1fc47ea38709ed6 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 06:09:53 +0200 Subject: [PATCH 1/2] feat: emit relative acl:agent in pod creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #427 (#430). Builds on #429 (relative accessTo/default). The owner WebID lives at /profile/card.jsonld#me. Each .acl now references it relative to that .acl's container — './profile/card.jsonld#me' from the pod root, '../profile/card.jsonld#me' from an immediate child folder, './card.jsonld#me' from /profile/.acl. The parser already resolves relative agent URIs against the .acl URL (PR #65 / #64), so this is a writer-side change only. Existing absolute-agent ACLs on disk continue to authorize correctly. Together with #429 the on-disk pod is now host-portable: an operator can `mv` a pod directory to a different domain without rewriting any ACL files. Same-host owner read/write keeps working — covered by existing pod creation / ACL access tests. The `webId` parameter on createPodStructure / createRootPodStructure remains the absolute WebID URI: it is still the global identifier published in the profile document and consumed by OIDC, federation, typeIndex generation, etc. Only the in-ACL references become relative. Cross-host *owner write* (token issued by host A, request to host B) still mismatches because the token's `webid` claim is fixed at auth time; that's Phase 4 and needs its own design. Tests: - 4 unit tests (one per affected generator) verify a relative agent string is preserved verbatim. - 2 round-trip tests parse the same on-wire ACL under different host URLs and assert the agent resolves to the requesting host, including the parent-relative '../profile/card.jsonld#me' from a child folder. 793/793 tests pass. --- src/handlers/container.js | 29 ++++++++++++++------- src/server.js | 28 ++++++++++++-------- src/wac/parser.js | 20 ++++++++------ test/wac.test.js | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/handlers/container.js b/src/handlers/container.js index e7908f2..a384e84 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -195,31 +195,42 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo // 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); + // the .acl's own URL by the parser — see #428). + // + // The owner WebID is also written relatively (#430). The WebID lives at + // /profile/card.jsonld#me, so each .acl references it relative to + // that .acl's container — './profile/card.jsonld#me' from the root, + // '../profile/card.jsonld#me' from an immediate child folder, etc. + // This keeps the on-disk pod portable across hostnames; the absolute + // podUri is no longer baked into either accessTo or agent. + const ownerFromRoot = './profile/card.jsonld#me'; + const ownerFromChild = '../profile/card.jsonld#me'; + const ownerFromProfile = './card.jsonld#me'; + + const rootAcl = generateOwnerAcl('./', ownerFromRoot, true); await storage.write(`${podPath}.acl`, serializeAcl(rootAcl)); - const privateAcl = generatePrivateAcl('./', webId); + const privateAcl = generatePrivateAcl('./', ownerFromChild); await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl('./', webId); + const settingsAcl = generatePrivateAcl('./', ownerFromChild); 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); + // The .acl's base URL is /settings/, so the agent is one level up. + const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', ownerFromChild, false); await storage.write(`${podPath}settings/publicTypeIndex.jsonld.acl`, serializeAcl(publicTypeIndexAcl)); - const inboxAcl = generateInboxAcl('./', webId); + const inboxAcl = generateInboxAcl('./', ownerFromChild); await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl('./', webId); + const publicAcl = generatePublicFolderAcl('./', ownerFromChild); await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl)); // Profile documents must be publicly readable for WebID verification - const profileAcl = generatePublicFolderAcl('./', webId); + const profileAcl = generatePublicFolderAcl('./', ownerFromProfile); 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..b488ddf 100644 --- a/src/server.js +++ b/src/server.js @@ -964,30 +964,36 @@ 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. New single-user pods only + // ever use the modern card.jsonld profile layout (legacy `card` pods + // are detected and skipped in the caller), so the relative WebID is + // safe to hardcode. + const ownerFromRoot = './profile/card.jsonld#me'; + const ownerFromChild = '../profile/card.jsonld#me'; + const ownerFromProfile = './card.jsonld#me'; + + const rootAcl = generateOwnerAcl('./', ownerFromRoot, true); await storage.write('/.acl', serializeAcl(rootAcl)); - const privateAcl = generatePrivateAcl('./', webId); + const privateAcl = generatePrivateAcl('./', ownerFromChild); await storage.write('/private/.acl', serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl('./', webId); + const settingsAcl = generatePrivateAcl('./', ownerFromChild); 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', ownerFromChild, false); await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl)); - const inboxAcl = generateInboxAcl('./', webId); + const inboxAcl = generateInboxAcl('./', ownerFromChild); await storage.write('/inbox/.acl', serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl('./', webId); + const publicAcl = generatePublicFolderAcl('./', ownerFromChild); await storage.write('/public/.acl', serializeAcl(publicAcl)); - const profileAcl = generatePublicFolderAcl('./', webId); + const profileAcl = generatePublicFolderAcl('./', ownerFromProfile); 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..6319cd4 100644 --- a/src/wac/parser.js +++ b/src/wac/parser.js @@ -242,8 +242,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 +292,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 +327,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 +367,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..4bee213 100644 --- a/test/wac.test.js +++ b/test/wac.test.js @@ -296,6 +296,61 @@ 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)}`); + }); + + 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); From b4243977b78e72bf211d84208b4c3460231b62b2 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 06:42:30 +0200 Subject: [PATCH 2/2] review: derive relative acl:agent from webId, drop hardcoded paths Address Copilot review on #431. The previous commit hardcoded the relative agent IRI as './profile/card.jsonld#me' / '../profile/card.jsonld#me' in both createPodStructure and createRootPodStructure, which would silently diverge from the profile document if a pod ever uses a different layout (legacy 'profile/card#me', custom shapes, future refactors). Replace with a new helper `relativizeOwnerWebId(webId, podUri, aclBase)` exported from src/wac/parser.js. Callers pass the .acl's container relative to the pod root ('', 'private/', 'profile/', 'settings/', 'inbox/', 'public/'); the helper slices the WebID's tail and prepends the right number of '../' hops. Foreign owner WebIDs (not under podUri) fall through unchanged as absolute IRIs. Tests: 6 new unit tests in test/wac.test.js cover the modern, legacy, and custom-shape WebIDs, foreign owners, and a round-trip through the parser confirming the relative form resolves back to the original absolute WebID under each .acl location. 799/799 tests pass. --- src/handlers/container.js | 42 ++++++++++---------- src/server.js | 31 +++++++-------- src/wac/parser.js | 32 +++++++++++++++ test/wac.test.js | 83 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 39 deletions(-) diff --git a/src/handlers/container.js b/src/handlers/container.js index a384e84..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,43 +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). + // 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). The WebID lives at - // /profile/card.jsonld#me, so each .acl references it relative to - // that .acl's container — './profile/card.jsonld#me' from the root, - // '../profile/card.jsonld#me' from an immediate child folder, etc. - // This keeps the on-disk pod portable across hostnames; the absolute - // podUri is no longer baked into either accessTo or agent. - const ownerFromRoot = './profile/card.jsonld#me'; - const ownerFromChild = '../profile/card.jsonld#me'; - const ownerFromProfile = './card.jsonld#me'; - - const rootAcl = generateOwnerAcl('./', ownerFromRoot, true); + // 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('./', ownerFromChild); + const privateAcl = generatePrivateAcl('./', owner('private/')); await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl('./', ownerFromChild); + 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. - // The .acl's base URL is /settings/, so the agent is one level up. - const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', ownerFromChild, 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('./', ownerFromChild); + const inboxAcl = generateInboxAcl('./', owner('inbox/')); await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl('./', ownerFromChild); + 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('./', ownerFromProfile); + 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 b488ddf..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/'); @@ -966,34 +966,33 @@ export function createServer(options = {}) { // 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. New single-user pods only - // ever use the modern card.jsonld profile layout (legacy `card` pods - // are detected and skipped in the caller), so the relative WebID is - // safe to hardcode. - const ownerFromRoot = './profile/card.jsonld#me'; - const ownerFromChild = '../profile/card.jsonld#me'; - const ownerFromProfile = './card.jsonld#me'; - - const rootAcl = generateOwnerAcl('./', ownerFromRoot, true); + // 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('./', ownerFromChild); + const privateAcl = generatePrivateAcl('./', owner('private/')); await storage.write('/private/.acl', serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl('./', ownerFromChild); + 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', ownerFromChild, false); + const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', owner('settings/'), false); await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl)); - const inboxAcl = generateInboxAcl('./', ownerFromChild); + const inboxAcl = generateInboxAcl('./', owner('inbox/')); await storage.write('/inbox/.acl', serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl('./', ownerFromChild); + const publicAcl = generatePublicFolderAcl('./', owner('public/')); await storage.write('/public/.acl', serializeAcl(publicAcl)); - const profileAcl = generatePublicFolderAcl('./', ownerFromProfile); + 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 6319cd4..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. diff --git a/test/wac.test.js b/test/wac.test.js index 4bee213..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'; @@ -339,6 +340,86 @@ describe('WAC Parser', () => { `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