// The one WAC shape Festipod adds to make a pod resource world-readable — `acl:Read` for // `acl:agentClass foaf:Agent` — plus how to read whether it is present and how to take exactly // that shape away again. // // How the pod's ACL API behaves (@semapps/webacl 1.2.7, read in setRights.ts + utils.ts, confirmed // live 2026-10-12): `PATCH {aclUri}` only ADDS triples; `PUT {aclUri}` REPLACES the resource's own // rights, computed as a diff over AGENT triples (`acl:agent`, `acl:agentClass`, `acl:agentGroup`, // `acl:origin`) keyed by the resource's own `#Read`/`#Write`/`#Append`/`#Control` authorizations. // Inherited `#Default*` rules come from the container, are not the resource's own, and are left // untouched by a PUT. An EMPTY own rule set is refused (400 "PUT rights cannot be empty"). // // A Festipod event's only OWN rule is `#Read` with `acl:agentClass foaf:Agent` (the grant this // backend adds); the owner reads through the container's `#Default` rules, not an own rule. So // removing the foaf:Agent triple alone would leave the own set empty and the PUT would be refused. // `revocationGraph` therefore keeps the owner as `acl:agent` of `#Read` when nothing else remains — // the owner already reads via the default, so no right is gained or lost — and preserves any other // own agent triples (e.g. the `/contacts` group a participations collection grants read to) unchanged. // // NARROW: only the public READ goes. The pod keys own rules by mode (`#Read`, `#Write`, `#Append`, // `#Control`); the `foaf:Agent` triple is removed from the `#Read` rule alone, so a `foaf:Agent` on // another own rule (a world-appendable collection, say) is left exactly as it was. // // INHERITED public read cannot be taken away here: a `#Default*` rule of the container is not the // resource's own, and a PUT on the resource's ACL leaves it in place. `hasOwnPublicRead` and // `hasInheritedPublicRead` tell the two apart so the caller can say which one it met. // // The platform's own `pod-permissions.remove` cannot do this: it matches the full foaf IRI against // the compacted `foaf:Agent` the pod returns (removes nothing), and it drops any authorization // whose removed predicate becomes empty even when other agent predicates remain (would empty the // set → 400). This module PUTs a correct graph instead. // // `ownReaders` says who a resource's own `#Read` rule lets read (its agents, groups and classes): // participations.service.js uses it to tell whether the `/contacts` WAC group it grants read to a // participations collection is already granted. // // Two pod calls go with the graphs: `readAcl` (the ACL as the pod serves it to the app, a failure // told apart from "no public read") and `putOwnRights` (the PUT, as the app actor). Every Festipod // caller that reads or replaces an ACL goes through them: public-read (events and their attendee // lists) and participations (a person's participations collection). const { arrayOf } = require('@semapps/ldp'); const ACL = 'http://www.w3.org/ns/auth/acl#'; const FOAF = 'http://xmlns.com/foaf/0.1/'; const FOAF_AGENT = FOAF + 'Agent'; const OWN_SUFFIXES = ['Read', 'Write', 'Append', 'Control']; const AGENT_PREDICATES = ['agent', 'agentClass', 'agentGroup', 'origin']; /** Expands `acl:`/`foaf:` CURIEs and `{ '@id' }` / `{ id }` nodes to full IRIs; passes others through. */ const expand = value => { const id = typeof value === 'object' && value !== null ? value['@id'] || value.id : value; if (typeof id !== 'string') return id; if (id.startsWith('acl:')) return ACL + id.slice('acl:'.length); if (id.startsWith('foaf:')) return FOAF + id.slice('foaf:'.length); return id; }; /** The id an authorization node carries (`@id` or `id`). */ const authId = authorization => (authorization && (authorization['@id'] || authorization.id)) || ''; /** The values of `acl:` on an authorization, under its compacted or full-IRI key (raw, not expanded). */ const rawValuesOf = (authorization, name) => [ ...arrayOf(authorization['acl:' + name]), ...arrayOf(authorization[ACL + name]) ]; const expandedValuesOf = (authorization, name) => rawValuesOf(authorization, name).map(expand); /** True when the authorization is a Read rule that lets any agent (foaf:Agent) read. */ const isPublicRead = authorization => expandedValuesOf(authorization, 'mode').includes(ACL + 'Read') && expandedValuesOf(authorization, 'agentClass').includes(FOAF_AGENT); /** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */ const grantsPublicRead = authorizations => arrayOf(authorizations).some(isPublicRead); /** The fragment of an authorization's id (`Read` for `…#Read`, `DefaultRead` for an inherited rule). */ const suffixOf = authorization => String(authId(authorization)).split('#')[1]; /** An authorization is the resource's OWN (not an inherited `#Default*`) when its id ends `#`. */ const isOwnAuthorization = authorization => OWN_SUFFIXES.includes(suffixOf(authorization)); /** True when the resource's OWN `#Read` rule lets any agent (foaf:Agent) read — what `revocationGraph` removes. */ const hasOwnPublicRead = authorizations => arrayOf(authorizations).some(authorization => suffixOf(authorization) === 'Read' && isPublicRead(authorization)); /** True when a rule that is NOT the resource's own (an inherited `#Default*`) lets any agent read. */ const hasInheritedPublicRead = authorizations => arrayOf(authorizations).some(authorization => !isOwnAuthorization(authorization) && isPublicRead(authorization)); /** * aclContext(aclUri) — the JSON-LD context to PUT with a revocation graph: the `acl:`/`foaf:` * prefixes and the id-typed agent/accessTo/mode terms, based at `aclUri` so `#Read` resolves to the * authorization fragment. Mirrors the pod's own `getAclContext` so the PUT round-trips identically. */ const aclContext = aclUri => ({ '@base': aclUri, acl: ACL, foaf: FOAF, 'acl:agent': { '@type': '@id' }, 'acl:agentClass': { '@type': '@id' }, 'acl:agentGroup': { '@type': '@id' }, 'acl:origin': { '@type': '@id' }, 'acl:mode': { '@type': '@id' }, 'acl:accessTo': { '@type': '@id' } }); /** * revocationGraph(authorizations, resourceUri, ownerUri, aclUri) → a JSON-LD document `{ '@context', * '@graph' }` to PUT on the resource's ACL so it keeps every one of its OWN agent triples EXCEPT * `acl:agentClass foaf:Agent` on its `#Read` authorization (a `foaf:Agent` on any other own rule * stays). The other own rules (`#Write`, `#Append`, `#Control`) stay exactly as they are. Inherited * `#Default*` rules are not emitted (a PUT does not touch them). When removing the public-read triple * would leave the own set empty, the owner is emitted as `acl:agent` of `#Read` so the PUT is accepted * without changing any right. `aclUri` is where the document will be PUT (used only for the context `@base`). */ const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => { const graph = []; for (const authorization of arrayOf(authorizations)) { if (!isOwnAuthorization(authorization)) continue; const id = authId(authorization); const isReadRule = suffixOf(authorization) === 'Read'; const node = { '@id': id, '@type': 'acl:Authorization', 'acl:accessTo': resourceUri }; for (const mode of rawValuesOf(authorization, 'mode')) { node['acl:mode'] = node['acl:mode'] ? [].concat(node['acl:mode'], mode) : mode; } let kept = 0; for (const name of AGENT_PREDICATES) { const values = rawValuesOf(authorization, name).filter( value => !(isReadRule && name === 'agentClass' && expand(value) === FOAF_AGENT) ); if (values.length > 0) { node['acl:' + name] = values.length === 1 ? values[0] : values; kept += values.length; } } if (kept > 0) graph.push(node); } // The pod refuses an empty own rule set; keep the owner as a reader (already true via the default). if (graph.length === 0) { graph.push({ '@id': '#Read', '@type': 'acl:Authorization', 'acl:accessTo': resourceUri, 'acl:mode': 'acl:Read', 'acl:agent': ownerUri }); } return { '@context': aclContext(aclUri), '@graph': graph }; }; /** * ownReaders(authorizations) → `{ agent, agentClass, agentGroup }`: the values (full IRIs, without * duplicates) the resource's OWN `#Read` authorization grants read to — not the inherited rules. */ const ownReaders = authorizations => { const readers = { agent: [], agentClass: [], agentGroup: [] }; for (const authorization of arrayOf(authorizations)) { if (suffixOf(authorization) !== 'Read' || !expandedValuesOf(authorization, 'mode').includes(ACL + 'Read')) continue; for (const name of Object.keys(readers)) { for (const iri of expandedValuesOf(authorization, name)) { if (typeof iri === 'string' && !readers[name].includes(iri)) readers[name].push(iri); } } } return readers; }; /** The ACL document URI of a pod resource: `{origin}/_acl{path}` (the pod's `getAclUriFromResourceUri`). */ const aclUriOf = resourceUri => { const { origin, pathname } = new URL(resourceUri); return `${origin}/_acl${pathname}`; }; /** * readAcl(ctx, resourceUri, actorUri) → `{ status, authorizations }`, the authorizations only when * the pod answered 200. Unlike `pod-permissions.get`, which answers `false` for ANY failure — * indistinguishable from "no public read" — a failure here is reported, so a caller can tell "not * public" from "unknown". Read where `putOwnRights` writes (`aclUriOf`), through the pod owner's * access (`actorUri`). */ async function readAcl(ctx, resourceUri, actorUri) { const { status, body } = (await ctx.call('pod-permissions.fetch', { url: aclUriOf(resourceUri), headers: { Accept: 'application/ld+json' }, actorUri })) || {}; if (status !== 200 || !body || typeof body !== 'object') return { status: typeof status === 'number' ? status : 0 }; const graph = body['@graph'] !== undefined ? body['@graph'] : body['@id'] || body.id ? [body] : []; return { status, authorizations: Array.isArray(graph) ? graph : [graph] }; } /** * putOwnRights(ctx, resourceUri, document) → the HTTP status of the PUT of `document` (a * `revocationGraph`) on the resource's ACL, sent as the APP ACTOR through the platform's signature * proxy — the actor that holds `acl:Control` on the resources Festipod grants or revokes on. * Resolves the status (0 when none); the caller decides what a refusal means. */ async function putOwnRights(ctx, resourceUri, document) { const app = await ctx.call('app.get'); const { status } = (await ctx.call('signature.proxy.query', { url: aclUriOf(resourceUri), method: 'PUT', headers: { 'Content-Type': 'application/ld+json' }, body: JSON.stringify(document), actorUri: app.id || app['@id'] })) || {}; return typeof status === 'number' ? status : 0; } module.exports = { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, ownReaders, isPublicRead, expand, aclUriOf, readAcl, putOwnRights, FOAF_AGENT, ACL };