// 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. a future per-contact grant) 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. 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) → 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). 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 }; }; module.exports = { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, isPublicRead, expand, FOAF_AGENT, ACL };