Files
festipod/backend/services/public-read-ledger.js
T
Sylvain Duchesne 88f23b7023 Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404.

Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
2026-10-13 16:30:52 +02:00

61 lines
2.8 KiB
JavaScript

// Which anonymous-read grants are FESTIPOD's — the record that lets public-read revoke only what it
// added.
//
// WHY. A pod resource can be world-readable for reasons that are not Festipod's: its owner shared it
// publicly, another app did, or the pod made it so. When an event stops being public, Festipod must
// take back the public read IT gave and leave any other one alone. The ACL cannot tell them apart (the
// rule is the same `acl:Read` for `foaf:Agent`), so Festipod records each grant it makes, here, and
// `revoke` acts only on a recorded one.
//
// WHAT COUNTS AS FESTIPOD'S:
// - a grant `public-read.grant` added (the resource was not already world-readable);
// - the public read the pod gives a collection Festipod itself created (an event's attendees
// collection: the pod makes it world-readable at creation — observed 2026-10-12/13).
// A resource already world-readable before Festipod touched it is never recorded, so never revoked.
//
// WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph
// (the one Fuseki persists), one triple per resource — subject the resource URI, one Festipod predicate,
// the time of the grant. It survives a backend restart. Grants made before this record existed
// (development data) are not in it, so they are not revoked.
const { iriRef } = require('./catalog-store');
const GRANTED_AT = 'http://festipod.org/publicReadGrantedAt';
/** Record (or re-record) that Festipod made `uri` world-readable. One update = one transaction. */
function recordGrantUpdate(uri) {
const s = iriRef(uri);
return `
DELETE { ${s} <${GRANTED_AT}> ?old }
INSERT { ${s} <${GRANTED_AT}> ?now }
WHERE { OPTIONAL { ${s} <${GRANTED_AT}> ?old } BIND(NOW() AS ?now) }
`;
}
/** Forget Festipod's grant on `uri` — only the ledger's predicate, nothing else about that URI. */
function forgetGrantUpdate(uri) {
return `DELETE WHERE { ${iriRef(uri)} <${GRANTED_AT}> ?at }`;
}
/** Whether Festipod's grant on `uri` is recorded. */
function grantedQuery(uri) {
return `ASK { ${iriRef(uri)} <${GRANTED_AT}> ?at }`;
}
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
async function recordGrant(ctx, { dataset, uri }) {
await ctx.call('triplestore.update', { query: recordGrantUpdate(uri), dataset, webId: 'system' });
}
async function forgetGrant(ctx, { dataset, uri }) {
await ctx.call('triplestore.update', { query: forgetGrantUpdate(uri), dataset, webId: 'system' });
}
/** Resolves true only when the store answers the ASK with `true`. */
async function isGrantedByFestipod(ctx, { dataset, uri }) {
return (await ctx.call('triplestore.query', { query: grantedQuery(uri), dataset, webId: 'system' })) === true;
}
module.exports = { GRANTED_AT, recordGrantUpdate, forgetGrantUpdate, grantedQuery, recordGrant, forgetGrant, isGrantedByFestipod };