Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions src/handlers/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
29 changes: 17 additions & 12 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/');
Expand All @@ -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)
Expand Down
52 changes: 44 additions & 8 deletions src/wac/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pod>/.acl`, `'private/'`
* for `<pod>/private/.acl`, `'settings/'` for the resource ACL
* `<pod>/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.
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 137 additions & 1 deletion test/wac.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
generateInboxAcl,
generatePublicFolderAcl,
generatePublicReadAcl,
serializeAcl
serializeAcl,
relativizeOwnerWebId
} from '../src/wac/parser.js';
import { checkAccess, getRequiredMode } from '../src/wac/checker.js';

Expand Down Expand Up @@ -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 "./<tail>" from the pod root', () => {
assert.strictEqual(
relativizeOwnerWebId(`${podUri}profile/card.jsonld#me`, podUri, ''),
'./profile/card.jsonld#me'
);
});

it('emits "../<tail>" 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);
Expand Down