Skip to content

Commit fa368e3

Browse files
feat: emit relative acl:agent in pod creation
Phase 2 of JavaScriptSolidServer#427 (JavaScriptSolidServer#430). Builds on JavaScriptSolidServer#429 (relative accessTo/default). The owner WebID lives at <pod>/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 JavaScriptSolidServer#65 / JavaScriptSolidServer#64), so this is a writer-side change only. Existing absolute-agent ACLs on disk continue to authorize correctly. Together with JavaScriptSolidServer#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.
1 parent 8dd60c1 commit fa368e3

4 files changed

Lines changed: 104 additions & 28 deletions

File tree

src/handlers/container.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,31 +195,42 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
195195

196196
// Create default ACL files. Each .acl is written inside the container it
197197
// protects, so the resource it refers to is always './' (resolved against
198-
// the .acl's own URL by the parser — see #428). This keeps pods portable
199-
// across hostnames; the absolute podUri is no longer baked in.
200-
const rootAcl = generateOwnerAcl('./', webId, true);
198+
// the .acl's own URL by the parser — see #428).
199+
//
200+
// The owner WebID is also written relatively (#430). The WebID lives at
201+
// <pod>/profile/card.jsonld#me, so each .acl references it relative to
202+
// that .acl's container — './profile/card.jsonld#me' from the root,
203+
// '../profile/card.jsonld#me' from an immediate child folder, etc.
204+
// This keeps the on-disk pod portable across hostnames; the absolute
205+
// podUri is no longer baked into either accessTo or agent.
206+
const ownerFromRoot = './profile/card.jsonld#me';
207+
const ownerFromChild = '../profile/card.jsonld#me';
208+
const ownerFromProfile = './card.jsonld#me';
209+
210+
const rootAcl = generateOwnerAcl('./', ownerFromRoot, true);
201211
await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
202212

203-
const privateAcl = generatePrivateAcl('./', webId);
213+
const privateAcl = generatePrivateAcl('./', ownerFromChild);
204214
await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
205215

206-
const settingsAcl = generatePrivateAcl('./', webId);
216+
const settingsAcl = generatePrivateAcl('./', ownerFromChild);
207217
await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
208218

209219
// publicTypeIndex: public read, overrides the private default inherited
210220
// from /settings/. This is a resource ACL (lives at .../publicTypeIndex.jsonld.acl),
211221
// so the resource is './publicTypeIndex.jsonld' relative to the parent.
212-
const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', webId, false);
222+
// The .acl's base URL is /settings/, so the agent is one level up.
223+
const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', ownerFromChild, false);
213224
await storage.write(`${podPath}settings/publicTypeIndex.jsonld.acl`, serializeAcl(publicTypeIndexAcl));
214225

215-
const inboxAcl = generateInboxAcl('./', webId);
226+
const inboxAcl = generateInboxAcl('./', ownerFromChild);
216227
await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
217228

218-
const publicAcl = generatePublicFolderAcl('./', webId);
229+
const publicAcl = generatePublicFolderAcl('./', ownerFromChild);
219230
await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
220231

221232
// Profile documents must be publicly readable for WebID verification
222-
const profileAcl = generatePublicFolderAcl('./', webId);
233+
const profileAcl = generatePublicFolderAcl('./', ownerFromProfile);
223234
await storage.write(`${podPath}profile/.acl`, serializeAcl(profileAcl));
224235

225236
// Initialize storage quota if configured

src/server.js

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -964,30 +964,36 @@ export function createServer(options = {}) {
964964
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false });
965965
await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex));
966966

967-
// ACL files. Each .acl uses './' (or a relative basename for resource
968-
// ACLs) so the pod isn't host-locked to whichever interface the server
969-
// happened to bind on first start. The parser resolves these against
970-
// the .acl's own request URL — see #428.
971-
const rootAcl = generateOwnerAcl('./', webId, true);
967+
// ACL files. Both `accessTo` (#428) and `acl:agent` (#430) are written
968+
// relatively so the on-disk pod isn't host-locked to whichever interface
969+
// the server happened to bind on first start. New single-user pods only
970+
// ever use the modern card.jsonld profile layout (legacy `card` pods
971+
// are detected and skipped in the caller), so the relative WebID is
972+
// safe to hardcode.
973+
const ownerFromRoot = './profile/card.jsonld#me';
974+
const ownerFromChild = '../profile/card.jsonld#me';
975+
const ownerFromProfile = './card.jsonld#me';
976+
977+
const rootAcl = generateOwnerAcl('./', ownerFromRoot, true);
972978
await storage.write('/.acl', serializeAcl(rootAcl));
973979

974-
const privateAcl = generatePrivateAcl('./', webId);
980+
const privateAcl = generatePrivateAcl('./', ownerFromChild);
975981
await storage.write('/private/.acl', serializeAcl(privateAcl));
976982

977-
const settingsAcl = generatePrivateAcl('./', webId);
983+
const settingsAcl = generatePrivateAcl('./', ownerFromChild);
978984
await storage.write('/settings/.acl', serializeAcl(settingsAcl));
979985

980986
// publicTypeIndex: public read, overrides the private default inherited from /settings/
981-
const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', webId, false);
987+
const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', ownerFromChild, false);
982988
await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl));
983989

984-
const inboxAcl = generateInboxAcl('./', webId);
990+
const inboxAcl = generateInboxAcl('./', ownerFromChild);
985991
await storage.write('/inbox/.acl', serializeAcl(inboxAcl));
986992

987-
const publicAcl = generatePublicFolderAcl('./', webId);
993+
const publicAcl = generatePublicFolderAcl('./', ownerFromChild);
988994
await storage.write('/public/.acl', serializeAcl(publicAcl));
989995

990-
const profileAcl = generatePublicFolderAcl('./', webId);
996+
const profileAcl = generatePublicFolderAcl('./', ownerFromProfile);
991997
await storage.write('/profile/.acl', serializeAcl(profileAcl));
992998

993999
// Note: Quota not initialized for root-level pods (no user directory)

src/wac/parser.js

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,10 @@ export function generatePublicReadAcl(resourceUrl) {
242242
* Generate a full owner ACL (owner has full control, public read)
243243
* @param {string} resourceUrl - URL of the resource. May be relative (see
244244
* `generatePublicReadAcl`).
245-
* @param {string} ownerWebId - WebID of the owner. Currently absolute;
246-
* relative agent WebIDs are tracked in #427 Phase 2.
245+
* @param {string} ownerWebId - WebID of the owner. May also be relative
246+
* (e.g. './profile/card.jsonld#me' for an in-pod owner) — the parser
247+
* resolves it against the .acl URL at check time. The profile document
248+
* itself still publishes the absolute WebID. See #430.
247249
* @param {boolean} isContainer - Whether this is a container
248250
* @returns {object} JSON-LD ACL document
249251
*/
@@ -290,8 +292,10 @@ export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
290292

291293
/**
292294
* Generate a private ACL (owner only, no public access)
293-
* @param {string} resourceUrl - URL of the resource
294-
* @param {string} ownerWebId - WebID of the owner
295+
* @param {string} resourceUrl - URL of the resource (may be relative — see
296+
* `generatePublicReadAcl`).
297+
* @param {string} ownerWebId - WebID of the owner (may be relative — see
298+
* `generateOwnerAcl`).
295299
* @param {boolean} isContainer - Whether this is a container
296300
* @returns {object} JSON-LD ACL document
297301
*/
@@ -323,8 +327,8 @@ export function generatePrivateAcl(resourceUrl, ownerWebId, isContainer = true)
323327

324328
/**
325329
* Generate an inbox ACL (owner full control, public append)
326-
* @param {string} resourceUrl - URL of the inbox
327-
* @param {string} ownerWebId - WebID of the owner
330+
* @param {string} resourceUrl - URL of the inbox (may be relative).
331+
* @param {string} ownerWebId - WebID of the owner (may be relative).
328332
* @returns {object} JSON-LD ACL document
329333
*/
330334
export function generateInboxAcl(resourceUrl, ownerWebId) {
@@ -363,8 +367,8 @@ export function generateInboxAcl(resourceUrl, ownerWebId) {
363367
/**
364368
* Generate a public folder ACL (owner full control, public read with inheritance)
365369
* Used for /public/ folders where content should be publicly readable
366-
* @param {string} resourceUrl - URL of the folder
367-
* @param {string} ownerWebId - WebID of the owner
370+
* @param {string} resourceUrl - URL of the folder (may be relative).
371+
* @param {string} ownerWebId - WebID of the owner (may be relative).
368372
* @returns {object} JSON-LD ACL document
369373
*/
370374
export function generatePublicFolderAcl(resourceUrl, ownerWebId) {

test/wac.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,61 @@ describe('WAC Parser', () => {
296296
);
297297
});
298298

299+
// Phase 2 of #427 (#430): generators should also preserve a relative
300+
// ownerWebId verbatim, so the on-disk pod is host-portable for the
301+
// owner half of the rule too. The parser already resolves relative
302+
// agents (PR #65 / #64) — this just exercises the writer side.
303+
it('generateOwnerAcl preserves a relative ownerWebId (#430)', () => {
304+
const acl = generateOwnerAcl('./', './profile/card.jsonld#me', true);
305+
const owner = acl['@graph'].find(a => a['@id'] === '#owner');
306+
assert.strictEqual(owner['acl:agent']['@id'], './profile/card.jsonld#me');
307+
});
308+
309+
it('generatePrivateAcl preserves a relative ownerWebId (#430)', () => {
310+
const acl = generatePrivateAcl('./', '../profile/card.jsonld#me');
311+
assert.strictEqual(acl['@graph'][0]['acl:agent']['@id'], '../profile/card.jsonld#me');
312+
});
313+
314+
it('generateInboxAcl preserves a relative ownerWebId (#430)', () => {
315+
const acl = generateInboxAcl('./', '../profile/card.jsonld#me');
316+
const owner = acl['@graph'].find(a => a['@id'] === '#owner');
317+
assert.strictEqual(owner['acl:agent']['@id'], '../profile/card.jsonld#me');
318+
});
319+
320+
it('generatePublicFolderAcl preserves a relative ownerWebId (#430)', () => {
321+
const acl = generatePublicFolderAcl('./', './card.jsonld#me');
322+
const owner = acl['@graph'].find(a => a['@id'] === '#owner');
323+
assert.strictEqual(owner['acl:agent']['@id'], './card.jsonld#me');
324+
});
325+
326+
it('round-trip: relative ownerWebId resolves to .acl base URL on parse (#430)', async () => {
327+
// Same ACL document, two hosts — agent should resolve to whichever
328+
// host asked, just like accessTo. This is what makes the on-disk
329+
// pod portable for the owner half.
330+
const generated = generateOwnerAcl('./', './profile/card.jsonld#me', true);
331+
const wire = serializeAcl(generated);
332+
const auths1 = await parseAcl(wire, 'http://localhost:4444/.acl');
333+
const auths2 = await parseAcl(wire, 'http://0.0.0.0:4444/.acl');
334+
const owner1 = auths1.find(a => a.id === '#owner');
335+
const owner2 = auths2.find(a => a.id === '#owner');
336+
assert.ok(owner1.agents.includes('http://localhost:4444/profile/card.jsonld#me'),
337+
`Expected localhost agent, got: ${JSON.stringify(owner1.agents)}`);
338+
assert.ok(owner2.agents.includes('http://0.0.0.0:4444/profile/card.jsonld#me'),
339+
`Expected 0.0.0.0 agent, got: ${JSON.stringify(owner2.agents)}`);
340+
});
341+
342+
it('round-trip: relative ownerWebId from a child folder resolves correctly (#430)', async () => {
343+
// /pod/private/.acl with agent '../profile/card.jsonld#me'
344+
// should resolve to /pod/profile/card.jsonld#me, not into the
345+
// pod root or escape it.
346+
const generated = generatePrivateAcl('./', '../profile/card.jsonld#me');
347+
const wire = serializeAcl(generated);
348+
const auths = await parseAcl(wire, 'http://localhost:4444/alice/private/.acl');
349+
const owner = auths.find(a => a.id === '#owner');
350+
assert.ok(owner.agents.includes('http://localhost:4444/alice/profile/card.jsonld#me'),
351+
`Expected resolution to /alice/profile/card.jsonld#me, got: ${JSON.stringify(owner.agents)}`);
352+
});
353+
299354
it('round-trip: relative "./" resolves to the .acl base URL on parse', async () => {
300355
const generated = generateOwnerAcl('./', webId, true);
301356
const wire = serializeAcl(generated);

0 commit comments

Comments
 (0)