Files
festipod/backend/services/public-read.service.js
Sylvain Duchesne 908f7ea973 Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00

332 lines
16 KiB
JavaScript

const urlJoin = require('url-join');
const { PodResourcesHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { hasPublicIntent } = require('./public-intent');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { neverThrowingMatcher, verifiedEmit, uriOf, singleUriOf, hasAsType } = require('./emitted-activity');
const {
grantsPublicRead,
hasOwnPublicRead,
hasInheritedPublicRead,
revocationGraph,
aclUriOf,
readAcl,
putOwnRights,
FOAF_AGENT
} = require('./public-read-acl');
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
/** The attendees collection an event names (compacted or full-IRI key), when it names exactly one. */
const attendeesCollectionOf = event => singleUriOf(event['apods:attendees'] ?? event[APODS_ATTENDEES]);
// Festipod creates its events and meeting points public: their bindings declare visibility
// 'public', and its create path addresses each one to the ActivityStreams Public collection
// (`src/shared/refine/providers/activitypods.ts`). This service owns the one WAC shape that makes
// a pod resource world-readable — `acl:Read` for `acl:agentClass foaf:Agent`, nothing more — and
// keeps it in step with the object's CURRENT public intent (`public-intent.js`), acting as the app
// actor: an `as:Event` addressed to the Public collection is world-readable, one that is not is not.
// Its attendees collection follows it both ways (the pod creates that collection world-readable).
// It takes back ONLY the public reads Festipod gave — recorded in `public-read-ledger.js` — and leaves
// any other one (the owner's, another app's) untouched.
//
// It learns of changes through the app's watcher on each user's outbox — the `Create` and `Update`
// of any `as:Event`, whichever client made it and whatever its audience — and every notification
// leads to the same `reconcile`: under the event's lock (`keyed-serializer.js`), read the event NOW,
// then grant or revoke on the event and its collection. The notification itself only names the
// object; whatever order notifications arrive or run in, the last action follows the newest read.
module.exports = {
name: 'public-read',
mixins: [PodResourcesHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
},
dependencies: ['pod-permissions'],
created() {
this.serialize = createKeyedSerializer();
},
// The mixin's default handlers would act on the activity's self-declared `actor` and on the
// object embedded in the (unauthenticated) webhook body. Override the whole map: keep the
// mixin's create/update matchers (made never-throwing), but every `onEmit` acts only on an
// activity `verifiedEmit` accepts — re-read from the pod as the app, attributed to the owner of
// the outbox the webhook fired on, its object an `as:Event` in that owner's pod — and even then
// only as a pointer to the event `reconcile` reads again. No delete handler: when an event is
// deleted its ACL goes with it.
activities: {
create: {
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
onEmit: verifiedEmit(
'Create',
async function (ctx, { objectUri }, actorUri) {
await this.onCreate(ctx, objectUri, actorUri);
},
{ objectType: 'Event' }
)
},
update: {
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
onEmit: verifiedEmit(
'Update',
async function (ctx, { objectUri }, actorUri) {
await this.onUpdate(ctx, objectUri, actorUri);
},
{ objectType: 'Event' }
)
}
},
actions: {
/**
* Brings the public read of an event of `actorUri`'s pod — and of its attendees collection — in
* line with the event's current public intent. Serialized per event: the event is read when the
* previous reconcile of the same event has finished, and that read decides. Resolves
* `{ isPublic, targets }`, or undefined when the event could not be judged (gone, unreadable,
* not an `as:Event`, not in the actor's pod). A failed grant or revoke is logged, never thrown.
* A reconcile that timed out and then WROTE (a write in flight at the timeout that landed after
* it) is followed by ONE fresh reconcile of the same event, which judges that late write against
* the state as it then is; that re-decision schedules nothing more, and a timed-out reconcile that
* wrote nothing late schedules nothing at all (see `keyed-serializer.js` `onLateSettle`).
*/
reconcile: {
params: {
resourceUri: { type: 'string' },
actorUri: { type: 'string' }
},
async handler(ctx) {
const { resourceUri, actorUri } = ctx.params;
return this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal), {
onLateSettle: result => {
if (result && result.wroteLate) this.redecideOnce(ctx, resourceUri, actorUri);
}
});
}
},
/** `grantNow` outside any lock (see there). */
grant: {
params: {
resourceUri: { type: 'string' },
actorUri: { type: 'string' }
},
async handler(ctx) {
return this.grantNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
}
},
/**
* Records that Festipod created `resourceUri` (an event's attendees collection, right after it is
* attached): whatever public read it has is Festipod's doing — the pod makes such collections
* world-readable — so `revoke` may take it back.
*/
recordCreated: {
params: {
resourceUri: { type: 'string' },
actorUri: { type: 'string' }
},
async handler(ctx) {
const { resourceUri } = ctx.params;
await recordGrant(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri });
this.logger.info(`Recorded ${resourceUri}, created by Festipod, as Festipod's to make public or not`);
return true;
}
},
/** `revokeNow` outside any lock (see there). */
revoke: {
params: {
resourceUri: { type: 'string' },
actorUri: { type: 'string' }
},
async handler(ctx) {
return this.revokeNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
}
}
},
methods: {
// The mixin's matchers fire only when these exist. Both take the object (or its URI) as a
// POINTER: the decision is `reconcile`'s, on a read taken when acting. They never throw: the
// watcher runs every matching handler of an activity in sequence without catching, inside a
// webhook call the platform does not await.
async onCreate(ctx, resource, actorUri) {
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
},
async onUpdate(ctx, resource, actorUri) {
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
},
async reconcileLogged(ctx, resourceUri, actorUri) {
try {
if (!resourceUri || !actorUri) throw new Error('no event or no actor to reconcile');
await this.actions.reconcile({ resourceUri, actorUri }, { parentCtx: ctx });
} catch (e) {
this.logger.error(`Could not reconcile public read on ${resourceUri}: ${e.message}`);
}
},
// The one re-decision after a late write: a plain serialized reconcile WITHOUT `onLateSettle`, so
// whatever happens to it (even a timeout with a late write of its own) it schedules nothing more.
async redecideOnce(ctx, resourceUri, actorUri) {
try {
await this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal));
} catch (e) {
this.logger.error(`Could not re-decide public read on ${resourceUri} after a late write: ${e.message}`);
}
},
/** The resource's ACL as the pod serves it to the app (`public-read-acl.js` `readAcl`). */
async readAcl(ctx, resourceUri, actorUri) {
return readAcl(ctx, resourceUri, actorUri);
},
/**
* Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was
* read and shows no public read: a resource already world-readable (the owner's share, another
* app's) is left as it is and never recorded, and when the ACL cannot be read nothing is recorded
* or written (the next notification retries). The record is written BEFORE the grant, so a
* failure in between never leaves an unrecorded Festipod grant behind (which `revoke` would then
* never take back). `signal` (from the event's lock) is checked immediately before each write;
* the one write past it is re-recording a grant that LANDED late (a fact, see the code).
* Resolves `true` when it added the grant, `false` otherwise; throws on a refusal or an unreadable ACL.
*/
async grantNow(ctx, resourceUri, actorUri, signal, late = {}) {
const aborted = () => !!(signal && signal.aborted);
// A write that RETURNS once the time has run out may have landed after a newer decision read.
const wrote = () => {
if (aborted()) late.wrote = true;
};
const acl = await this.readAcl(ctx, resourceUri, actorUri);
if (!acl.authorizations) throw new Error(`the ACL of ${resourceUri} could not be read (HTTP ${acl.status}): nothing granted or recorded`);
if (grantsPublicRead(acl.authorizations)) {
this.logger.info(`${resourceUri} is already publicly readable`);
return false;
}
const dataset = CONFIG.MAIN_DATASET;
if (aborted()) return false;
await recordGrant(ctx, { dataset, uri: resourceUri });
wrote();
if (aborted()) return false;
const added = await ctx.call('pod-permissions.add', {
uri: resourceUri,
agentPredicate: 'acl:agentClass',
agentUri: FOAF_AGENT,
mode: 'acl:Read',
actorUri
});
if (!added) {
if (!aborted()) await forgetGrant(ctx, { dataset, uri: resourceUri }).catch(() => {});
throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
}
if (aborted()) {
late.wrote = true;
// The grant landed after the time ran out, and a newer decision may have run meanwhile and
// forgotten the record (it saw no public read yet). A grant Festipod made must stay on record —
// this writes a FACT, not a decision — so the re-decision that follows (`onLateSettle`) can
// judge it against the current state and take it back if the event is no longer public.
await recordGrant(ctx, { dataset, uri: resourceUri });
this.logger.warn(`Public read on ${resourceUri} landed after its time ran out: re-recorded, to be re-decided`);
return true;
}
this.logger.info(`Granted public read on ${resourceUri}`);
return true;
},
/**
* The reverse of `grantNow`, for a grant FESTIPOD made (recorded in the ledger) and only for it:
* a public read Festipod did not add — the owner's, another app's, the pod's — is left untouched
* and logged. Removes ONLY the `acl:agentClass foaf:Agent` triple of the resource's own `#Read`
* rule, leaving every other OWN authorization (a `foaf:Agent` on another mode included) and the
* inherited owner defaults in place, then forgets the record. When the ACL cannot be read, the
* state is unknown: nothing changes and the record is KEPT, so a later notification retries.
* Idempotent: with the own public-read rule already gone, it only forgets the record. A public
* read INHERITED from the container cannot be removed this way: it is reported as a warning. Not
* usable via `pod-permissions.remove` (see `public-read-acl.js`): it PUTs a reconstructed
* own-rights graph, acting as the app actor (the same actor `grant` writes as, which holds
* `acl:Control` on the resource). `signal` is checked immediately before each write. Resolves
* `true` when it removed the rule, `false` otherwise; throws when the pod refuses the change.
*/
async revokeNow(ctx, resourceUri, actorUri, signal, late = {}) {
const aborted = () => !!(signal && signal.aborted);
const wrote = () => {
if (aborted()) late.wrote = true;
};
const dataset = CONFIG.MAIN_DATASET;
const acl = await this.readAcl(ctx, resourceUri, actorUri);
if (!(await isGrantedByFestipod(ctx, { dataset, uri: resourceUri }))) {
if (acl.authorizations && grantsPublicRead(acl.authorizations)) {
this.logger.info(`${resourceUri} is publicly readable, but not through a grant Festipod made: left untouched`);
}
return false;
}
if (!acl.authorizations) {
this.logger.warn(`The ACL of ${resourceUri} could not be read (HTTP ${acl.status}): Festipod's grant kept, a later notification retries`);
return false;
}
const inherited = hasInheritedPublicRead(acl.authorizations);
if (!hasOwnPublicRead(acl.authorizations)) {
if (aborted()) return false;
await forgetGrant(ctx, { dataset, uri: resourceUri });
wrote();
if (inherited) {
this.logger.warn(`${resourceUri} is publicly readable through a rule inherited from its container: not revocable here`);
} else {
this.logger.info(`${resourceUri} is not publicly readable: nothing to revoke`);
}
return false;
}
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUriOf(resourceUri));
if (aborted()) return false;
const status = await putOwnRights(ctx, resourceUri, document);
if (status !== 204 && status !== 200) {
throw new Error(`The pod refused to revoke the public-read grant on ${resourceUri} (HTTP ${status})`);
}
if (aborted()) {
late.wrote = true; // the PUT landed after the time ran out: to be re-decided
return true;
}
await forgetGrant(ctx, { dataset, uri: resourceUri });
wrote();
this.logger.info(`Revoked public read on ${resourceUri}`);
if (inherited) {
this.logger.warn(`${resourceUri} stays publicly readable through a rule inherited from its container`);
}
return true;
},
// Runs under the event's lock (see `reconcile`). The read here is the one that decides. Once
// `signal` is aborted (the work timed out and the lock moved on) it writes nothing more; it
// resolves `wroteLate: true` when a write it had sent returned after that (see `reconcile`).
async reconcileNow(ctx, resourceUri, actorUri, signal) {
if (!isInActorPod(resourceUri, actorUri)) {
this.logger.warn(`Public read not reconciled on ${resourceUri}: not in the pod of ${actorUri}`);
return undefined;
}
const { status, body } = await ctx.call('pod-resources.get', { resourceUri, actorUri });
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
this.logger.info(`${resourceUri} is gone: its ACL went with it`);
return undefined;
}
if (status !== 200 || !body || uriOf(body) !== resourceUri) {
this.logger.warn(`Public read not reconciled on ${resourceUri}: the pod does not return it (HTTP ${status})`);
return undefined;
}
if (!hasAsType(body, 'Event')) {
this.logger.warn(`Public read not reconciled on ${resourceUri}: not an as:Event`);
return undefined;
}
const isPublic = hasPublicIntent(body);
const targets = [resourceUri];
const collectionUri = attendeesCollectionOf(body);
if (collectionUri && isInActorPod(collectionUri, actorUri)) targets.push(collectionUri);
else if (collectionUri) this.logger.warn(`${resourceUri} names an attendees collection outside its pod: left alone`);
if (!isPublic) this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
const late = { wrote: false };
for (const uri of targets) {
if (signal && signal.aborted) break;
try {
if (isPublic) await this.grantNow(ctx, uri, actorUri, signal, late);
else await this.revokeNow(ctx, uri, actorUri, signal, late);
} catch (e) {
this.logger.error(`Could not ${isPublic ? 'grant' : 'revoke'} public read on ${uri}: ${e.message}`);
}
}
return { isPublic, targets, wroteLate: late.wrote };
}
}
};