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.
This commit is contained in:
Sylvain Duchesne
2026-10-13 16:30:52 +02:00
parent d38f8a7384
commit 88f23b7023
21 changed files with 3362 additions and 243 deletions
+88
View File
@@ -0,0 +1,88 @@
// The last participation act applied for each (meeting point, participant) — what makes attendance
// independent of the order notifications arrive in.
//
// WHY. A participant's Join and Leave reach the backend as separate notifications, handled
// concurrently; the per-meeting-point lock (`keyed-serializer.js`) serializes them, but in the order
// their handlers reach it — after verification reads of unequal duration — not in the order the
// participant acted. "Join then Leave" could be applied as Leave (nothing to remove) then Join, and leave
// her listed. A Join or a Leave says what to do, not what the state should be, so the decision must
// compare it with what was already applied.
//
// THE RULE (`isNewerAct`). Each act is placed by the time the participant's pod stamped on it — its
// `published` (the pod overwrites any client value when the activity is posted to the outbox) — then by
// its URI, to break a tie. Under the lock, an act is applied only when it is newer than the last act
// applied for that pair; an older one (a late notification, a replay) changes nothing. Whatever the
// arrival order, the pair ends in the state of the participant's latest act: "join then leave" ends not
// attending, "leave then join" ends attending. All acts of one participant are stamped by her own pod,
// so they share one clock.
//
// WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph
// (persisted by Fuseki), so the rule holds across a backend restart. One entry per pair, under a minted
// `urn:festipod:attendance:` subject, with three Festipod predicates: the act's URI, its stamp, its kind.
const { iriRef, stringLiteral } = require('./catalog-store');
const ACT = 'http://festipod.org/attendanceAct';
const PUBLISHED = 'http://festipod.org/attendancePublished';
const CHANGE = 'http://festipod.org/attendanceChange';
/** The subject of the (meeting point, participant) entry: both URIs percent-encoded into one URN. */
function attendanceSubject(meetingPointUri, memberUri) {
return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(memberUri)}`;
}
/** The last act applied for the pair: one row (act, published, change) or none. */
function lastAttendanceQuery(meetingPointUri, memberUri) {
const s = iriRef(attendanceSubject(meetingPointUri, memberUri));
return `SELECT ?act ?published ?change WHERE { ${s} <${ACT}> ?act ; <${PUBLISHED}> ?published ; <${CHANGE}> ?change } LIMIT 1`;
}
/** Replace the pair's entry with `{ activityUri, published, change }`. One update = one transaction. */
function recordAttendanceUpdate(meetingPointUri, memberUri, { activityUri, published, change }) {
const s = iriRef(attendanceSubject(meetingPointUri, memberUri));
return `
DELETE { ${s} ?p ?o }
INSERT { ${s} <${ACT}> ${iriRef(activityUri)} ; <${PUBLISHED}> ${stringLiteral(published)} ; <${CHANGE}> ${stringLiteral(change)} }
WHERE { OPTIONAL { ${s} ?p ?o . FILTER(?p IN (<${ACT}>, <${PUBLISHED}>, <${CHANGE}>)) } }
`;
}
/**
* isNewerAct(candidate, last) → boolean. `candidate` and `last` are `{ activityUri, published }`
* (`last` undefined when nothing was applied yet). Newer = a later `published` instant, or the same
* instant and a greater URI. A candidate without a readable stamp is never newer (it cannot be placed).
*/
function isNewerAct(candidate, last) {
const at = Date.parse(candidate && candidate.published);
if (Number.isNaN(at)) return false;
if (!last) return true;
const lastAt = Date.parse(last.published);
if (Number.isNaN(lastAt) || at > lastAt) return true;
if (at < lastAt) return false;
return String(candidate.activityUri) > String(last.activityUri);
}
const termValue = term => (term && typeof term === 'object' ? term.value : term);
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
/** The last act applied for the pair, `{ activityUri, published, change }`, or undefined. */
async function lastAttendance(ctx, { dataset, meetingPointUri, memberUri }) {
const rows = await ctx.call('triplestore.query', { query: lastAttendanceQuery(meetingPointUri, memberUri), dataset, webId: 'system' });
const row = Array.isArray(rows) ? rows[0] : undefined;
if (!row) return undefined;
return { activityUri: termValue(row.act), published: termValue(row.published), change: termValue(row.change) };
}
async function recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act }) {
await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, memberUri, act), dataset, webId: 'system' });
}
module.exports = {
attendanceSubject,
lastAttendanceQuery,
recordAttendanceUpdate,
isNewerAct,
lastAttendance,
recordAttendance
};
+47 -47
View File
@@ -1,19 +1,21 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { hasPublicIntent } = require('./public-intent');
const { neverThrowingMatcher, verifiedEmit, uriOf } = require('./emitted-activity');
const { guardedCreateAndAttach } = require('./collection-attach');
// Attaches an `apods:attendees` collection (and a WAC group of the same members) to every
// `as:Event` created in a user's pod that this backend learns of through the outbox watcher —
// plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it.
//
// Who may read the collection: the pod creates it world-readable (`acl:Read` for
// `foaf:Agent`) — a platform default, observed 2026-10-12, not a choice made here. Whether
// attendee lists SHOULD be world-readable is an open product question: the functional-domain
// doctrine places participation in the protected scope. This service does not settle it. On a
// resource carrying Festipod's public intent it also applies `public-read.grant` to the
// collection, which finds it already world-readable and adds nothing; on any other resource the
// backend grants nothing on the collection.
// Who may read the collection: the pod creates it world-readable (`acl:Read` for `foaf:Agent`) — a
// platform default, observed 2026-10-12, not a choice made here. Whether attendee lists of a PUBLIC
// event SHOULD be world-readable is an open product question (the functional-domain doctrine places
// participation in the protected scope); this service does not settle it. What it does settle: the
// collection it creates never stays more readable than its event. Once attached, it records that
// collection's public read as Festipod's (`public-read.recordCreated`) and asks `public-read.reconcile`
// to align the event AND its collection with the event's current public intent — granted while the
// event is public, revoked while it is not, in both directions on every later update.
module.exports = {
name: 'attendees',
mixins: [PodCollectionsHandlerMixin],
@@ -27,57 +29,55 @@ module.exports = {
createWacGroup: true
},
dependencies: ['public-read'],
actions: {
// Replaces the mixin's action of the same name (`PodCollectionsHandlerMixin`,
// @activitypods/app 2.3.2) with one difference. `pod-collections.createAndAttach` returns
// undefined when it attaches nothing — the resource already carries an `apods:attendees`
// value (written by its creator or by another app), or the pod refused to create the
// collection — and the mixin then derives a WAC group slug from that undefined URI:
// `new URL(undefined)` throws. The webhook call that runs the watcher is not awaited by the
// platform, so that throw ended the whole backend process (observed 2026-10-12, an event
// POSTed with `apods:attendees` already set). Here, nothing attached means no group to create.
createAndAttach: {
async handler(ctx) {
const { resourceUri, actorUri } = ctx.params;
const { attachPredicate, collectionOptions, createWacGroup } = this.settings;
const collectionUri = await ctx.call('pod-collections.createAndAttach', {
resourceUri,
attachPredicate,
collectionOptions,
actorUri
});
if (!collectionUri) {
this.logger.info(`No attendees collection attached to ${resourceUri} (already carries one, or creation refused)`);
return undefined;
}
if (createWacGroup) {
await ctx.call('pod-wac-groups.create', {
groupSlug: this.getGroupSlugFromCollectionUri(collectionUri),
actorUri
});
}
return collectionUri;
}
// The mixin's create handler would act on the object embedded in the (unauthenticated) webhook
// body, attributed to the activity's self-declared `actor`. Override it: the matcher stays the
// mixin's (made never-throwing), but the handler runs only for a Create `verifiedEmit` accepts —
// re-read from the pod as the app, attributed to the owner of the outbox the webhook fired on,
// the STORED object, an `as:Event` of that owner's pod — and attaches the collection there.
activities: {
create: {
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
onEmit: verifiedEmit(
'Create',
async function (ctx, { object }, actorUri) {
await this.onCreate(ctx, object, actorUri);
},
{ objectType: 'Event' }
)
}
},
actions: {
// Nothing attached → no WAC group (see collection-attach.js).
createAndAttach: guardedCreateAndAttach
},
methods: {
// Called by `createAndAttach` right after it attached a collection it CREATED, before anything
// that may fail: Festipod created it, so the public read the pod gave it is Festipod's to take back
// (public-read-ledger.js). A collection it did not create (nothing attached) is never recorded,
// so its public read is never revoked. A failure here fails the attach step (logged by onCreate).
async onCollectionAttached(ctx, { collectionUri, actorUri }) {
await ctx.call('public-read.recordCreated', { resourceUri: collectionUri, actorUri });
},
// Replaces the mixin's `onCreate`. The watcher runs the handlers of one `Create` in sequence
// and does not catch, in an order that is not fixed (attendees ran before public-read and
// catalog in the runs observed 2026-10-12): a throw here would skip whichever handlers come
// after it, and — the webhook call being un-awaited — end the process. So a failure is
// logged, never thrown.
async onCreate(ctx, resource, actorUri) {
const resourceUri = resource.id || resource['@id'];
const resourceUri = uriOf(resource);
try {
const collectionUri = await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
if (collectionUri && hasPublicIntent(resource)) {
await ctx.call('public-read.grant', { resourceUri: collectionUri, actorUri });
}
await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
} catch (e) {
this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`);
}
// Whatever happened above, the collection the event now names follows the event's CURRENT
// visibility. `reconcile` reads the event again under its lock, so this cannot race the
// public-read handlers of the same event's later updates.
try {
await ctx.call('public-read.reconcile', { resourceUri, actorUri });
} catch (e) {
this.logger.error(`Could not align the public read of ${resourceUri} and its attendees collection: ${e.message}`);
}
}
}
};
+74 -34
View File
@@ -5,15 +5,13 @@ const CONFIG = require('../config/config');
const { upsertEntry, removeEntry, listEntries, sweepEntries } = require('./catalog-store');
const { hasListedPublicIntent } = require('./public-intent');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { neverThrowingMatcher, verifiedEmit, uriOf, hasAsType } = require('./emitted-activity');
function arrayOf(value) {
return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
}
function uriOf(resource) {
return typeof resource === 'string' ? resource : resource && (resource.id || resource['@id']);
}
/**
* What the catalog may hold and serve: an item addressed to the ActivityStreams Public collection
* in `to` — public AND listed (`hasListedPublicIntent`, built on the public-read gate's own
@@ -24,44 +22,42 @@ function uriOf(resource) {
*/
const isListable = item => hasListedPublicIntent(item);
/**
* A matcher that never throws: on error it logs and reports no match. The watcher runs every
* service's matchers for an activity in sequence without catching, inside a webhook call the
* platform does not await — a throw would skip the handlers after this one and end the process.
*/
function neverThrowingMatcher(match) {
return async function (activity, fetcher) {
try {
return await match.call(this, activity, fetcher);
} catch (e) {
this.logger.error(`Catalog could not match activity ${uriOf(activity)}: ${e.message}`);
return { match: false, dereferencedActivity: activity };
}
};
}
module.exports = {
name: 'catalog',
mixins: [PodResourcesHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
},
created() {
this.serialize = createKeyedSerializer();
},
// Moleculer does not merge a custom schema key: declaring `activities` here replaces the mixin's
// whole map. `create` and `update` keep the mixin's matchers (made never-throwing), but every
// `onEmit` hands the handler the actor the WATCHER resolved — the owner of the outbox the
// webhook fired on — instead of the mixin's `activity.actor`, which the emitting client writes.
// whole map. `create` and `update` keep the mixin's matchers (made never-throwing). Every `onEmit`
// acts only on an activity `verifiedEmit` accepts (`emitted-activity.js`): re-read from the pod
// with the app's credentials, attributed to the owner of the outbox the webhook fired on, its
// object an `as:Event` of that owner's pod — and the actor passed on is that owner, never the
// activity's own `actor`. Even then the activity only POINTS at the event: `sync` reads it again,
// under the event's lock, and that read decides (see `sync`).
activities: {
create: {
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
async onEmit(ctx, activity, actorUri) {
await this.onCreate(ctx, activity.object, actorUri);
}
onEmit: verifiedEmit(
'Create',
async function (ctx, { objectUri }, actorUri) {
await this.sync(ctx, objectUri, actorUri);
},
{ objectType: 'Event' }
)
},
update: {
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
async onEmit(ctx, activity, actorUri) {
await this.onUpdate(ctx, activity.object, actorUri);
}
onEmit: verifiedEmit(
'Update',
async function (ctx, { objectUri }, actorUri) {
await this.sync(ctx, objectUri, actorUri);
},
{ objectType: 'Event' }
)
},
// The mixin's delete handler never fires for a deleted event: it matches the
// Tombstone's `formerType` against the COMPACTED type (`Event`) while the
@@ -70,8 +66,8 @@ module.exports = {
// pattern here — an emitted Delete of a Tombstone whose former type is this
// service's type — with both types compared EXPANDED, routed to `onDelete`.
// The Tombstone may be EMBEDDED in the activity (the pod accepts a Delete of any URI
// with an embedded Tombstone, verified 2026-10-12): it is then used as written, so
// only `onDelete`'s ownership test stands between it and someone else's entry.
// with an embedded Tombstone, verified 2026-10-12), so the match proves nothing:
// `verifiedEmit` de-indexes only when the pod says the object is really gone.
delete: {
match: neverThrowingMatcher(async function (activity, fetcher) {
const pattern = { type: ACTIVITY_TYPES.DELETE, object: { type: OBJECT_TYPES.TOMBSTONE } };
@@ -91,9 +87,9 @@ module.exports = {
return { match: false, dereferencedActivity };
}
}),
async onEmit(ctx, activity, actorUri) {
await this.onDelete(ctx, uriOf(activity.object), actorUri);
}
onEmit: verifiedEmit('Delete', async function (ctx, { objectUri }, actorUri) {
await this.sync(ctx, objectUri, actorUri);
})
}
},
/**
@@ -133,6 +129,50 @@ module.exports = {
// removes another actor's entry; (2) only then is the listing rule applied; (3) only then is the
// store written. A handler never throws: it runs inside the watcher (see `neverThrowingMatcher`).
methods: {
/**
* Where every notification about an event ends: under that event's lock (`keyed-serializer.js`),
* read it NOW from the pod and bring its entry in line — gone (or no longer an `as:Event`) →
* de-indexed; there → `onUpdate` (indexed if listed public, de-indexed otherwise). Notifications
* run concurrently and in no guaranteed order; serialized with the read inside, the last one to
* run acts on the newest state, so an older notification never overrides a newer one. A read
* that fails (unreachable, refused) changes nothing; a sync that times out (keyed-serializer.js)
* writes nothing once its time is out, and one whose write landed after it is followed by ONE
* re-sync (`followUp: false`, so it never chains). Never throws.
*/
async sync(ctx, uri, actorUri, { followUp = true } = {}) {
await this.guarded(`sync ${uri}`, () =>
this.serialize(
uri,
async signal => {
if (!this.isOwnedBy(uri, actorUri, 'sync')) return undefined;
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
// Timed out while reading (the lock has moved on): a newer sync decides, this one writes nothing.
if (signal.aborted) return undefined;
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
await this.onDelete(ctx, uri, actorUri);
} else if (status !== 200 || !body || uriOf(body) !== uri) {
this.logger.warn(`Catalog left ${uri} as it was: the pod does not return it (HTTP ${status})`);
return undefined;
} else if (!hasAsType(body, 'Event')) {
await this.onDelete(ctx, uri, actorUri);
} else {
await this.onUpdate(ctx, body, actorUri);
}
// The store write was sent right after the check above; if the time ran out before it
// returned, it may have landed after a newer sync's.
return { wroteLate: signal.aborted };
},
followUp
? {
// Only a sync whose write landed late is re-judged, once: the re-sync has no follow-up.
onLateSettle: result => {
if (result && result.wroteLate) this.sync(ctx, uri, actorUri, { followUp: false });
}
}
: {}
)
);
},
async onCreate(ctx, resource, actorUri) {
const uri = uriOf(resource);
await this.guarded(`index ${uri}`, async () => {
+207 -15
View File
@@ -17,6 +17,7 @@ function service({ broker } = {}) {
settings: { type: 'as:Event' },
broker: broker || { call: async () => [] }
};
if (catalog.created) catalog.created.call(svc);
for (const [name, fn] of Object.entries(catalog.methods)) svc[name] = fn.bind(svc);
return { svc, logs };
}
@@ -141,26 +142,67 @@ describe('catalog mutations — ownership first, then the listing rule, then the
});
});
describe('catalog activity handlers — the emitting actor is the one the watcher resolved', () => {
test("onEmit uses the outbox owner, not the activity's self-declared actor", async () => {
describe('catalog activity handlers — re-read from the pod, acting for the outbox owner', () => {
// A ctx that answers `pod-resources.get` from a map of uri → { status, body } (the pod re-read),
// and records `triplestore.query`/`triplestore.update` (the store writes), answering queries []`.
function emitCtx(pod = {}) {
const calls = [];
return {
calls,
call: async (action, params) => {
calls.push({ action, params });
if (action === 'pod-resources.get') return pod[params.resourceUri] || { status: 404 };
return action === 'triplestore.query' ? [] : undefined;
}
};
}
const writes = ctx => ctx.calls.filter(c => c.action === 'triplestore.update').map(c => c.params.query);
// A Create/Update/Delete activity stored under Alice's outbox, naming an object in her pod.
const act = (type, objectUri) => ({ id: `${ALICE}/outbox/${type}`, type, actor: ALICE, object: objectUri });
test("a forged activity on Alice's outbox naming Bob's event is refused: nothing is written", async () => {
const { svc, logs } = service();
const ctx = store();
// Alice's outbox carries an activity claiming Bob as its actor, about Bob's event.
const bobs = listedEvent(BOB);
await catalog.activities.create.onEmit.call(svc, ctx, { type: 'Create', actor: BOB, object: bobs }, ALICE);
await catalog.activities.update.onEmit.call(svc, ctx, { type: 'Update', actor: BOB, object: bobs }, ALICE);
await catalog.activities.delete.onEmit.call(svc, ctx, { type: 'Delete', actor: BOB, object: { id: bobs.id, type: 'Tombstone' } }, ALICE);
expect(ctx.calls).toEqual([]);
expect(logs.warn).toHaveLength(3);
// Webhook body: a Create/Update/Delete whose EMBEDDED object is Bob's event, claimed public.
// The watcher resolved actorUri = Alice (her outbox). No valid activity in Alice's pod names it.
const ctx = emitCtx();
await catalog.activities.create.onEmit.call(svc, ctx, { type: 'Create', actor: ALICE, object: bobs }, ALICE);
await catalog.activities.update.onEmit.call(svc, ctx, { type: 'Update', actor: ALICE, object: bobs }, ALICE);
await catalog.activities.delete.onEmit.call(svc, ctx, { type: 'Delete', actor: ALICE, object: { id: bobs.id, type: 'Tombstone' } }, ALICE);
expect(writes(ctx)).toEqual([]);
expect(logs.warn).toHaveLength(3); // each ignored with a reason
});
test("the owner's own activities go through", async () => {
const { svc } = service();
const ctx = store();
test("an activity that the pod does not actually store (webhook-only) is refused", async () => {
const { svc, logs } = service();
const e = listedEvent(ALICE);
await catalog.activities.create.onEmit.call(svc, ctx, { type: 'Create', actor: ALICE, object: e }, ALICE);
await catalog.activities.delete.onEmit.call(svc, ctx, { type: 'Delete', actor: ALICE, object: { id: e.id, type: 'Tombstone' } }, ALICE);
expect(queries(ctx)).toEqual([upsertEntryUpdate(e.id, e), removeEntryUpdate(e.id)]);
const ctx = emitCtx(); // pod returns 404 for the activity
await catalog.activities.create.onEmit.call(svc, ctx, act('Create', e.id), ALICE);
expect(writes(ctx)).toEqual([]);
expect(logs.warn[0]).toContain('ignored a Create');
});
test("the owner's own, really-stored activities go through with the STORED object", async () => {
const { svc } = service();
const e = listedEvent(ALICE);
// The webhook claims a bogus name; the pod holds the real one — the stored object must win.
const stored = { ...e, name: 'the real stored name' };
const create = act('Create', e.id);
const del = act('Delete', e.id);
const ctx = emitCtx({
[create.id]: { status: 200, body: create },
[del.id]: { status: 200, body: del },
[e.id]: { status: 200, body: stored } // for the Create re-read; the Delete re-read also hits it
});
await catalog.activities.create.onEmit.call(svc, ctx, { ...create, object: { ...e, name: 'bogus' } }, ALICE);
// The object is still live → the Delete is not honoured (the deletion is not true).
await catalog.activities.delete.onEmit.call(svc, ctx, del, ALICE);
expect(writes(ctx)).toEqual([upsertEntryUpdate(e.id, stored)]);
// Now the pod says the object is gone → the Delete de-indexes.
const ctx2 = emitCtx({ [del.id]: { status: 200, body: del }, [e.id]: { status: 404 } });
await catalog.activities.delete.onEmit.call(svc, ctx2, del, ALICE);
expect(writes(ctx2)).toEqual([removeEntryUpdate(e.id)]);
});
test('an embedded Tombstone of an event matches the delete handler (it is then judged by ownership)', async () => {
@@ -187,6 +229,156 @@ describe('catalog activity handlers — the emitting actor is the one the watche
});
});
describe('catalog — the decision is taken on the event read when acting, one notification at a time', () => {
const act = (type, objectUri, n = '') => ({ id: `${ALICE}/outbox/${type}${n}`, type, actor: ALICE, object: objectUri });
test('CONCURRENT updates: an older "public" notification never re-lists an event made private', async () => {
const e = listedEvent(ALICE);
let current = e; // what the pod holds
const entries = new Map(); // the catalog store
const upd1 = act('Update', e.id, 1);
const upd2 = act('Update', e.id, 2);
const ctx = {
call: async (action, params) => {
if (action === 'pod-resources.get') {
if (params.resourceUri === upd1.id) return { status: 200, body: upd1 };
if (params.resourceUri === upd2.id) return { status: 200, body: upd2 };
if (params.resourceUri === e.id) return { status: 200, body: current };
return { status: 404 };
}
if (action === 'triplestore.update') {
if (params.query === upsertEntryUpdate(e.id, e)) {
await new Promise(resolve => setTimeout(resolve, 30)); // a slow store write
entries.set(e.id, e);
} else if (params.query === removeEntryUpdate(e.id)) {
entries.delete(e.id);
}
return undefined;
}
return [];
}
};
const { svc } = service();
const first = catalog.activities.update.onEmit.call(svc, ctx, upd1, ALICE); // notified while listed
await new Promise(resolve => setTimeout(resolve, 5));
current = event(ALICE, { name: 'now private' }); // the owner unlists it; a second notification follows
const second = catalog.activities.update.onEmit.call(svc, ctx, upd2, ALICE);
await Promise.all([first, second]);
expect(entries.has(e.id)).toBe(false);
});
test('a store write that lands after its timeout is re-judged: the newer de-listing wins', async () => {
const { createKeyedSerializer } = require('./keyed-serializer');
const e = listedEvent(ALICE);
let current = e;
const entries = new Map();
let upserts = 0;
const upd1 = act('Update', e.id, 1);
const upd2 = act('Update', e.id, 2);
const ctx = {
call: async (action, params) => {
if (action === 'pod-resources.get') {
if (params.resourceUri === upd1.id) return { status: 200, body: upd1 };
if (params.resourceUri === upd2.id) return { status: 200, body: upd2 };
if (params.resourceUri === e.id) return { status: 200, body: current };
return { status: 404 };
}
if (action === 'triplestore.update') {
if (params.query === upsertEntryUpdate(e.id, e)) {
if (upserts++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // sent in time, lands late
entries.set(e.id, e);
} else if (params.query === removeEntryUpdate(e.id)) {
entries.delete(e.id);
}
return undefined;
}
return [];
}
};
const { svc } = service();
svc.serialize = createKeyedSerializer({ timeoutMs: 50 });
const first = catalog.activities.update.onEmit.call(svc, ctx, upd1, ALICE);
await new Promise(resolve => setTimeout(resolve, 60));
current = event(ALICE, { name: 'now private' });
const second = catalog.activities.update.onEmit.call(svc, ctx, upd2, ALICE);
await Promise.all([first, second]);
await new Promise(resolve => setTimeout(resolve, 250));
expect(entries.has(e.id)).toBe(false);
});
test('a slow pod and NO late write: a timed-out sync schedules nothing (no self-re-queuing loop)', async () => {
const { createKeyedSerializer } = require('./keyed-serializer');
const e = listedEvent(ALICE);
const upd = act('Update', e.id, 'loop');
let eventReads = 0;
let writes = 0;
const ctx = {
call: async (action, params) => {
if (action === 'pod-resources.get') {
if (params.resourceUri === upd.id) return { status: 200, body: upd };
if (params.resourceUri === e.id) {
eventReads++;
await new Promise(resolve => setTimeout(resolve, 120)); // every read slower than the limit
return { status: 200, body: e };
}
return { status: 404 };
}
if (action === 'triplestore.update') writes++;
return [];
}
};
const { svc, logs } = service();
svc.serialize = createKeyedSerializer({ timeoutMs: 50 });
await catalog.activities.update.onEmit.call(svc, ctx, upd, ALICE);
await new Promise(resolve => setTimeout(resolve, 1000));
expect(eventReads).toBe(2); // the verification read and the one sync read — nothing re-queued
expect(writes).toBe(0);
expect(logs.error).toHaveLength(1); // the one timeout
});
test('a public Note stored at the URI is never indexed (the stored type is re-checked)', async () => {
const { svc, logs } = service();
const note = { id: `${ALICE}/data/n1`, type: 'Note', to: 'as:Public', content: 'not an event' };
const create = act('Create', note.id);
const calls = [];
const ctx = {
call: async (action, params) => {
calls.push(action);
if (action === 'pod-resources.get') {
if (params.resourceUri === create.id) return { status: 200, body: create };
if (params.resourceUri === note.id) return { status: 200, body: note };
return { status: 404 };
}
return [];
}
};
await catalog.activities.create.onEmit.call(svc, ctx, create, ALICE);
expect(calls.filter(a => a.startsWith('triplestore'))).toEqual([]);
expect(logs.warn[0]).toContain('is not a Event');
});
test('an unreadable event (pod error) is left as it was: nothing written', async () => {
const e = listedEvent(ALICE);
const update = act('Update', e.id);
let objectReads = 0;
const calls = [];
const ctx = {
call: async (action, params) => {
calls.push(action);
if (action !== 'pod-resources.get') return [];
if (params.resourceUri === update.id) return { status: 200, body: update };
objectReads += 1;
// The verification read sees the event; the read taken when acting fails.
return objectReads === 1 ? { status: 200, body: e } : { status: 502 };
}
};
const { svc, logs } = service();
await catalog.activities.update.onEmit.call(svc, ctx, update, ALICE);
expect(calls.filter(a => a.startsWith('triplestore'))).toEqual([]);
expect(logs.warn[0]).toContain('HTTP 502');
});
});
describe('catalog listing — only listed public items are served, shape unchanged', () => {
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
+45
View File
@@ -0,0 +1,45 @@
// The `createAndAttach` action every Festipod collection handler uses (attendees, contacts), in place
// of the one `PodCollectionsHandlerMixin` (@activitypods/app 2.3.2) gives them.
//
// The mixin's version calls `pod-collections.createAndAttach`, then derives a WAC group slug from the
// collection URI it returns. That call returns undefined when it attaches nothing — the resource
// already carries a value for the predicate (written by its creator or by another app), or the pod
// refused to create the collection — and the mixin then runs `new URL(undefined)`, which throws. The
// webhook call that runs the watcher is not awaited by the platform, so that throw ended the whole
// backend process (observed 2026-10-12, an event POSTed with `apods:attendees` already set). Here,
// nothing attached means no group to create.
/** The action definition; runs bound to a service with the mixin's settings and `getGroupSlugFromCollectionUri`. */
const guardedCreateAndAttach = {
async handler(ctx) {
const { resourceUri, actorUri } = ctx.params;
const { attachPredicate, collectionOptions, createWacGroup } = this.settings;
const collectionUri = await ctx.call('pod-collections.createAndAttach', {
resourceUri,
attachPredicate,
collectionOptions,
actorUri
});
if (!collectionUri) {
this.logger.info(`No ${attachPredicate} collection attached to ${resourceUri} (already carries one, or creation refused)`);
return undefined;
}
// The service's own follow-up for a collection it has just created (attendees: record it as
// Festipod's, see public-read-ledger.js) — right after the attach, so a failure of what follows
// (the WAC group) cannot skip it.
if (typeof this.onCollectionAttached === 'function') {
await this.onCollectionAttached(ctx, { resourceUri, collectionUri, actorUri });
}
if (createWacGroup) {
await ctx.call('pod-wac-groups.create', {
groupSlug: this.getGroupSlugFromCollectionUri(collectionUri),
actorUri
});
}
return collectionUri;
}
};
module.exports = { guardedCreateAndAttach };
@@ -0,0 +1,127 @@
const { test, expect, describe } = require('bun:test');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const attendees = require('./attendees.service');
const contacts = require('./contacts.service');
const ALICE = 'http://localhost:3000/alice';
const MALLORY = 'http://localhost:3000/mallory';
const E = `${ALICE}/data/events/e1`;
const C = `${ALICE}/data/e1-attendees`;
const PROFILE = `${ALICE}/data/profiles/me`;
const CREATE = `${ALICE}/data/activities/create-1`;
/** A schema bound like moleculer binds it: mixin methods first, the service's own over them. */
function bind(schema, pod, { attached, groupFails = false } = {}) {
const logs = { info: [], warn: [], error: [] };
const calls = [];
const ctx = {
call: async (action, params) => {
calls.push({ action, params });
if (action === 'pod-resources.get') {
const body = pod[params.resourceUri];
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
}
if (action === 'pod-collections.createAndAttach') return attached;
if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed');
return undefined;
}
};
const svc = {
name: schema.name,
settings: { ...PodCollectionsHandlerMixin.settings, ...schema.settings },
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
actions: {}
};
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...(schema.methods || {}) })) svc[name] = fn.bind(svc);
const actions = { ...PodCollectionsHandlerMixin.actions, ...schema.actions };
for (const [name, def] of Object.entries(actions)) {
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
}
const emit = (key, hint, actorUri) => schema.activities[key].onEmit.call(svc, ctx, hint, actorUri);
return { svc, ctx, calls, logs, emit };
}
const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object: objectUri });
const actions = calls => calls.map(c => c.action);
describe('attendees — a verified Create attaches the collection, then aligns its public read with the event', () => {
test('a public event: collection attached, group created, then public-read.reconcile on the event', async () => {
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', to: 'as:Public' } }, { attached: C });
await emit('create', created(E), ALICE);
expect(actions(calls).filter(a => a !== 'pod-resources.get')).toEqual([
'pod-collections.createAndAttach',
'public-read.recordCreated',
'pod-wac-groups.create',
'public-read.reconcile'
]);
expect(calls.find(c => c.action === 'public-read.recordCreated').params).toEqual({ resourceUri: C, actorUri: ALICE });
expect(calls.find(c => c.action === 'public-read.reconcile').params).toEqual({ resourceUri: E, actorUri: ALICE });
});
test('the collection is recorded as Festipod\'s even when the WAC group creation after the attach fails', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, groupFails: true });
await emit('create', created(E), ALICE);
const order = actions(calls).filter(a => a !== 'pod-resources.get');
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile']);
expect(logs.error.some(m => m.includes('group creation failed'))).toBe(true);
});
test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => {
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C });
await emit('create', created(E), ALICE);
expect(calls.some(c => c.action === 'public-read.reconcile' && c.params.resourceUri === E)).toBe(true);
expect(calls.some(c => c.action === 'public-read.grant')).toBe(false);
});
test('nothing attached (already carries a collection): no WAC group, no throw, still reconciled', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', 'apods:attendees': C } }, { attached: undefined });
await emit('create', created(E), ALICE);
expect(actions(calls)).not.toContain('pod-wac-groups.create');
expect(actions(calls)).not.toContain('public-read.recordCreated'); // not Festipod's collection: never revoked
expect(actions(calls)).toContain('public-read.reconcile');
expect(logs.error).toEqual([]);
});
test("a forged Create naming another actor's event, or a stored Note, touches nothing", async () => {
const malloryEvent = `${MALLORY}/data/events/e`;
let w = bind(attendees, {}, { attached: C });
await w.emit('create', { type: 'Create', actor: ALICE, object: { id: malloryEvent, type: 'Event' } }, ALICE);
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
w = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Note', to: 'as:Public' } }, { attached: C });
await w.emit('create', created(E), ALICE);
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
});
});
describe('contacts — the webhook is a hint, and nothing attached creates no group', () => {
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
test('the normal case: the collection is attached to the STORED profile, for the outbox owner', async () => {
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
// The body claims Mallory created it: the outbox owner (Alice), re-read from the pod, is who counts.
await emit('create', { ...created(PROFILE), actor: MALLORY }, ALICE);
const attach = calls.find(c => c.action === 'pod-collections.createAndAttach');
expect(attach.params).toMatchObject({ resourceUri: PROFILE, actorUri: ALICE });
expect(actions(calls)).toContain('pod-wac-groups.create');
});
test('a Create the pod does not store attaches nothing', async () => {
const { calls, emit } = bind(contacts, { [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
await emit('create', { type: 'Create', actor: ALICE, object: profile }, ALICE);
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
});
test('a stored object that is not a Profile attaches nothing', async () => {
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: { id: PROFILE, type: 'Event' } }, { attached: `${ALICE}/data/contacts` });
await emit('create', created(PROFILE), ALICE);
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
});
test('nothing attached (the profile already carries contacts): no WAC group, and nothing thrown', async () => {
const { calls, emit, logs } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: undefined });
await expect(emit('create', created(PROFILE), ALICE)).resolves.toBeUndefined();
expect(actions(calls)).toContain('pod-collections.createAndAttach');
expect(actions(calls)).not.toContain('pod-wac-groups.create');
expect(logs.error).toEqual([]);
});
});
+29 -1
View File
@@ -1,7 +1,17 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { neverThrowingMatcher, verifiedEmit } = require('./emitted-activity');
const { guardedCreateAndAttach } = require('./collection-attach');
// Attaches an `apods:contacts` collection (and a WAC group of the same members) to the `as:Profile`
// a user creates, as the platform's collection handler does — with two differences that keep the
// webhook a hint (see emitted-activity.js):
// - the handler runs only for a Create `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:Profile` of that
// owner's pod, re-read too. The mixin's handler would act on the object embedded in the
// (unauthenticated) webhook body, attributed to the activity's self-declared `actor`;
// - `createAndAttach` creates no WAC group when nothing was attached (collection-attach.js).
module.exports = {
name: 'contacts',
mixins: [PodCollectionsHandlerMixin],
@@ -13,5 +23,23 @@ module.exports = {
summary: 'Festipod contacts'
},
createWacGroup: true
},
// Replaces the mixin's whole map (moleculer does not merge a custom schema key). The mixin's
// update and delete handlers are left out on purpose: they never matched here (they require an
// `onUpdate` this service does not have).
activities: {
create: {
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
onEmit: verifiedEmit(
'Create',
async function (ctx, { object }, actorUri) {
await this.onCreate(ctx, object, actorUri);
},
{ objectType: 'Profile' }
)
}
},
actions: {
createAndAttach: guardedCreateAndAttach
}
};
+12 -1
View File
@@ -2,6 +2,7 @@ const path = require('path');
const { CoreService } = require('@semapps/core');
const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies');
const CONFIG = require('../../config/config');
const { publicSurfaceGate } = require('../public-surface');
module.exports = {
mixins: [CoreService],
@@ -20,6 +21,11 @@ module.exports = {
},
api: {
port: CONFIG.PORT,
// In front of EVERY route — these below and the ones CoreService's services add (the LDP
// catch-all among them, which with `webacl: false` would serve the whole `settings` dataset,
// the app's private key included): only the allow-listed public endpoints pass, anything else
// is a 404 for every caller. See public-surface.js.
use: [publicSurfaceGate],
routes: [
{
path: '/app',
@@ -72,6 +78,11 @@ module.exports = {
]
},
webacl: false,
void: false
void: false,
// No public SPARQL endpoint. CoreService mounts one at `/sparql` unless this is `false`, and
// with `webacl: false` it answers anonymous queries over the whole `settings` dataset — the
// app actor's key, the webhook listener URLs, the catalog store and, locally, the pod
// provider's accounts (observed 2026-10-12). Nothing in Festipod queries it.
sparqlEndpoint: false
}
};
+236
View File
@@ -0,0 +1,236 @@
// What the backend may believe about an activity its watcher reports — the check in front of every
// Festipod handler fed by that watcher (public-read, catalog, attendees, contacts, registration).
//
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
// activity or object EMBEDDED in the body is used as written, never re-read (platform behaviour,
// @activitypods/app 2.3.2 `pod-activities-watcher.processWebhook`, @semapps/solid listener
// `transfer`). Nothing in the body is trusted here except as a pointer: the activity is re-read by
// its id from the pod, the object by its id, both through the box owner's access, and the handlers
// act only on what the pod returns.
//
// WHO EMITTED IT. A resource lies in an actor's pod only if that actor (or an app it authorised)
// wrote it: the pod refuses to create or modify a resource under another actor's path (see
// `actor-pod.js`). So:
// - an OUTBOX notification names its emitter: the owner of that outbox (`verifiedEmit`). The
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
// about an actor cannot be stored there.
//
// An activity is accepted only when all of this holds:
// 1. it is identified by a URI (an embedded activity without an id was never stored);
// 2. for an outbox notification, that URI is strictly inside the emitter's pod;
// 3. the pod returns it at that URI, as a resource with that id;
// 4. its stored type is the one the handler expects, and it names exactly one `actor`: the emitter
// (outbox), or an actor whose pod holds the activity (inbox);
// 5. it names exactly one object, by URI, lying where the handler requires (the emitter's pod for
// a resource the emitter creates, updates or deletes; the inbox owner's pod for an activity
// about the inbox owner's object; anywhere when the handler checks the location itself);
// 6. for a Delete, the pod says the object is gone (404/410) or serves a Tombstone — a live object
// means the deletion is not true; otherwise the pod returns the object at that URI, as a
// resource with that id, declaring the ActivityStreams type the handler acts on (a Note is not
// an Event, whatever the notification says).
//
// WHAT IT DOES NOT DO: order notifications. Handlers run concurrently, one per webhook; this check
// reads the pod BEFORE the handler acts, so on its own an older notification's action can still land
// after a newer one's. A handler whose decision depends on the object's current state therefore takes
// its own read INSIDE `keyed-serializer.js`, keyed by the object — that, not this check, is what makes
// a replayed or reordered notification harmless.
const { isInActorPod } = require('./actor-pod');
const AS = 'https://www.w3.org/ns/activitystreams#';
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
/** The URI a value names: a string, or a node's `id`/`@id`; undefined otherwise. */
function uriOf(value) {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
const id = value.id || value['@id'];
return typeof id === 'string' ? id : undefined;
}
return undefined;
}
/** The single URI a property holds (one value, a URI or a node with an id); undefined otherwise. */
function singleUriOf(value) {
const values = listOf(value);
return values.length === 1 ? uriOf(values[0]) : undefined;
}
/** The ActivityStreams types a node declares, as full IRIs (`Create`, `as:Create` and the IRI agree). */
function asTypesOf(node) {
if (!node || typeof node !== 'object') return [];
return listOf(node.type || node['@type']).map(type => {
if (typeof type !== 'string') return undefined;
if (type.startsWith(AS)) return type;
if (type.startsWith('as:')) return AS + type.slice('as:'.length);
return type.includes(':') ? type : AS + type;
});
}
const hasAsType = (node, name) => asTypesOf(node).includes(AS + name);
const ACTIVITY_TYPES = new Set(['Create', 'Update', 'Delete', 'Join', 'Leave']);
/** `objectOwnerUri` value for a handler that checks where the object lies itself. */
const ANY_POD = Symbol('any pod');
/**
* verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) →
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
* { ok: false, reason }
*
* `activity` is what the watcher handed over (a URI or a node — only its id is used). `type` is the
* activity type the handler expects. `emitterUri` is the owner of the outbox the notification names,
* or `null` for an inbox notification (the emitter is then the stored activity's actor). The object
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the
* ActivityStreams type the stored object must declare (`Event`, `Profile`; not checked for a Delete).
* `read(uri)` reads a resource as the box owner and resolves `{ status, body }` (`status` 0 when the
* request itself failed). Never throws for a refusal; a throwing `read` propagates.
*/
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
if (objectOwnerUri === undefined) throw new Error('objectOwnerUri is required (a URI, or ANY_POD)');
if (type !== 'Delete' && !objectType) throw new Error(`objectType is required for a ${type}`);
const refuse = reason => ({ ok: false, reason });
const activityUri = uriOf(activity);
if (!activityUri) return refuse('the activity has no id');
if (emitterUri !== null && !isInActorPod(activityUri, emitterUri)) {
return refuse(`the activity ${activityUri} is not in the pod of ${emitterUri}`);
}
const storedActivity = await read(activityUri);
if (storedActivity.status !== 200 || !storedActivity.body) {
return refuse(`the pod does not return the activity ${activityUri} (HTTP ${storedActivity.status})`);
}
const stored = storedActivity.body;
if (uriOf(stored) !== activityUri) return refuse(`the pod returned another resource for ${activityUri}`);
if (!hasAsType(stored, type)) return refuse(`the stored activity ${activityUri} is not a ${type}`);
const storedActor = singleUriOf(stored.actor);
let emitter = emitterUri;
if (emitter === null) {
if (!storedActor || !isInActorPod(activityUri, storedActor)) {
return refuse(`the stored activity ${activityUri} is not in the pod of the actor it names`);
}
emitter = storedActor;
} else if (storedActor !== emitter) {
return refuse(`the stored activity ${activityUri} is not attributed to ${emitter}`);
}
const objectUri = singleUriOf(stored.object);
if (!objectUri) return refuse(`the stored activity ${activityUri} has no single object id`);
if (objectOwnerUri !== ANY_POD && !isInActorPod(objectUri, objectOwnerUri)) {
return refuse(`the object ${objectUri} is not in the pod of ${objectOwnerUri}`);
}
const storedObject = await read(objectUri);
if (type === 'Delete') {
const gone = storedObject.status === 404 || storedObject.status === 410;
const tombstone = storedObject.status === 200 && hasAsType(storedObject.body, 'Tombstone');
if (!gone && !tombstone) return refuse(`the object ${objectUri} is not deleted (HTTP ${storedObject.status})`);
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: undefined };
}
if (storedObject.status !== 200 || !storedObject.body) {
return refuse(`the pod does not return the object ${objectUri} (HTTP ${storedObject.status})`);
}
if (uriOf(storedObject.body) !== objectUri) return refuse(`the pod returned another resource for ${objectUri}`);
if (!hasAsType(storedObject.body, objectType)) return refuse(`the stored object ${objectUri} is not a ${objectType}`);
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: storedObject.body };
}
/**
* The outbox case: an activity `actorUri` emitted about an object in its own pod (a resource it
* created, updated or deleted).
*/
function verifyEmittedActivity({ activity, actorUri, type, objectType, read }) {
return verifyActivity({ activity, type, emitterUri: actorUri, objectOwnerUri: actorUri, objectType, read });
}
/** `read` for the checks: a GET through the platform's `pod-resources.get`, as the box owner. */
function podReader(ctx, actorUri) {
return async uri => {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
return { status: typeof status === 'number' ? status : 0, body };
};
}
/**
* A matcher that never throws: on error it logs and reports no match. The watcher runs every
* service's matchers for an activity in sequence without catching, inside a webhook call the
* platform does not await — a throw would skip the handlers after this one and end the process.
* The match itself runs on the webhook's (untrusted) data; it only routes, the verified handler decides.
*/
function neverThrowingMatcher(match) {
return async function (activity, fetcher) {
try {
return await match.call(this, activity, fetcher);
} catch (e) {
this.logger.error(`${this.name} could not match activity ${uriOf(activity)}: ${e.message}`);
return { match: false, dereferencedActivity: activity };
}
};
}
/** Runs `handle` (bound to the service) on an accepted verdict; logs a refusal or a failure, never throws. */
function verifiedHandler(box, type, handle, verify) {
return async function (ctx, activity, actorUri) {
try {
const verdict = await verify(activity, actorUri, podReader(ctx, actorUri));
if (!verdict.ok) {
this.logger.warn(`${this.name} ignored a ${type} reported in the ${box} of ${actorUri}: ${verdict.reason}`);
return;
}
await handle.call(this, ctx, verdict, actorUri);
} catch (e) {
this.logger.error(`${this.name} could not process a ${type} reported in the ${box} of ${actorUri}: ${e.message}`);
}
};
}
/**
* An `onEmit` (outbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
* `verifyActivity` accepts as emitted by `actorUri`, the outbox owner. `objectType` is the type the
* stored object must declare; `objectIn` is `'emitter'` (default: the object is in the emitter's pod)
* or `'any'` (the handler checks where the object lies, e.g. the event a Join names).
*/
function verifiedEmit(type, handle, { objectType, objectIn = 'emitter' } = {}) {
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
return verifiedHandler('outbox', type, handle, (activity, actorUri, read) =>
verifyActivity({
activity,
type,
emitterUri: actorUri,
objectOwnerUri: objectIn === 'any' ? ANY_POD : actorUri,
objectType,
read
})
);
}
/**
* An `onReceive` (inbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
* `verifyActivity` accepts as emitted by the actor it names, about an object in the pod of
* `actorUri`, the inbox owner. `verdict.emitterUri` is that emitter.
*/
function verifiedReceive(type, handle, { objectType } = {}) {
return verifiedHandler('inbox', type, handle, (activity, actorUri, read) =>
verifyActivity({ activity, type, emitterUri: null, objectOwnerUri: actorUri, objectType, read })
);
}
module.exports = {
verifyActivity,
verifyEmittedActivity,
podReader,
neverThrowingMatcher,
verifiedEmit,
verifiedReceive,
uriOf,
singleUriOf,
hasAsType,
ANY_POD
};
+266
View File
@@ -0,0 +1,266 @@
const { test, expect, describe } = require('bun:test');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
// The activity and object URIs a genuine Festipod creation produces: both under the actor's pod.
const ACT = `${ALICE}/outbox/act-1`;
const OBJ = `${ALICE}/data/events/e1`;
/** A `read(uri)` built from a map of uri → { status, body }; a missing uri answers 404. */
const reader = pod => async uri => pod[uri] || { status: 404, body: undefined };
const createActivity = { id: ACT, type: 'Create', actor: ALICE, object: OBJ };
const event = (extra = {}) => ({ id: OBJ, type: 'Event', name: 'Concert', ...extra });
describe('verifyEmittedActivity — the webhook is a hint; the pod is the truth', () => {
test('the normal case: a Create emitted by the actor, re-read, is accepted with the STORED object', async () => {
const stored = event({ to: 'as:Public', name: 'the real, stored name' });
const read = reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: stored } });
// The webhook copy carries a DIFFERENT, attacker-chosen object body — it must be ignored.
const webhookActivity = { id: ACT, type: 'Create', actor: ALICE, object: { id: OBJ, type: 'Event', to: 'as:Public', name: 'forged' } };
const r = await verifyEmittedActivity({ activity: webhookActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(true);
expect(r.objectUri).toBe(OBJ);
expect(r.object.name).toBe('the real, stored name');
});
test('the activity is re-read even when the webhook embeds a full activity node', async () => {
const calls = [];
const read = async uri => {
calls.push(uri);
return uri === ACT ? { status: 200, body: createActivity } : { status: 200, body: event() };
};
const embedded = { id: ACT, type: 'Create', actor: ALICE, object: event() };
const r = await verifyEmittedActivity({ activity: embedded, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(true);
expect(calls).toEqual([ACT, OBJ]); // both re-read from the pod, nothing taken from the body
});
test('FORGERY: a victim\'s private event named through the attacker\'s outbox is refused', async () => {
// The attacker (Alice) owns her webhook URL; she forges a Create naming her own outbox as
// target and a VICTIM\'s private event as the object, addressed to Public. The watcher resolves
// actorUri = Alice (her outbox). The embedded object claims BOB\'s event is public.
const victimEvent = `${BOB}/data/events/private`;
const forged = { id: `${ALICE}/outbox/forged`, type: 'Create', actor: ALICE, object: { id: victimEvent, type: 'Event', to: 'as:Public' } };
// Even if the pod happens to return the forged activity (it is in Alice\'s pod), its object is
// in BOB\'s pod → refused at the object-ownership check, before any read of BOB\'s event.
const read = reader({ [`${ALICE}/outbox/forged`]: { status: 200, body: forged } });
const r = await verifyEmittedActivity({ activity: forged, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not in the pod of');
});
test('a Create the pod does not actually store (only in the webhook body) is refused', async () => {
const read = reader({}); // the pod returns 404 for the activity
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('does not return the activity');
});
test('a stored activity attributed to someone else is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Create', actor: BOB, object: OBJ } } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not attributed to');
});
test('a stored activity of the wrong type is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Announce', actor: ALICE, object: OBJ } } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('is not a Create');
});
test('an activity with no id (never stored) is refused before any read', async () => {
let reads = 0;
const read = async () => { reads++; return { status: 404 }; };
const r = await verifyEmittedActivity({ activity: { type: 'Create', actor: ALICE, object: event() }, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('no id');
expect(reads).toBe(0);
});
test('a Delete is accepted only when the pod says the object is gone or a Tombstone', async () => {
const del = { id: `${ALICE}/outbox/del`, type: 'Delete', actor: ALICE, object: OBJ };
const act = { [`${ALICE}/outbox/del`]: { status: 200, body: del } };
// Object gone → accepted.
let r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 404 } }) });
expect(r.ok).toBe(true);
expect(r.objectUri).toBe(OBJ);
// Tombstone → accepted.
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: { id: OBJ, type: 'Tombstone' } } }) });
expect(r.ok).toBe(true);
// Object still live → refused (the deletion is not true).
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: event() } }) });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not deleted');
});
});
describe('verifiedEmit — runs the handler only on an accepted activity, never throws', () => {
function svc(pod) {
const logs = { info: [], warn: [], error: [] };
const ran = [];
const service = {
name: 'test',
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
};
const ctx = { call: async (action, { resourceUri }) => (pod[resourceUri] || { status: 404 }) };
const onEmit = verifiedEmit('Create', async function (_ctx, verdict, actorUri) {
ran.push({ objectUri: verdict.objectUri, actorUri });
}, { objectType: 'Event' }).bind(service);
return { onEmit, ctx, logs, ran };
}
test('an accepted Create runs the handler with the stored object and the watcher\'s actor', async () => {
const { onEmit, ctx, ran } = svc({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: event() } });
await onEmit(ctx, createActivity, ALICE);
expect(ran).toEqual([{ objectUri: OBJ, actorUri: ALICE }]);
});
test('a refused Create logs a warning and does NOT run the handler', async () => {
const { onEmit, ctx, logs, ran } = svc({}); // pod returns 404 for the activity
await onEmit(ctx, createActivity, ALICE);
expect(ran).toEqual([]);
expect(logs.warn).toHaveLength(1);
expect(logs.warn[0]).toContain('ignored a Create');
});
test('a throwing read is caught and logged, never propagated', async () => {
const service = { name: 'test', logger: { info() {}, warn() {}, error(m) { this._e = m; } } };
const onEmit = verifiedEmit('Create', async () => {}, { objectType: 'Event' }).bind(service);
const ctx = { call: async () => { throw new Error('proxy down'); } };
await expect(onEmit(ctx, createActivity, ALICE)).resolves.toBeUndefined();
expect(service.logger._e).toContain('proxy down');
});
});
describe('neverThrowingMatcher', () => {
test('a throwing matcher reports no match and logs', async () => {
const service = { name: 'test', logger: { error(m) { this._e = m; } } };
const wrapped = neverThrowingMatcher(async () => { throw new Error('boom'); }).bind(service);
const r = await wrapped({ id: ACT }, async () => false);
expect(r.match).toBe(false);
expect(service.logger._e).toContain('boom');
});
});
describe('the stored object must be of the type the handler acts on', () => {
test('a public Note re-read at the object URI is refused for an Event handler; an Event is accepted', async () => {
const note = { id: OBJ, type: 'Note', to: 'as:Public', content: 'not an event' };
let r = await verifyEmittedActivity({
activity: createActivity,
actorUri: ALICE,
type: 'Create',
objectType: 'Event',
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: note } })
});
expect(r.ok).toBe(false);
expect(r.reason).toContain('is not a Event');
for (const type of ['Event', 'as:Event', 'https://www.w3.org/ns/activitystreams#Event', ['Event', 'apods:MeetingPoint']]) {
r = await verifyEmittedActivity({
activity: createActivity,
actorUri: ALICE,
type: 'Create',
objectType: 'Event',
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: { id: OBJ, type } } })
});
expect(r.ok).toBe(true);
}
});
test('a Profile handler refuses an Event and accepts a Profile', async () => {
const pod = body => reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body } });
const asProfile = { activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Profile' };
expect((await verifyEmittedActivity({ ...asProfile, read: pod(event()) })).ok).toBe(false);
expect((await verifyEmittedActivity({ ...asProfile, read: pod({ id: OBJ, type: ['as:Profile', 'vcard:Individual'] }) })).ok).toBe(true);
});
test('a type is required for anything but a Delete (a forgotten check is a programming error)', async () => {
await expect(verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', read: reader({}) })).rejects.toThrow('objectType is required');
});
test('the pod answering with another resource than the activity asked for is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { ...createActivity, id: `${ALICE}/outbox/other` } }, [OBJ]: { status: 200, body: event() } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('another resource');
});
});
describe('verifyActivity — an activity RECEIVED in an inbox (emitter = the actor whose pod holds it)', () => {
// Bob joins Alice's meeting point: the Join lies in Bob's pod, its object in Alice's pod; the
// notification comes from ALICE's inbox (Alice is the box owner, `actorUri`).
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE };
const meetingPoint = { id: MP, type: 'Event', 'apods:attendees': `${ALICE}/data/collections/mp1-attendees` };
const received = read => verifyActivity({ activity: JOIN, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
test('the normal case: a Join stored in the joiner\'s pod, about the inbox owner\'s meeting point', async () => {
const r = await received(reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(true);
expect(r.emitterUri).toBe(BOB);
expect(r.object).toEqual(meetingPoint);
});
test('an activity naming an actor whose pod does not hold it is refused (Mallory cannot join as Bob)', async () => {
const MALLORY = 'http://localhost:3000/mallory';
const forgedUri = `${MALLORY}/data/activities/join-as-bob`;
const read = reader({ [forgedUri]: { status: 200, body: { ...join, id: forgedUri, actor: BOB } }, [MP]: { status: 200, body: meetingPoint } });
const r = await verifyActivity({ activity: forgedUri, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not in the pod of the actor it names');
});
test('a Join about an object outside the inbox owner\'s pod is refused (an embedded attendees URI never reaches a write)', async () => {
const elsewhere = `${BOB}/data/events/bobs-mp`;
const read = reader({ [JOIN]: { status: 200, body: { ...join, object: { id: elsewhere, type: 'Event', 'apods:attendees': 'http://localhost:3000/carol/data/c' } } } });
const r = await received(read);
expect(r.ok).toBe(false);
expect(r.reason).toContain(`not in the pod of ${ALICE}`);
});
test('an activity with several actors, or a Leave where a Join is expected, is refused', async () => {
let r = await received(reader({ [JOIN]: { status: 200, body: { ...join, actor: [BOB, ALICE] } }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(false);
r = await received(reader({ [JOIN]: { status: 200, body: { ...join, type: 'Leave' } }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(false);
});
test('a location rule must be stated: ANY_POD explicitly, never by omission', async () => {
await expect(verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectType: 'Event', read: reader({}) })).rejects.toThrow('objectOwnerUri is required');
const read = reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } });
const r = await verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectOwnerUri: ANY_POD, objectType: 'Event', read });
expect(r.ok).toBe(true);
});
});
describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
const pod = { [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: { id: MP, type: 'Event' } } };
const service = () => ({ name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error(m) { this.e = m; } } });
const ctx = { call: async (action, { resourceUri }) => pod[resourceUri] || { status: 404 } };
test('an inbox Join runs the handler with the emitter read from the pod, not from the webhook body', async () => {
const ran = [];
const onReceive = verifiedReceive('Join', async (_ctx, verdict, actorUri) => ran.push([verdict.emitterUri, verdict.objectUri, actorUri]), { objectType: 'Event' }).bind(service());
// The webhook body claims Carol joined; the stored activity says Bob.
await onReceive(ctx, { id: JOIN, type: 'Join', actor: 'http://localhost:3000/carol', object: MP }, ALICE);
expect(ran).toEqual([[BOB, MP, ALICE]]);
});
test('an outbox Join about an event in another pod passes with objectIn "any", and is refused by default', async () => {
const ran = [];
const handle = async (_ctx, verdict) => ran.push(verdict.objectUri);
await verifiedEmit('Join', handle, { objectType: 'Event', objectIn: 'any' }).call(service(), ctx, JOIN, BOB);
const svc = service();
await verifiedEmit('Join', handle, { objectType: 'Event' }).call(svc, ctx, JOIN, BOB);
expect(ran).toEqual([MP]);
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
});
});
+108
View File
@@ -0,0 +1,108 @@
// One-at-a-time execution PER KEY, with a time limit — the ordering primitive behind every decision
// the backend takes about one pod object (grant or revoke its public read, index or de-index it in the
// catalog, add or remove an attendee).
//
// WHY. The platform runs the handlers of every webhook as soon as it arrives, without awaiting the
// previous one. Two notifications about the same object therefore run CONCURRENTLY: each reads, then
// acts — and one's action can land between the other's read and action.
//
// WHAT IT GUARANTEES — and what it does not:
// - works for one key never overlap; each starts once the previous one for that key has settled
// (finished, failed, or timed out), in the order they ENTERED `serialize` — which is the order the
// handlers got there (after their own verification reads), NOT the order the user acted or the
// pod emitted the activities;
// - so it makes a decision order-proof only when the work derives that decision from state it reads
// INSIDE: every pod change is notified after it happened, so whichever work runs last started
// after the last change and acts on a state at least that recent (public-read `reconcile`, catalog
// `sync`);
// - a decision taken from a notification's own content (a Join versus a Leave) gains no ordering
// from this; it must compare that content with recorded state under the lock
// (`attendance-ledger.js`).
//
// BOUNDED. A work that has not settled after `timeoutMs` (default `DEFAULT_TIMEOUT_MS`) is abandoned:
// its caller's promise rejects with a `SerializedWorkTimeout`, its `signal` is aborted, and the key is
// released so the next work — the next notification — re-decides from a fresh read. A pod request that
// never answers can then block one object for at most that long, never until restart. The abandoned
// work cannot be stopped, so each work checks `signal.aborted` immediately before every write and
// gives up once it is set. A write ALREADY SENT when the time ran out can still land after the next
// work has read the state. So when an abandoned work finally settles, `onLateSettle(value)` (if given)
// is called with what the work resolved (undefined if it rejected): the CALLER decides from it whether
// a write actually landed late and, only then, queues one fresh decision for the key — a re-decision
// that must not itself schedule another (no chain while the pod stays slow). A work that never
// settles never wrote late.
//
// Keys are independent (different objects run concurrently); an idle key holds no memory.
// 30 s: a serialized decision makes a handful of pod round-trips — read the object, read up to two
// ACLs, write up to two (public-read); read, then one store write (catalog); a wait of at most 1.5 s
// for a fresh collection, a read and one write (attendance) — each answering in tens of milliseconds
// on the local stack, a few seconds at worst on a loaded one. 30 s is over ten times the slowest
// legitimate run, and short enough that a stalled pod holds one object's decisions for half a minute
// rather than until the backend restarts.
const DEFAULT_TIMEOUT_MS = 30000;
class SerializedWorkTimeout extends Error {
constructor(key, timeoutMs) {
super(`serialized work for ${key} timed out after ${timeoutMs} ms: released, the next notification re-decides`);
this.name = 'SerializedWorkTimeout';
this.key = key;
}
}
/**
* createKeyedSerializer({ timeoutMs }) → serialize(key, work, { onLateSettle })
*
* `serialize(key, work)` runs `work(signal)` once every work previously queued for `key` has settled,
* and resolves or rejects as `work` does — or rejects with a `SerializedWorkTimeout` (and aborts
* `signal`) when `work` has not settled within `timeoutMs`; `onLateSettle(value)` is then called if
* and when that abandoned work settles, with its resolved value (undefined when it rejected). `serialize.pending()` is the number of keys with queued work.
*/
function createKeyedSerializer({ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const tails = new Map();
function bounded(key, work, onLateSettle) {
const controller = new AbortController();
let timer;
const expiry = new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new SerializedWorkTimeout(key, timeoutMs));
}, timeoutMs);
if (typeof timer.unref === 'function') timer.unref();
});
const run = Promise.resolve().then(() => work(controller.signal));
if (onLateSettle) {
const late = value => {
if (!controller.signal.aborted) return;
try {
onLateSettle(value);
} catch {
// a re-decision that cannot even be queued changes nothing; the next notification decides
}
};
run.then(late, () => late(undefined));
}
// `race` handles `run` too, so a late rejection of an abandoned work is never unhandled.
return Promise.race([run, expiry]).finally(() => clearTimeout(timer));
}
function serialize(key, work, { onLateSettle } = {}) {
const previous = tails.get(key) || Promise.resolve();
const result = previous.then(() => bounded(key, work, onLateSettle));
// The chain continues whatever `work` does; the tail never rejects.
const tail = result.then(
() => undefined,
() => undefined
);
tails.set(key, tail);
tail.then(() => {
if (tails.get(key) === tail) tails.delete(key);
});
return result;
}
serialize.pending = () => tails.size;
return serialize;
}
module.exports = { createKeyedSerializer, SerializedWorkTimeout, DEFAULT_TIMEOUT_MS };
+156
View File
@@ -0,0 +1,156 @@
const { test, expect, describe } = require('bun:test');
const { createKeyedSerializer, SerializedWorkTimeout, DEFAULT_TIMEOUT_MS } = require('./keyed-serializer');
const tick = ms => new Promise(resolve => setTimeout(resolve, ms));
describe('createKeyedSerializer — one work at a time per key', () => {
test('works for one key never overlap and run in submission order, even when the first is slower', async () => {
const serialize = createKeyedSerializer();
const trace = [];
const work = (name, ms) => async () => {
trace.push(`start ${name}`);
await tick(ms);
trace.push(`end ${name}`);
return name;
};
const results = await Promise.all([serialize('E', work('first', 30)), serialize('E', work('second', 1))]);
expect(results).toEqual(['first', 'second']);
expect(trace).toEqual(['start first', 'end first', 'start second', 'end second']);
});
test('different keys run concurrently', async () => {
const serialize = createKeyedSerializer();
const trace = [];
const work = (name, ms) => async () => {
trace.push(`start ${name}`);
await tick(ms);
trace.push(`end ${name}`);
};
await Promise.all([serialize('E1', work('a', 20)), serialize('E2', work('b', 1))]);
expect(trace.slice(0, 2).sort()).toEqual(['start a', 'start b']);
expect(trace.indexOf('end b')).toBeLessThan(trace.indexOf('end a'));
});
test('a failing work rejects its own caller but does not block the next one for its key', async () => {
const serialize = createKeyedSerializer();
const first = serialize('E', async () => {
throw new Error('pod down');
});
const second = serialize('E', async () => 'ran');
await expect(first).rejects.toThrow('pod down');
await expect(second).resolves.toBe('ran');
});
test('an idle key holds no memory', async () => {
const serialize = createKeyedSerializer();
await Promise.all([serialize('E1', async () => {}), serialize('E2', async () => {})]);
await tick(0);
expect(serialize.pending()).toBe(0);
});
test('THE RACE IT CLOSES: the decision is taken on a read made when acting, so the newest state wins', async () => {
// Two notifications about one event: it was public, then made private. Each handler reads the
// event then acts. Unserialized, the first handler's read (public) can precede the second's while
// its action lands last: grant-after-revoke. Serialized with the read INSIDE, the last action
// follows a read of the newest state.
const scenario = async runFor => {
let podState = 'public';
const acl = [];
const handler = actDelay => async () => {
const seen = podState; // the read, taken when acting
await tick(actDelay); // the pod's latency before the action lands
acl.push(seen === 'public' ? 'grant' : 'revoke');
};
const first = runFor(handler(30)); // slow: its action lands late
await tick(5); // the first handler has read "public" and is acting
podState = 'private'; // the owner switches the event; its notification arrives
const second = runFor(handler(1));
await Promise.all([first, second]);
return acl[acl.length - 1];
};
// Unserialized (the baseline): the stale grant lands last.
expect(await scenario(work => work())).toBe('grant');
// Serialized per object: the second handler reads after the first has acted.
const serialize = createKeyedSerializer();
expect(await scenario(work => serialize('E', work))).toBe('revoke');
});
});
describe('createKeyedSerializer — bounded: a stalled work never holds its key forever', () => {
test('a work that never settles is abandoned after the timeout: its caller is told, the next work runs', async () => {
const serialize = createKeyedSerializer({ timeoutMs: 50 });
const stalled = serialize('E', () => new Promise(() => {})); // a pod request that never answers
let ran = false;
const next = serialize('E', async () => {
ran = true;
});
await expect(stalled).rejects.toBeInstanceOf(SerializedWorkTimeout);
await next;
expect(ran).toBe(true);
await tick(0);
expect(serialize.pending()).toBe(0);
});
test('the abandoned work sees its signal aborted, so it can give up before writing', async () => {
const serialize = createKeyedSerializer({ timeoutMs: 20 });
let wrote = false;
const slow = serialize('E', async signal => {
await tick(60); // the read that took too long
if (signal.aborted) return;
wrote = true;
});
await expect(slow).rejects.toBeInstanceOf(SerializedWorkTimeout);
await tick(80);
expect(wrote).toBe(false);
});
test('a work within the limit is untouched, and the default limit is 30 s', async () => {
const serialize = createKeyedSerializer({ timeoutMs: 100 });
await expect(serialize('E', async signal => (signal.aborted ? 'aborted' : 'done'))).resolves.toBe('done');
expect(DEFAULT_TIMEOUT_MS).toBe(30000);
});
});
describe('createKeyedSerializer — a write that lands after its timeout is re-decided', () => {
test('onLateSettle runs once the abandoned work settles, and its re-decision runs after the newer work', async () => {
const serialize = createKeyedSerializer({ timeoutMs: 20 });
const order = [];
const redecided = new Promise(resolve => {
const slow = serialize(
'E',
async () => {
await tick(60); // a write sent before the timeout, landing after it
order.push('late write landed');
return { wroteLate: true };
},
{ onLateSettle: value => value.wroteLate && serialize('E', async () => resolve(order.push('re-decision'))) }
);
slow.catch(() => order.push('timed out'));
serialize('E', async () => order.push('newer work'));
});
await redecided;
expect(order).toEqual(['timed out', 'newer work', 'late write landed', 're-decision']);
});
test('onLateSettle is not called for a work that finished in time', async () => {
const serialize = createKeyedSerializer({ timeoutMs: 100 });
let called = false;
await serialize('E', async () => {}, { onLateSettle: () => (called = true) });
await tick(5);
expect(called).toBe(false);
});
});
describe('createKeyedSerializer — onLateSettle receives what the late work resolved', () => {
test('the value of a late work is handed over (undefined when it rejected), so the caller can tell a late write', async () => {
const serialize = createKeyedSerializer({ timeoutMs: 20 });
const seen = [];
const done = new Promise(resolve => {
serialize('A', async () => (await tick(50), { wroteLate: false }), { onLateSettle: v => seen.push(['A', v]) }).catch(() => {});
serialize('B', async () => { await tick(50); throw new Error('pod down'); }, { onLateSettle: v => { seen.push(['B', v]); resolve(); } }).catch(() => {});
});
await done;
await tick(10);
expect(seen).toEqual([['A', { wroteLate: false }], ['B', undefined]]);
});
});
+146
View File
@@ -0,0 +1,146 @@
// 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:<name>` 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 `#<suffix>`. */
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
};
+96
View File
@@ -0,0 +1,96 @@
const { test, expect, describe } = require('bun:test');
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, isPublicRead, FOAF_AGENT } = require('./public-read-acl');
const ACL = 'http://www.w3.org/ns/auth/acl#';
const ALICE = 'http://localhost:3000/alice';
const E = `${ALICE}/data/events/e1`;
const ACL_URI = `http://localhost:3000/_acl/alice/data/events/e1`;
const defaultRead = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
const publicRead = (agentClass = 'foaf:Agent') => ({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': agentClass });
// Flatten the PUT graph's agent triples for assertions.
const triples = doc => {
const out = [];
for (const n of doc['@graph']) for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin'])
for (const o of [].concat(n[p] ?? [])) out.push({ auth: n['@id'], p, o });
return out;
};
describe('grantsPublicRead / isPublicRead', () => {
test('detects the foaf:Agent read rule in both spellings', () => {
expect(grantsPublicRead([defaultRead, publicRead('foaf:Agent')])).toBe(true);
expect(grantsPublicRead([defaultRead, publicRead(FOAF_AGENT)])).toBe(true);
expect(grantsPublicRead([defaultRead, publicRead({ '@id': FOAF_AGENT })])).toBe(true);
});
test('a non-public ACL is not public-read', () => {
expect(grantsPublicRead([defaultRead])).toBe(false);
expect(isPublicRead({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agent': ALICE })).toBe(false);
});
});
describe('revocationGraph — a PUT body that drops foaf:Agent, keeps the rest, never empty', () => {
test('typical event (only own rule is #Read foaf:Agent): keeps owner, drops public, omits #Default', () => {
const doc = revocationGraph([defaultRead, publicRead('foaf:Agent')], E, ALICE, ACL_URI);
const ts = triples(doc);
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
expect(ts.some(t => t.auth.includes('#Default'))).toBe(false); // PUT must not touch inherited defaults
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
expect(doc['@context']['@base']).toBe(ACL_URI);
});
test('full foaf IRI is dropped too', () => {
const ts = triples(revocationGraph([publicRead(FOAF_AGENT)], E, ALICE, ACL_URI));
expect(ts.some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(true); // owner fallback, not empty
});
test('a co-existing per-contact grant on #Read is preserved', () => {
const carol = 'http://localhost:3000/carol';
const node = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': carol };
const ts = triples(revocationGraph([node], E, ALICE, ACL_URI));
expect(ts.some(t => t.p === 'acl:agent' && t.o === carol)).toBe(true);
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
// The owner fallback is NOT added when another agent already keeps the set non-empty.
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(false);
});
test('a separate own #Control rule is retained (only the public Read triple goes)', () => {
const control = { '@id': '#Control', 'acl:mode': 'acl:Control', 'acl:agent': ALICE };
const ts = triples(revocationGraph([control, publicRead('foaf:Agent')], E, ALICE, ACL_URI));
expect(ts.some(t => t.auth.includes('#Control') && t.o === ALICE)).toBe(true);
expect(ts.some(t => t.o === 'foaf:Agent')).toBe(false);
});
});
describe('revocationGraph is NARROW — only the public READ goes', () => {
test('a foaf:Agent on the own #Append rule (a world-appendable collection) is kept; only #Read loses it', () => {
const append = { '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' };
const ts = triples(revocationGraph([publicRead('foaf:Agent'), append], E, ALICE, ACL_URI));
expect(ts.some(t => t.auth === '#Append' && t.p === 'acl:agentClass' && t.o === 'foaf:Agent')).toBe(true);
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
});
test('a foaf:Agent on the own #Write rule is kept too, under either spelling', () => {
const write = { '@id': '#Write', 'acl:mode': 'acl:Write', 'acl:agentClass': FOAF_AGENT };
const ts = triples(revocationGraph([publicRead(FOAF_AGENT), write], E, ALICE, ACL_URI));
expect(ts.some(t => t.auth === '#Write' && t.o === FOAF_AGENT)).toBe(true);
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
});
});
describe('own versus inherited public read', () => {
const inheritedPublic = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
test('the own #Read rule is what a revocation can remove', () => {
expect(hasOwnPublicRead([defaultRead, publicRead()])).toBe(true);
expect(hasInheritedPublicRead([defaultRead, publicRead()])).toBe(false);
});
test('a public read inherited from the container is told apart (a PUT cannot remove it)', () => {
expect(hasOwnPublicRead([inheritedPublic])).toBe(false);
expect(hasInheritedPublicRead([inheritedPublic])).toBe(true);
expect(grantsPublicRead([inheritedPublic])).toBe(true);
});
test('a foaf:Agent on another own rule is not a public read', () => {
expect(hasOwnPublicRead([{ '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' }])).toBe(false);
});
});
+60
View File
@@ -0,0 +1,60 @@
// 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 };
+308 -53
View File
@@ -1,38 +1,39 @@
const urlJoin = require('url-join');
const { PodResourcesHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
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, FOAF_AGENT } = require('./public-read-acl');
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
const ACL = 'http://www.w3.org/ns/auth/acl#';
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
// The pod compacts ACL documents with the `acl:` and `foaf:` prefixes; accept both forms.
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 'http://xmlns.com/foaf/0.1/' + id.slice('foaf:'.length);
return id;
/** 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}`;
};
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
const grantsPublicRead = authorizations =>
arrayOf(authorizations).some(
authorization =>
arrayOf(authorization['acl:mode']).map(expand).includes(ACL + 'Read') &&
arrayOf(authorization['acl:agentClass']).map(expand).includes(FOAF_AGENT)
);
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
// applies it, acting as the app actor, to an `as:Event` a user creates ONLY when the created
// object carries that public intent (`backend/services/public-intent.js`). It learns of creations
// through the same channel as the catalog: the app's watcher on each user's outbox, which calls
// `onCreate` for every `Create` of an `as:Event` — whichever client created it and whatever its
// audience, which is why the intent check, not the channel, decides.
// 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],
@@ -40,55 +41,309 @@ module.exports = {
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: {
/**
* Idempotent: reads the resource's ACL first and adds nothing when a public-read
* authorization is already there. Resolves `true` when it added the grant, `false` when the
* resource was already public; throws when the pod refuses the grant.
* 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`).
*/
grant: {
reconcile: {
params: {
resourceUri: { type: 'string' },
actorUri: { type: 'string' }
},
async handler(ctx) {
const { resourceUri, actorUri } = ctx.params;
const authorizations = await ctx.call('pod-permissions.get', { uri: resourceUri, actorUri });
if (authorizations && grantsPublicRead(authorizations)) {
this.logger.info(`${resourceUri} is already publicly readable`);
return false;
}
const added = await ctx.call('pod-permissions.add', {
uri: resourceUri,
agentPredicate: 'acl:agentClass',
agentUri: FOAF_AGENT,
mode: 'acl:Read',
actorUri
return this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal), {
onLateSettle: result => {
if (result && result.wroteLate) this.redecideOnce(ctx, resourceUri, actorUri);
}
});
if (!added) throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
this.logger.info(`Granted public read on ${resourceUri}`);
}
},
/** `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) {
const resourceUri = resource.id || resource['@id'];
if (!hasPublicIntent(resource)) {
this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
return;
}
// The watcher runs every matching handler of an activity in sequence and does not catch: a
// throw here would skip the handlers after this one, and the platform does not await the
// webhook call that runs the watcher, so the rejection would also end the process. Log, never throw.
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 {
await this.actions.grant({ resourceUri, actorUri }, { parentCtx: ctx });
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 grant public read on ${resourceUri}: ${e.message}`);
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: `{ 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 `revoke` writes (`aclUriOf`).
*/
async 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] };
},
/**
* 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 aclUri = aclUriOf(resourceUri);
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUri);
const app = await ctx.call('app.get');
if (aborted()) return false;
const { status } = await ctx.call('signature.proxy.query', {
url: aclUri,
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(document),
actorUri: app.id || app['@id']
});
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 };
}
}
};
@@ -0,0 +1,613 @@
const { test, expect, describe } = require('bun:test');
const publicRead = require('./public-read.service');
const { createKeyedSerializer } = require('./keyed-serializer');
const { GRANTED_AT, grantedQuery, recordGrantUpdate, forgetGrantUpdate } = require('./public-read-ledger');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
const E = `${ALICE}/data/events/e1`;
const C = `${ALICE}/data/collections/e1-attendees`;
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
const APP = 'http://localhost:3003/as/actor/app';
// The ACL document shape `pod-permissions.get` returns (compacted with the acl:/foaf: prefixes).
// Note: for an event, the owner's rights come from the container's inherited #Default rules — the
// event's only OWN rule is #Read foaf:Agent (confirmed live 2026-10-12).
const PUBLIC_READ_AUTH = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
const DEFAULT_READ = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
// A revoke PUT returns 204; capture its body so tests can assert the reconstructed graph.
const okProxy = captured => params => {
captured.put = params;
return { status: 204 };
};
const publicEvent = (extra = {}) => ({ id: E, type: 'Event', to: 'as:Public', ...extra });
const privateEvent = (extra = {}) => ({ id: E, type: 'Event', name: 'now private', ...extra });
/** `pod-resources.get` answering from a map of uri → stored body (404 for anything else). */
const stored = (...bodies) => ({ resourceUri }) => {
const body = bodies.find(b => b.id === resourceUri);
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
};
/**
* The backend's grant ledger, answering exactly the queries `public-read-ledger.js` builds.
* `granted` lists the resources Festipod is recorded as having made public.
*/
function ledgerCall(granted, action, params) {
const match = /<([^>]+)> <http:\/\/festipod\.org\/publicReadGrantedAt>/.exec(params.query);
if (!match) throw new Error(`unexpected store call ${params.query}`);
const uri = match[1];
if (action === 'triplestore.query' && params.query === grantedQuery(uri)) return granted.has(uri);
if (action === 'triplestore.update' && params.query === recordGrantUpdate(uri)) return void granted.add(uri);
if (action === 'triplestore.update' && params.query === forgetGrantUpdate(uri)) return void granted.delete(uri);
throw new Error(`unexpected store call ${params.query}`);
}
/**
* The service with its actions+methods bound to a recording logger, over a programmable pod and a
* grant ledger (by default: Festipod made both the event and its collection public).
*/
function service(pod, { granted = [E, C], timeoutMs } = {}) {
const logs = { info: [], warn: [], error: [] };
const calls = [];
const ledger = new Set(granted);
const svc = {
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
actions: {}
};
if (publicRead.created) publicRead.created.call(svc);
if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs });
const ctx = {
call: async (action, params) => {
calls.push({ action, params });
if (action.startsWith('triplestore.')) return ledgerCall(ledger, action, params);
if (action === 'pod-permissions.fetch' && !pod['pod-permissions.fetch']) {
// The ACL read, served from a test's `pod-permissions.get` (authorizations, or `false` = a failed read).
const uri = params.url.replace(/^(https?:\/\/[^/]+)\/_acl/, '$1');
const authorizations = pod['pod-permissions.get'] ? await pod['pod-permissions.get']({ uri }) : undefined;
return authorizations === false || authorizations === undefined ? { status: 500 } : { status: 200, body: { '@graph': authorizations } };
}
return pod[action] ? pod[action](params) : undefined;
}
};
// Bind actions so `this.actions.reconcile|grant|revoke(...)` work from the methods.
for (const [name, def] of Object.entries(publicRead.actions)) {
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
}
for (const [name, fn] of Object.entries(publicRead.methods)) svc[name] = fn.bind(svc);
return { svc, ctx, logs, calls, ledger };
}
// The agent triples a PUT revocation graph carries, flattened to {auth, p, o} for assertions.
function putTriples(putParams) {
const graph = JSON.parse(putParams.body)['@graph'];
const out = [];
for (const node of graph) {
for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin']) {
for (const o of [].concat(node[p] ?? [])) out.push({ auth: node['@id'], p, o });
}
}
return out;
}
describe('public-read.onUpdate — the grant and the ACL stay consistent with visibility', () => {
test('private → public: grants acl:Read to foaf:Agent (and is idempotent)', async () => {
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
const { svc, ctx, calls } = service(pod);
await svc.onUpdate(ctx, publicEvent(), ALICE);
const add = calls.find(c => c.action === 'pod-permissions.add');
expect(add).toBeDefined();
expect(add.params).toMatchObject({ uri: E, agentPredicate: 'acl:agentClass', agentUri: FOAF_AGENT, mode: 'acl:Read' });
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
});
test('already public twin: grant finds the rule present and adds nothing', async () => {
const pod = {
'pod-resources.get': stored(publicEvent()),
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
'pod-permissions.add': () => {
throw new Error('must not add');
}
};
const { svc, ctx, calls } = service(pod);
await svc.onUpdate(ctx, publicEvent(), ALICE);
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
});
test('public → private: PUTs a graph that drops foaf:Agent and keeps the owner (set not empty)', async () => {
const captured = {};
const pod = {
'pod-resources.get': stored(privateEvent()),
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
'app.get': () => ({ id: APP }),
'signature.proxy.query': okProxy(captured)
};
const { svc, ctx, calls } = service(pod);
await svc.onUpdate(ctx, privateEvent(), ALICE);
const put = calls.find(c => c.action === 'signature.proxy.query');
expect(put).toBeDefined();
expect(put.params).toMatchObject({ method: 'PUT', url: `http://localhost:3000/_acl/alice/data/events/e1`, actorUri: APP });
const triples = putTriples(captured.put);
// No foaf:Agent anywhere in the PUT; the #Default rule is NOT re-emitted (a PUT leaves it be).
expect(triples.some(t => t.p === 'acl:agentClass' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
expect(triples.some(t => t.auth.includes('#Default'))).toBe(false);
// The own set is non-empty: the owner is kept as acl:agent of #Read.
expect(triples.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
});
test('public → private when the ACL stored the FULL foaf IRI: still dropped', async () => {
const captured = {};
const fullIriAuth = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': FOAF_AGENT };
const pod = {
'pod-resources.get': stored(privateEvent()),
'pod-permissions.get': () => [DEFAULT_READ, fullIriAuth],
'app.get': () => ({ id: APP }),
'signature.proxy.query': okProxy(captured)
};
const { svc, ctx } = service(pod);
await svc.onUpdate(ctx, privateEvent(), ALICE);
expect(putTriples(captured.put).some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
});
test('a per-contact own grant is preserved when the public rule is removed', async () => {
const captured = {};
const friend = 'http://localhost:3000/carol';
const readWithFriend = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': friend };
const pod = {
'pod-resources.get': stored(privateEvent()),
'pod-permissions.get': () => [DEFAULT_READ, readWithFriend],
'app.get': () => ({ id: APP }),
'signature.proxy.query': okProxy(captured)
};
const { svc, ctx } = service(pod);
await svc.onUpdate(ctx, privateEvent(), ALICE);
const triples = putTriples(captured.put);
expect(triples.some(t => t.p === 'acl:agent' && t.o === friend)).toBe(true); // friend kept
expect(triples.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false); // public dropped
});
test('update to private on an already-private event: nothing to revoke, no PUT', async () => {
const pod = {
'pod-resources.get': stored(privateEvent({ name: 'still private' })),
'pod-permissions.get': () => [DEFAULT_READ],
'signature.proxy.query': () => {
throw new Error('must not PUT');
}
};
const { svc, ctx, calls } = service(pod);
await svc.onUpdate(ctx, privateEvent({ name: 'still private' }), ALICE);
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
});
test('a pod refusal of the revoke PUT is logged, never thrown', async () => {
const pod = {
'pod-resources.get': stored(privateEvent()),
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
'app.get': () => ({ id: APP }),
'signature.proxy.query': () => ({ status: 400 })
};
const { svc, ctx, logs } = service(pod);
await expect(svc.onUpdate(ctx, privateEvent(), ALICE)).resolves.toBeUndefined();
expect(logs.error.some(m => m.includes('HTTP 400'))).toBe(true);
});
test('a pod failure is logged, never thrown (the watcher is not awaited)', async () => {
const pod = {
'pod-resources.get': stored(publicEvent()),
'pod-permissions.get': () => {
throw new Error('pod down');
}
};
const { svc, ctx, logs } = service(pod);
await expect(svc.onUpdate(ctx, publicEvent(), ALICE)).resolves.toBeUndefined();
expect(logs.error[0]).toContain('pod down');
});
test('a failing read of the event itself is logged, never thrown', async () => {
const pod = {
'pod-resources.get': () => {
throw new Error('proxy unreachable');
}
};
const { svc, ctx, logs } = service(pod);
await expect(svc.onUpdate(ctx, publicEvent(), ALICE)).resolves.toBeUndefined();
expect(logs.error[0]).toContain('proxy unreachable');
});
});
describe('public-read.onCreate — grants only on public intent', () => {
test('a created public event is granted', async () => {
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
const { svc, ctx, calls } = service(pod);
await svc.onCreate(ctx, publicEvent(), ALICE);
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(true);
});
test('a created private event is not granted', async () => {
const pod = { 'pod-resources.get': stored(privateEvent({ name: 'private' })), 'pod-permissions.get': () => [DEFAULT_READ] };
const { svc, ctx, calls, logs } = service(pod);
await svc.onCreate(ctx, privateEvent({ name: 'private' }), ALICE);
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
expect(logs.info.some(m => m.includes('not addressed to the Public collection'))).toBe(true);
});
});
describe('public-read.reconcile — decides on the STORED event, read when acting', () => {
test('the notification\'s copy is only a pointer: a "public" copy of an event the pod holds private grants nothing', async () => {
const pod = { 'pod-resources.get': stored(privateEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
const { svc, ctx, calls } = service(pod);
await svc.onUpdate(ctx, publicEvent(), ALICE);
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
});
test('a public Note stored at the URI is not an event: nothing granted (the type is re-checked)', async () => {
const note = { id: E, type: 'Note', to: 'as:Public' };
const pod = { 'pod-resources.get': stored(note), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
const { svc, ctx, calls, logs } = service(pod);
await svc.onCreate(ctx, E, ALICE);
expect(calls.some(c => c.action.startsWith('pod-permissions'))).toBe(false);
expect(logs.warn[0]).toContain('not an as:Event');
});
test("an event outside the actor's pod is neither read nor touched", async () => {
const bobs = `${BOB}/data/events/e1`;
const { svc, ctx, calls, logs } = service({});
await svc.onUpdate(ctx, bobs, ALICE);
expect(calls).toEqual([]);
expect(logs.warn[0]).toContain(`not in the pod of ${ALICE}`);
});
test('a deleted event is left alone (its ACL went with it)', async () => {
const { svc, ctx, calls } = service({ 'pod-resources.get': () => ({ status: 404 }) });
await svc.onUpdate(ctx, E, ALICE);
expect(calls.map(c => c.action)).toEqual(['pod-resources.get']);
});
test('CONCURRENT notifications: the newest state wins (no grant lands after the revoke)', async () => {
// A stateful pod: the event's addressing, and whether its ACL holds the public-read rule.
// The grant is slow (the pod's latency); the event is made private while the first handler acts.
let current = publicEvent();
let aclIsPublic = false;
const pod = {
'pod-resources.get': ({ resourceUri }) => (resourceUri === E ? { status: 200, body: current } : { status: 404 }),
'pod-permissions.get': () => (aclIsPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
'pod-permissions.add': async () => {
await new Promise(resolve => setTimeout(resolve, 30));
aclIsPublic = true;
return true;
},
'app.get': () => ({ id: APP }),
'signature.proxy.query': ({ body }) => {
aclIsPublic = putTriples({ body }).some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT);
return { status: 204 };
}
};
const { svc, ctx } = service(pod);
const first = svc.onUpdate(ctx, publicEvent(), ALICE); // notified while public
await new Promise(resolve => setTimeout(resolve, 5));
current = privateEvent(); // the owner makes it private; a second notification follows
const second = svc.onUpdate(ctx, privateEvent(), ALICE);
await Promise.all([first, second]);
expect(aclIsPublic).toBe(false);
});
});
describe("public-read.reconcile — the event's attendees collection follows its visibility, both ways", () => {
const COLLECTION_DEFAULT = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
test('public: the event AND its collection are granted (the collection already is: nothing added)', async () => {
const granted = [];
const pod = {
'pod-resources.get': stored(publicEvent({ 'apods:attendees': C })),
'pod-permissions.get': ({ uri }) => (uri === C ? [COLLECTION_DEFAULT] : [DEFAULT_READ]),
'pod-permissions.add': ({ uri }) => granted.push(uri)
};
const { svc, ctx, calls } = service(pod);
await svc.onUpdate(ctx, E, ALICE);
expect(calls.filter(c => c.action === 'pod-permissions.fetch').map(c => c.params.url)).toEqual([
'http://localhost:3000/_acl/alice/data/events/e1',
'http://localhost:3000/_acl/alice/data/collections/e1-attendees'
]);
expect(granted).toEqual([E]);
});
test('private: the public read of the event AND of its collection are revoked', async () => {
const puts = [];
const pod = {
'pod-resources.get': stored(privateEvent({ 'apods:attendees': C })),
'pod-permissions.get': ({ uri }) => (uri === C ? [COLLECTION_DEFAULT] : [DEFAULT_READ, PUBLIC_READ_AUTH]),
'app.get': () => ({ id: APP }),
'signature.proxy.query': params => {
puts.push(params.url);
return { status: 204 };
}
};
const { svc, ctx } = service(pod);
await svc.onUpdate(ctx, E, ALICE);
expect(puts).toEqual(['http://localhost:3000/_acl/alice/data/events/e1', 'http://localhost:3000/_acl/alice/data/collections/e1-attendees']);
});
test('private → public: the collection is granted back', async () => {
const granted = [];
const pod = {
'pod-resources.get': stored(publicEvent({ 'http://activitypods.org/ns/core#attendees': { id: C } })),
'pod-permissions.get': () => [DEFAULT_READ],
'pod-permissions.add': ({ uri }) => granted.push(uri)
};
const { svc, ctx } = service(pod);
await svc.onUpdate(ctx, E, ALICE);
expect(granted).toEqual([E, C]);
});
test('a collection named outside the event\'s pod is left alone', async () => {
const foreign = `${BOB}/data/collections/x`;
const pod = { 'pod-resources.get': stored(privateEvent({ 'apods:attendees': foreign })), 'pod-permissions.get': () => [DEFAULT_READ] };
const { svc, ctx, calls, logs } = service(pod);
await svc.onUpdate(ctx, E, ALICE);
expect(calls.some(c => c.params && c.params.uri === foreign)).toBe(false);
expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true);
});
test('a public read INHERITED from the container cannot be revoked: reported, no PUT', async () => {
const inherited = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
const pod = {
'pod-resources.get': stored(privateEvent({ 'apods:attendees': C })),
'pod-permissions.get': ({ uri }) => (uri === C ? [inherited] : [DEFAULT_READ]),
'signature.proxy.query': () => {
throw new Error('must not PUT');
}
};
const { svc, ctx, logs } = service(pod);
await svc.onUpdate(ctx, E, ALICE);
expect(logs.warn.some(m => m.includes(`${C} is publicly readable through a rule inherited`))).toBe(true);
expect(logs.error).toEqual([]);
});
});
describe('public-read — revokes ONLY a public read Festipod granted (the persisted grant ledger)', () => {
test('a public read Festipod did not add (owner, another app) is left untouched and logged', async () => {
const pod = {
'pod-resources.get': stored(privateEvent()),
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
'signature.proxy.query': () => {
throw new Error('must not PUT');
}
};
const { svc, ctx, calls, logs } = service(pod, { granted: [] });
await svc.onUpdate(ctx, privateEvent(), ALICE);
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
expect(logs.info.some(m => m.includes('not through a grant Festipod made: left untouched'))).toBe(true);
expect(logs.error).toEqual([]);
});
test('a grant is recorded BEFORE it is made, and a revocation forgets it', async () => {
let aclIsPublic = false;
let recordedWhenAdded;
const pod = {
'pod-resources.get': stored(publicEvent()),
'pod-permissions.get': () => (aclIsPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
'app.get': () => ({ id: APP }),
'signature.proxy.query': () => {
aclIsPublic = false;
return { status: 204 };
}
};
const w = service(pod, { granted: [] });
pod['pod-permissions.add'] = () => {
recordedWhenAdded = w.ledger.has(E);
aclIsPublic = true;
return true;
};
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(recordedWhenAdded).toBe(true);
expect(w.ledger.has(E)).toBe(true);
pod['pod-resources.get'] = stored(privateEvent());
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(aclIsPublic).toBe(false);
expect(w.ledger.has(E)).toBe(false);
});
test('already world-readable before Festipod: nothing granted, nothing recorded', async () => {
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH] };
const w = service(pod, { granted: [] });
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(w.ledger.has(E)).toBe(false);
});
test('a grant the pod refuses is not left in the record', async () => {
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => false };
const w = service(pod, { granted: [] });
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(w.ledger.has(E)).toBe(false);
expect(w.logs.error.some(m => m.includes('refused the public-read grant'))).toBe(true);
});
test('recordCreated records a collection Festipod created, without depending on an ACL read', async () => {
const w = service({}, { granted: [] }); // no ACL reachable at all
expect(await w.svc.actions.recordCreated({ resourceUri: C, actorUri: ALICE })).toBe(true);
expect([...w.ledger]).toEqual([C]);
});
test('the ledger predicate is Festipod\'s own', () => {
expect(GRANTED_AT).toBe('http://festipod.org/publicReadGrantedAt');
expect(grantedQuery(E)).toBe(`ASK { <${E}> <${GRANTED_AT}> ?at }`);
});
});
describe('public-read — an unreadable ACL is UNKNOWN, never "not public" (second review)', () => {
const OWNER_SHARE = [DEFAULT_READ, PUBLIC_READ_AUTH];
test('revoke on a transient ACL failure keeps Festipod\'s record; the next notification revokes', async () => {
let gets = 0;
let puts = 0;
const pod = {
'pod-resources.get': stored(privateEvent()),
'pod-permissions.get': () => (gets++ === 0 ? false : OWNER_SHARE), // one non-200, then the real ACL
'app.get': () => ({ id: APP }),
'signature.proxy.query': () => {
puts++;
return { status: 204 };
}
};
const w = service(pod, { granted: [E] });
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(puts).toBe(0);
expect(w.ledger.has(E)).toBe(true); // kept: the state was unknown
expect(w.logs.warn.some(m => m.includes('could not be read'))).toBe(true);
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(puts).toBe(1);
expect(w.ledger.has(E)).toBe(false);
});
test("grant on a transient ACL failure records nothing, so the owner's own share is never taken for Festipod's", async () => {
let gets = 0;
let puts = 0;
let current = publicEvent();
const pod = {
'pod-resources.get': () => ({ status: 200, body: current }),
'pod-permissions.get': () => (gets++ === 0 ? false : OWNER_SHARE), // the owner already shared it publicly
'pod-permissions.add': () => true,
'app.get': () => ({ id: APP }),
'signature.proxy.query': () => {
puts++;
return { status: 204 };
}
};
const w = service(pod, { granted: [] });
await w.svc.onUpdate(w.ctx, E, ALICE); // ACL unreadable: no grant, no record
expect(w.ledger.has(E)).toBe(false);
current = privateEvent();
await w.svc.onUpdate(w.ctx, E, ALICE);
expect(puts).toBe(0); // the owner's public share is left untouched
});
});
describe('public-read — a timed-out reconcile writes nothing more (second review)', () => {
test('a stale "public" reconcile stalled in its ACL read neither records nor grants after a newer "private" one', async () => {
let reads = 0;
let aclGets = 0;
let aclPublic = false;
const pod = {
'pod-resources.get': () => ({ status: 200, body: reads++ === 0 ? publicEvent() : privateEvent() }),
'pod-permissions.get': async () => {
if (aclGets++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // stalls past the timeout
return aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ];
},
'pod-permissions.add': () => {
aclPublic = true;
return true;
}
};
const w = service(pod, { granted: [], timeoutMs: 50 });
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: public
await new Promise(resolve => setTimeout(resolve, 10));
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: private (the owner's newer edit)
await Promise.all([stale, newer]);
await new Promise(resolve => setTimeout(resolve, 250)); // the stale one resumes; its re-decision runs
expect(aclPublic).toBe(false);
expect(w.ledger.has(E)).toBe(false);
});
test('a stale "private" reconcile stalled in its ACL read does not revoke after a newer "public" one', async () => {
let reads = 0;
let aclGets = 0;
let aclPublic = true;
const pod = {
'pod-resources.get': () => ({ status: 200, body: reads++ === 0 ? privateEvent() : publicEvent() }),
'pod-permissions.get': async () => {
if (aclGets++ === 0) await new Promise(resolve => setTimeout(resolve, 120));
return aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ];
},
'pod-permissions.add': () => {
aclPublic = true;
return true;
},
'app.get': () => ({ id: APP }),
'signature.proxy.query': () => {
aclPublic = false;
return { status: 204 };
}
};
const w = service(pod, { granted: [E], timeoutMs: 50 });
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: private
await new Promise(resolve => setTimeout(resolve, 10));
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: public
await Promise.all([stale, newer]);
await new Promise(resolve => setTimeout(resolve, 250));
expect(aclPublic).toBe(true);
});
test('a grant that lands AFTER its timeout is undone by the re-decision that follows', async () => {
let current = publicEvent();
let aclPublic = false;
let adds = 0;
const pod = {
'pod-resources.get': () => ({ status: 200, body: current }),
'pod-permissions.get': () => (aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
'pod-permissions.add': async () => {
if (adds++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // sent in time, lands late
aclPublic = true;
return true;
},
'app.get': () => ({ id: APP }),
'signature.proxy.query': () => {
aclPublic = false;
return { status: 204 };
}
};
const w = service(pod, { granted: [], timeoutMs: 50 });
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE);
await new Promise(resolve => setTimeout(resolve, 60)); // timed out with its PATCH in flight
current = privateEvent();
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // runs before the late PATCH lands
await Promise.all([stale, newer]);
await new Promise(resolve => setTimeout(resolve, 250));
expect(aclPublic).toBe(false);
});
});
describe('public-read — late-settle handling only after a late WRITE, and never chained (round-4 review)', () => {
test('a slow pod and NO late write: a timed-out reconcile schedules nothing', async () => {
let reads = 0;
const pod = {
'pod-resources.get': async () => {
reads++;
await new Promise(resolve => setTimeout(resolve, 120));
return { status: 200, body: publicEvent() };
},
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH]
};
const w = service(pod, { granted: [], timeoutMs: 50 });
await w.svc.onUpdate(w.ctx, E, ALICE);
await new Promise(resolve => setTimeout(resolve, 1000));
expect(reads).toBe(1);
});
test('a re-decision after a late write schedules no other, even when it writes late itself', async () => {
let reads = 0;
let aclPublic = false;
const slow = () => new Promise(resolve => setTimeout(resolve, 120));
const pod = {
// The owner keeps flipping the event: every read sees the other visibility.
'pod-resources.get': () => ({ status: 200, body: reads++ % 2 === 0 ? publicEvent() : privateEvent() }),
'pod-permissions.get': () => (aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
'pod-permissions.add': async () => {
await slow(); // every write lands after the limit
aclPublic = true;
return true;
},
'app.get': () => ({ id: APP }),
'signature.proxy.query': async () => {
await slow();
aclPublic = false;
return { status: 204 };
}
};
const w = service(pod, { granted: [], timeoutMs: 50 });
await w.svc.onUpdate(w.ctx, E, ALICE);
await new Promise(resolve => setTimeout(resolve, 1200));
expect(reads).toBe(2); // the reconcile and its ONE re-decision — no chain
});
});
+84
View File
@@ -0,0 +1,84 @@
// The backend's whole HTTP surface, as an allow-list checked in front of every route.
//
// WHY. The backend runs SemApps' CoreService with `webacl: false`, so nothing checks rights on the
// resources it stores, and CoreService mounts routes Festipod never asked for — above all the LDP
// catch-all (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`,
// `authorization: false`), which served the backend's `settings` dataset to anyone: the app actor's
// PRIVATE key (`GET /key`, observed 2026-10-13), the user registrations and grants the app holds
// (`/interop/…`), the webhook listeners. A signed request is no better: any pod user can sign one.
// Rather than chase each route (deny-list), the gateway serves ONLY what a caller legitimately needs
// and refuses everything else with a 404 — for every caller, anonymous or signed, since none needs
// more over HTTP: the backend's own services reach its data through internal calls, never through
// these routes.
//
// WHAT IS PUBLIC, and who calls it (backend request log, 2026-10-09 → 2026-10-13, all e2e runs):
// GET /app the app description — the Festipod front end and the pod (install)
// GET /as/actor/app the app actor and its Solid-OIDC Client ID Document — the pod
// GET /catalog the public catalog — the Festipod front end
// GET /public-key/<id> the app actor's PUBLIC key — every pod verifying an app signature
// GET /interop/access-need-group/<id>, /interop/access-need/<id>
// what the app asks for — the pod, at install and consent
// GET /.well-known/context.jsonld the JSON-LD context the app's documents reference — the pod
// GET /.well-known/nodeinfo, /nodeinfo/2.1
// server discovery (links the app actor) — the pod
// POST /as/actor/app/inbox activities pods deliver to the app (HTTP-signature checked by the platform)
// POST /.webhooks/<id> the pods' notifications to the app's watcher (see emitted-activity.js)
// HEAD follows GET; a CORS preflight (OPTIONS) is judged on the method it announces.
//
// Everything else is refused, notably: `/key` (private keys), `/` and every other container listing,
// `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the
// signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app
// actor's other collections, and every write through the LDP routes.
// moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency).
const { MoleculerClientError } = require('moleculer').Errors;
// One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no
// encoded separator or traversal; no empty segment.
const ID = '[A-Za-z0-9][A-Za-z0-9_-]*';
const READ = ['GET', 'HEAD'];
const PUBLIC_ENDPOINTS = [
{ methods: READ, path: /^\/app$/ },
{ methods: READ, path: /^\/as\/actor\/app$/ },
{ methods: READ, path: /^\/catalog$/ },
{ methods: READ, path: new RegExp(`^/public-key/${ID}$`) },
{ methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) },
{ methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) },
{ methods: READ, path: /^\/\.well-known\/context\.jsonld$/ },
{ methods: READ, path: /^\/\.well-known\/nodeinfo$/ },
{ methods: READ, path: /^\/nodeinfo\/2\.1$/ },
{ methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ },
{ methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) }
];
/**
* isPublicRequest(method, path) → boolean. True only when `method` (upper case) on `path` (the
* request path without its query string, as the gateway routes it) is in `PUBLIC_ENDPOINTS`.
*/
function isPublicRequest(method, path) {
if (typeof method !== 'string' || typeof path !== 'string') return false;
const verb = method.toUpperCase();
return PUBLIC_ENDPOINTS.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(path));
}
/** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */
function routedPathOf(req) {
if (typeof req.parsedUrl === 'string') return req.parsedUrl;
const raw = String(req.originalUrl || req.url || '').split('?')[0];
return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
/**
* publicSurfaceGate(req, res, next) — a moleculer-web global middleware (`api.use`, which the gateway
* puts in front of every route, including the ones services add at runtime). Passes a request in
* `PUBLIC_ENDPOINTS` through; answers any other with 404 before authentication or any action runs.
*/
function publicSurfaceGate(req, res, next) {
const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method;
if (isPublicRequest(method, routedPathOf(req))) return next();
return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND'));
}
module.exports = { isPublicRequest, publicSurfaceGate, PUBLIC_ENDPOINTS };
+176
View File
@@ -0,0 +1,176 @@
const path = require('path');
const { test, expect, describe, afterEach } = require('bun:test');
const { ServiceBroker } = require('moleculer');
const { isPublicRequest, publicSurfaceGate } = require('./public-surface');
const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20';
describe('the core service mounts the gate in front of every route', () => {
test('core.service.js wires publicSurfaceGate as a global gateway middleware, and no SPARQL endpoint', () => {
const core = require('./core/core.service.js');
expect(core.settings.api.use).toContain(publicSurfaceGate);
expect(core.settings.sparqlEndpoint).toBe(false);
});
});
describe('isPublicRequest — the allow-list', () => {
test('every endpoint a pod or the Festipod front end calls is public', () => {
for (const [method, p] of [
['GET', '/app'],
['GET', '/as/actor/app'],
['GET', '/catalog'],
['GET', `/public-key/${UUID}`],
['HEAD', `/public-key/${UUID}`],
['GET', `/interop/access-need-group/${UUID}`],
['GET', `/interop/access-need/${UUID}`],
['GET', '/.well-known/context.jsonld'],
['GET', '/.well-known/nodeinfo'],
['GET', '/nodeinfo/2.1'],
['POST', '/as/actor/app/inbox'],
['POST', `/.webhooks/${UUID}`]
]) {
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} true`);
}
});
test("the app's private keys, its registrations and grants, every listing and the SPARQL endpoint are refused", () => {
for (const p of [
'/key',
`/key/${UUID}`,
'/', // the root container: lists every container of the settings dataset
'/public-key', // container listing
'/interop',
`/interop/application-registration/${UUID}`,
`/interop/access-grant/${UUID}`,
`/interop/data-grant/${UUID}`,
'/interop/access-need',
'/sparql',
'/as/actor/app/outbox',
'/as/actor/app/inbox', // GET: nobody reads the app's inbox
'/as/actor/app/followers',
'/as/actor',
'/.well-known/webfinger',
`/.webhooks/${UUID}`, // GET
'/proxy'
]) {
expect(`GET ${p} ${isPublicRequest('GET', p)}`).toBe(`GET ${p} false`);
}
});
test('every write outside the two delivery endpoints is refused', () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
for (const p of ['/app', '/catalog', '/as/actor/app', `/public-key/${UUID}`, `/interop/access-need/${UUID}`, '/key', '/', '/sparql', '/proxy', '/as/actor/app/outbox']) {
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} false`);
}
}
expect(isPublicRequest('PUT', `/.webhooks/${UUID}`)).toBe(false);
expect(isPublicRequest('DELETE', '/as/actor/app/inbox')).toBe(false);
});
test('a path that only resembles a public one is refused (traversal, encoding, extra segments, case)', () => {
for (const p of [
`/public-key/../key/${UUID}`,
'/public-key/..',
`/public-key/%2e%2e/key/${UUID}`,
`/public-key/${UUID}%2F..%2Fkey`,
`/public-key/${UUID}/extra`,
`//public-key/${UUID}`,
`/Public-Key/${UUID}`,
'/app/',
'/apps',
'/catalog/x',
`/interop/access-need/${UUID}/../../key`,
'/.well-known/context.jsonldx'
]) {
expect(`${p} ${isPublicRequest('GET', p)}`).toBe(`${p} false`);
}
expect(isPublicRequest(undefined, '/app')).toBe(false);
expect(isPublicRequest('GET', undefined)).toBe(false);
});
});
describe('publicSurfaceGate — the middleware', () => {
const run = req => {
let passed;
publicSurfaceGate(req, {}, err => {
passed = err === undefined ? true : err;
});
return passed;
};
test('passes a public request, answers 404 to any other (before authentication runs)', () => {
expect(run({ method: 'GET', parsedUrl: '/catalog', headers: {} })).toBe(true);
const refusal = run({ method: 'GET', parsedUrl: '/key', headers: {} });
expect(refusal).toBeInstanceOf(Error);
expect(refusal.code).toBe(404);
});
test('a CORS preflight is judged on the method it announces', () => {
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'GET' } })).toBe(true);
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'DELETE' } }).code).toBe(404);
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: {} }).code).toBe(404);
});
test('without the gateway-parsed path, the raw URL is used without its query string', () => {
expect(run({ method: 'GET', originalUrl: '/catalog?x=1', headers: {} })).toBe(true);
expect(run({ method: 'GET', originalUrl: '/key?x=/catalog', headers: {} }).code).toBe(404);
});
});
// The mechanism, end to end: a real moleculer-web gateway (the version CoreService uses), with the
// REAL @semapps/ldp catch-all route added at runtime as the LDP service adds it, in front of a stand-in
// `ldp.api` whose every action answers as if it served the settings dataset. Status codes only.
describe('the gate covers routes added at runtime — the LDP catch-all that served the private key', () => {
const semappsCore = path.dirname(require.resolve('@semapps/core'));
const ApiGateway = require(require.resolve('moleculer-web', { paths: [semappsCore] }));
const ldpDir = path.dirname(require.resolve('@semapps/ldp/package.json', { paths: [semappsCore] }));
const { default: getCatchAllRoute } = require(path.join(ldpDir, 'dist/routes/getCatchAllRoute.js'));
let broker;
afterEach(async () => {
if (broker) await broker.stop();
broker = undefined;
});
async function startGateway(use) {
broker = new ServiceBroker({ logger: false });
const answer = () => 'settings dataset content';
broker.createService({
name: 'ldp.api',
actions: { get: answer, head: answer, post: answer, put: answer, patch: answer, delete: answer }
});
const gateway = broker.createService({
mixins: [ApiGateway],
settings: { port: 0, ip: '127.0.0.1', routes: [], ...(use ? { use } : {}) },
methods: {
// CoreService's: no credentials → anonymous.
authenticate(ctx) {
ctx.meta.webId = 'anon';
return Promise.resolve(null);
}
}
});
await broker.start();
await broker.call('api.addRoute', { route: getCatchAllRoute('/', false) });
return `http://127.0.0.1:${gateway.server.address().port}`;
}
const status = async (base, method, p) => (await fetch(base + p, { method })).status;
test('BASELINE (no gate): the catch-all serves the key container to an anonymous caller', async () => {
const base = await startGateway(undefined);
expect(await status(base, 'GET', '/key')).toBe(200);
});
test('with the gate: the key container, the root and any write are 404; a public key is served', async () => {
const base = await startGateway([publicSurfaceGate]);
expect(await status(base, 'GET', '/key')).toBe(404);
expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404);
expect(await status(base, 'GET', '/')).toBe(404);
expect(await status(base, 'GET', `/interop/data-grant/${UUID}`)).toBe(404);
expect(await status(base, 'PUT', `/public-key/${UUID}`)).toBe(404);
expect(await status(base, 'DELETE', `/interop/access-need/${UUID}`)).toBe(404);
expect(await status(base, 'GET', `/public-key/${UUID}`)).toBe(200);
expect(await status(base, 'GET', `/interop/access-need/${UUID}`)).toBe(200);
});
});
+151 -92
View File
@@ -1,120 +1,179 @@
const { ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
const { ACTIVITY_TYPES, OBJECT_TYPES, matchActivity } = require('@semapps/activitypub');
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
const CONFIG = require('../config/config');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { isNewerAct, lastAttendance, recordAttendance } = require('./attendance-ledger');
const { neverThrowingMatcher, verifiedEmit, verifiedReceive, singleUriOf, uriOf } = require('./emitted-activity');
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
const APODS_CONTACTS = 'http://activitypods.org/ns/core#contacts';
const DC_CREATOR = 'http://purl.org/dc/terms/creator';
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN, object: { type: OBJECT_TYPES.EVENT } };
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE, object: { type: OBJECT_TYPES.EVENT } };
const AS_PUBLISHED = 'https://www.w3.org/ns/activitystreams#published';
/** The time the pod stamped on a stored activity (`published`, under any of its spellings). */
const publishedOf = activity => {
if (!activity || typeof activity !== 'object') return undefined;
const value = activity.published ?? activity['as:published'] ?? activity[AS_PUBLISHED];
return value && typeof value === 'object' ? value['@value'] : value;
};
/** The single URI an event names under a compacted or full-IRI key. */
const linkOf = (node, compacted, full) => singleUriOf(node[compacted] ?? node[full]);
// Participation: a user joins (or leaves) a meeting point with a Join (Leave) posted to her outbox,
// addressed to its organizer. Two effects, each from one side's box:
// - EMITTED (the joiner's outbox): the organizer joins the joiner's contacts group, so the
// organizer may read her profile;
// - RECEIVED (the organizer's inbox): the joiner is added to (removed from) the meeting point's
// attendees collection.
// The webhook that reports either is a hint (emitted-activity.js): each handler acts only on the
// activity and the meeting point as the pod stores them, and only for the people they really name:
// - the joiner is the actor whose pod holds the activity, never a value of the webhook body;
// - the organizer is the meeting point's `dc:creator`, accepted only when the meeting point lies in
// that creator's pod — the pod stamps the creator, and nobody can store a resource in someone
// else's pod;
// - the collection and the group written to are the ones the STORED meeting point and the
// joiner's STORED actor document name, and must lie in the pod of whoever owns them.
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
// order they reach that lock does not decide: under the lock, an act is applied only when it is newer —
// by the time the participant's pod stamped on it — than the last act applied for that participant and
// meeting point (`attendance-ledger.js`, persisted). Whatever order the notifications run in, the
// participant ends in the state of her latest act. The collection is read when acting.
module.exports = {
name: 'registration',
mixins: [PodActivitiesHandlerMixin],
created() {
this.serialize = createKeyedSerializer();
},
async started() {
this.logger.info('Registration watcher subscribed');
},
methods: {
/**
* The Event's `apods:attendees` collection is attached asynchronously by
* PodCollectionsHandlerMixin on create, so a Join/Leave processed in that window sees no
* collection. Returning immediately would drop the activity forever (the client overlay
* shows joined, nothing reconciles). Re-fetch the Event a bounded number of times until the
* collection appears; only then give up.
* The attendees collection of a meeting point of `actorUri`'s pod, from the STORED meeting point.
* It is attached asynchronously when the meeting point is created (attendees service), so a
* Join/Leave processed in that window sees none: the meeting point is read again a bounded number
* of times until it names one; only then give up. The collection must lie in the same pod.
*/
async fetchAttendeesCollectionUri(ctx, event, actorUri) {
if (event['apods:attendees'] || !event.id) return event['apods:attendees'];
for (let attempt = 0; attempt < 3; attempt++) {
async attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri) {
let collectionUri = linkOf(meetingPoint, 'apods:attendees', APODS_ATTENDEES);
for (let attempt = 0; !collectionUri && attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, 500));
try {
const { ok, body } = await ctx.call('pod-resources.get', { resourceUri: event.id, actorUri });
if (ok && body && body['apods:attendees']) return body['apods:attendees'];
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri });
if (status === 200 && body) collectionUri = linkOf(body, 'apods:attendees', APODS_ATTENDEES);
} catch (e) {
this.logger.warn(`Could not re-fetch ${event.id} while waiting for its attendees collection: ${e.message}`);
this.logger.warn(`Could not re-read ${meetingPointUri} while waiting for its attendees collection: ${e.message}`);
}
}
return undefined;
if (collectionUri && !isInActorPod(collectionUri, actorUri)) {
this.logger.warn(`${meetingPointUri} names an attendees collection outside its pod: ignored`);
return undefined;
}
return collectionUri;
},
/**
* Applies one participant act (`join` / `leave`, the stored activity `activity`) to the meeting
* point's collection — under the meeting point's lock, and only when the act is newer than the last
* one applied for that participant there (see the header). A stale act changes nothing; an act
* without a pod stamp cannot be placed and changes nothing. The decision is recorded once the
* collection matches it. Gives up before any write once its time is out (`signal`).
*
* KNOWN LIMIT — a write in flight at the timeout: an add or remove ALREADY SENT when the time ran
* out still lands, and is not recorded. If a newer act of the same participant was applied in the
* meantime (on a read taken before the late write landed), the collection shows the older act's
* effect until the participant acts again. It is deliberately NOT corrected from the ledger
* afterwards: when nothing superseded the late act (a rejoin, say), the late write is right and it
* is the ledger that is behind — re-applying the ledger would undo it, and the two cases cannot be
* told apart here. Needs a pod slower than the 30 s limit.
*/
async updateAttendance(ctx, { change, memberUri, meetingPointUri, meetingPoint, activity, actorUri }) {
const act = { activityUri: uriOf(activity), published: publishedOf(activity), change };
if (Number.isNaN(Date.parse(act.published))) {
this.logger.warn(`${change} ${act.activityUri} of ${memberUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
return;
}
await this.serialize(meetingPointUri, async signal => {
const dataset = CONFIG.MAIN_DATASET;
const last = await lastAttendance(ctx, { dataset, meetingPointUri, memberUri });
if (!isNewerAct(act, last)) {
this.logger.info(`${change} ${act.activityUri} of ${memberUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
return;
}
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri);
if (!collectionUri) {
this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
return;
}
if (signal.aborted) return;
const attending = arrayOf(await ctx.call('pod-collections.getItems', { collectionUri, actorUri })).includes(memberUri);
if (signal.aborted) return;
if (change === 'join' && !attending) {
await ctx.call('attendees.add', { collectionUri, itemUri: memberUri, actorUri });
} else if (change === 'leave' && attending) {
await ctx.call('attendees.remove', { collectionUri, itemUri: memberUri, actorUri });
} else {
this.logger.info(`${memberUri} already ${change === 'join' ? 'attends' : 'does not attend'} ${meetingPointUri}`);
}
if (signal.aborted) return;
await recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act });
});
}
},
activities: {
join: {
match: {
type: ACTIVITY_TYPES.JOIN,
object: {
type: OBJECT_TYPES.EVENT
}
},
async onEmit(ctx, activity, actorUri) {
const event = activity.object;
const organizerUri = event['dc:creator'];
if (!organizerUri) return;
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(JOIN_PATTERN, activity, fetcher);
}),
// The joiner's outbox: `actorUri` is the joiner. The meeting point lies in the organizer's pod.
onEmit: verifiedEmit(
'Join',
async function (ctx, { object: meetingPoint, objectUri: meetingPointUri }, actorUri) {
const organizerUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
if (!organizerUri || !isInActorPod(meetingPointUri, organizerUri)) {
this.logger.warn(`${meetingPointUri} is not in the pod of the creator it names: no contact added`);
return;
}
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
if (!actor || !actor['apods:contacts']) return;
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
const contactsUri = actor && linkOf(actor, 'apods:contacts', APODS_CONTACTS);
if (!contactsUri) return;
if (!isInActorPod(contactsUri, actorUri)) {
this.logger.warn(`${actorUri} names a contacts collection outside its pod: no contact added`);
return;
}
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', {
collectionUri: actor['apods:contacts']
});
await ctx.call('pod-wac-groups.addMember', {
groupUri: contactsGroupUri,
memberUri: organizerUri,
actorUri
});
},
async onReceive(ctx, activity, actorUri) {
const event = activity.object;
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
if (!attendeesCollectionUri) {
this.logger.warn(`No attendees collection on ${event.id}, skipping Join`);
return;
}
const attendees = await ctx.call('pod-collections.getItems', {
collectionUri: attendeesCollectionUri,
actorUri
});
if (arrayOf(attendees).includes(activity.actor)) {
this.logger.info(`${activity.actor} is already attending ${event.id}`);
return;
}
await ctx.call('attendees.add', {
collectionUri: attendeesCollectionUri,
itemUri: activity.actor,
actorUri
});
}
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
await ctx.call('pod-wac-groups.addMember', { groupUri: contactsGroupUri, memberUri: organizerUri, actorUri });
},
{ objectType: 'Event', objectIn: 'any' }
),
// The organizer's inbox: `actorUri` is the organizer; the meeting point must lie in her pod.
onReceive: verifiedReceive(
'Join',
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
await this.updateAttendance(ctx, { change: 'join', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
},
{ objectType: 'Event' }
)
},
leave: {
match: {
type: ACTIVITY_TYPES.LEAVE,
object: {
type: OBJECT_TYPES.EVENT
}
},
async onReceive(ctx, activity, actorUri) {
const event = activity.object;
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
if (!attendeesCollectionUri) {
this.logger.warn(`No attendees collection on ${event.id}, skipping Leave`);
return;
}
const attendees = await ctx.call('pod-collections.getItems', {
collectionUri: attendeesCollectionUri,
actorUri
});
if (!arrayOf(attendees).includes(activity.actor)) {
this.logger.info(`${activity.actor} is not attending ${event.id}, skipping Leave`);
return;
}
await ctx.call('attendees.remove', {
collectionUri: attendeesCollectionUri,
itemUri: activity.actor,
actorUri
});
}
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(LEAVE_PATTERN, activity, fetcher);
}),
onReceive: verifiedReceive(
'Leave',
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
await this.updateAttendance(ctx, { change: 'leave', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
},
{ objectType: 'Event' }
)
}
}
};
@@ -0,0 +1,333 @@
const { test, expect, describe } = require('bun:test');
const registration = require('./registration.service');
const { createKeyedSerializer } = require('./keyed-serializer');
const { lastAttendanceQuery, recordAttendanceUpdate, isNewerAct, attendanceSubject } = require('./attendance-ledger');
const ALICE = 'http://localhost:3000/alice'; // the organizer
const BOB = 'http://localhost:3000/bob'; // the joiner
const MALLORY = 'http://localhost:3000/mallory';
const MP = `${ALICE}/data/events/mp1`;
const C = `${ALICE}/data/collections/mp1-attendees`;
const JOIN = `${BOB}/data/activities/join-1`;
const LEAVE = `${BOB}/data/activities/leave-1`;
const BOB_CONTACTS = `${BOB}/data/collections/contacts`;
const meetingPoint = (extra = {}) => ({ id: MP, type: 'Event', name: 'Entrée nord', 'dc:creator': ALICE, 'apods:attendees': C, to: 'as:Public', ...extra });
// The pod stamps `published` on every activity posted to an outbox.
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:00.000Z' };
const leave = { id: LEAVE, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:05.000Z' };
/**
* A pod (uri → stored body, 404 otherwise) plus the collection's state and the backend's attendance
* ledger (answering exactly the queries `attendance-ledger.js` builds); records every call.
* `addDelay` makes `attendees.add` slow; `readDelays` (uri → ms) makes some pod reads slow, to expose
* interleavings. `ledger` seeds the last act applied per member.
*/
function world({ stored = {}, items = [], addDelay = 0, readDelays = {}, ledger = {} } = {}) {
const calls = [];
const state = { items: [...items], ledger: { ...ledger } };
const members = [BOB, MALLORY, ALICE];
// Every act the store could be asked to record: the stored Join/Leave activities.
const acts = Object.values(stored)
.filter(body => body.type === 'Join' || body.type === 'Leave')
.map(body => ({ activityUri: body.id, published: body.published, change: body.type.toLowerCase() }));
const ctx = {
call: async (action, params) => {
calls.push({ action, params });
switch (action) {
case 'pod-resources.get': {
if (readDelays[params.resourceUri]) await new Promise(resolve => setTimeout(resolve, readDelays[params.resourceUri]));
const body = stored[params.resourceUri];
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
}
case 'triplestore.query': {
const member = members.find(m => params.query === lastAttendanceQuery(MP, m));
if (!member) throw new Error(`unexpected query ${params.query}`);
const last = state.ledger[member];
return last ? [{ act: { value: last.activityUri }, published: { value: last.published }, change: { value: last.change } }] : [];
}
case 'triplestore.update': {
for (const m of members) {
const act = acts.find(a => params.query === recordAttendanceUpdate(MP, m, a));
if (act) {
state.ledger[m] = act;
return undefined;
}
}
throw new Error(`unexpected update ${params.query}`);
}
case 'pod-collections.getItems':
return [...state.items];
case 'attendees.add':
if (addDelay) await new Promise(resolve => setTimeout(resolve, addDelay));
state.items.push(params.itemUri);
return undefined;
case 'attendees.remove':
state.items = state.items.filter(item => item !== params.itemUri);
return undefined;
case 'pod-wac-groups.getUriFromCollectionUri':
return `${params.collectionUri}-group`;
default:
return undefined;
}
}
};
return { ctx, calls, state };
}
function service({ timeoutMs } = {}) {
const logs = { info: [], warn: [], error: [] };
const svc = { name: 'registration', logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
if (registration.created) registration.created.call(svc);
if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs });
for (const [name, fn] of Object.entries(registration.methods)) svc[name] = fn.bind(svc);
return { svc, logs };
}
const receiveJoin = (svc, ctx, hint) => registration.activities.join.onReceive.call(svc, ctx, hint, ALICE);
const receiveLeave = (svc, ctx, hint) => registration.activities.leave.onReceive.call(svc, ctx, hint, ALICE);
const emitJoin = (svc, ctx, hint) => registration.activities.join.onEmit.call(svc, ctx, hint, BOB);
const adds = calls => calls.filter(c => c.action === 'attendees.add').map(c => c.params);
describe("registration — a Join received in the organizer's inbox adds the joiner the pod names", () => {
test('the normal case: the stored joiner is added to the STORED meeting point\'s collection', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } });
await receiveJoin(service().svc, ctx, join);
expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]);
});
test('the webhook body is ignored: neither its actor nor its embedded attendees collection is used', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } });
const body = { id: JOIN, type: 'Join', actor: MALLORY, object: { id: MP, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } };
await receiveJoin(service().svc, ctx, body);
expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]);
});
test('a Join the pod does not store (webhook only) adds no one', async () => {
const { ctx, calls } = world({ stored: { [MP]: meetingPoint() } });
const { svc, logs } = service();
await receiveJoin(svc, ctx, { ...join, object: meetingPoint() });
expect(adds(calls)).toEqual([]);
expect(logs.warn[0]).toContain('ignored a Join');
});
test('a Join about a meeting point outside the organizer\'s pod adds no one', async () => {
const bobsMp = `${BOB}/data/events/mp`;
const { ctx, calls } = world({ stored: { [JOIN]: { ...join, object: bobsMp }, [bobsMp]: { ...meetingPoint(), id: bobsMp } } });
await receiveJoin(service().svc, ctx, join);
expect(adds(calls)).toEqual([]);
});
test('a stored meeting point naming a collection outside the organizer\'s pod: nothing written', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'apods:attendees': `${MALLORY}/data/c` }) } });
const { svc, logs } = service();
await receiveJoin(svc, ctx, join);
expect(adds(calls)).toEqual([]);
expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true);
});
test('a stored object that is not an event (a Note) adds no one', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: { id: MP, type: 'Note', 'apods:attendees': C } } });
await receiveJoin(service().svc, ctx, join);
expect(adds(calls)).toEqual([]);
});
test('an already attending joiner is not added twice', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] });
await receiveJoin(service().svc, ctx, join);
expect(adds(calls)).toEqual([]);
});
});
describe("registration — a Leave received in the organizer's inbox removes only its stored emitter", () => {
test('the stored leaver is removed; a body claiming another actor changes nothing to who is removed', async () => {
const { ctx, state } = world({ stored: { [LEAVE]: leave, [MP]: meetingPoint() }, items: [BOB, MALLORY] });
await receiveLeave(service().svc, ctx, { ...leave, actor: MALLORY });
expect(state.items).toEqual([MALLORY]);
});
test('a Leave the pod does not store removes no one', async () => {
const { ctx, state } = world({ stored: { [MP]: meetingPoint() }, items: [BOB] });
await receiveLeave(service().svc, ctx, leave);
expect(state.items).toEqual([BOB]);
});
test('a Join and a Leave of one meeting point never interleave (the join cannot land after the leave)', async () => {
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 30 });
const { svc } = service();
await Promise.all([receiveJoin(svc, ctx, join), receiveLeave(svc, ctx, leave)]);
expect(state.items).toEqual([]);
});
});
describe('registration — attendance follows the participant\'s LATEST act, whatever order notifications run in', () => {
// The reviewer's probe: the Join's verification read is slower, so the Leave reaches the lock first.
test('join then leave, the Leave handled first: ends NOT attending', async () => {
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, readDelays: { [JOIN]: 40 } });
const { svc } = service();
const j = receiveJoin(svc, ctx, { id: JOIN }); // the Join webhook first
await new Promise(resolve => setTimeout(resolve, 5)); // the Leave webhook 5 ms later
const l = receiveLeave(svc, ctx, { id: LEAVE });
await Promise.all([j, l]);
expect(state.items).toEqual([]);
});
test('leave then join, the Join handled first: ends attending', async () => {
const earlyLeave = { ...leave, published: '2026-10-13T09:00:00.000Z' }; // she left, then joined again
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: earlyLeave, [MP]: meetingPoint() }, items: [BOB], readDelays: { [LEAVE]: 40 } });
const { svc } = service();
const l = receiveLeave(svc, ctx, { id: LEAVE });
await new Promise(resolve => setTimeout(resolve, 5));
const j = receiveJoin(svc, ctx, { id: JOIN });
await Promise.all([l, j]);
expect(state.items).toEqual([BOB]);
});
test('a replayed older Join after a later Leave changes nothing (the ledger says the Leave is newer)', async () => {
const { ctx, state, calls } = world({
stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() },
ledger: { [BOB]: { activityUri: LEAVE, published: leave.published, change: 'leave' } }
});
const { svc, logs } = service();
await receiveJoin(svc, ctx, { id: JOIN });
expect(state.items).toEqual([]);
expect(adds(calls)).toEqual([]);
expect(logs.info.some(m => m.includes('not newer than the last act applied'))).toBe(true);
});
test('every applied act is recorded, even when the collection already matched it', async () => {
const { ctx, state } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] });
await receiveJoin(service().svc, ctx, { id: JOIN });
expect(state.ledger[BOB]).toEqual({ activityUri: JOIN, published: join.published, change: 'join' });
});
test('an act without a pod stamp cannot be ordered: nothing written', async () => {
const unstamped = { ...join, published: undefined };
const { ctx, calls } = world({ stored: { [JOIN]: unstamped, [MP]: meetingPoint() } });
const { svc, logs } = service();
await receiveJoin(svc, ctx, { id: JOIN });
expect(adds(calls)).toEqual([]);
expect(calls.some(c => c.action === 'triplestore.update')).toBe(false);
expect(logs.warn.some(m => m.includes('no pod stamp'))).toBe(true);
});
});
describe('registration — a write in flight at the timeout (no re-alignment: decided limit)', () => {
// Bob joined then left (Leave L0 recorded). He acts once more; that act's write is sent before the
// 50 ms timeout and lands at 120 ms; nothing follows. The late write is the latest act: it must stand.
const L0 = { id: `${BOB}/data/activities/leave-0`, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' };
const J0 = { id: `${BOB}/data/activities/join-0`, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' };
test('a late, unrecorded REJOIN is not undone by the older recorded Leave: ends attending', async () => {
const { ctx, state } = world({
stored: { [JOIN]: join, [MP]: meetingPoint(), [L0.id]: L0 },
addDelay: 120,
ledger: { [BOB]: { activityUri: L0.id, published: L0.published, change: 'leave' } }
});
const { svc } = service({ timeoutMs: 50 });
await receiveJoin(svc, ctx, join);
await new Promise(resolve => setTimeout(resolve, 300));
expect(state.items).toEqual([BOB]);
});
test('the mirror case: a late, unrecorded LEAVE is not undone by the older recorded Join: ends NOT attending', async () => {
const slowRemove = world({
stored: { [LEAVE]: leave, [MP]: meetingPoint(), [J0.id]: J0 },
items: [BOB],
ledger: { [BOB]: { activityUri: J0.id, published: J0.published, change: 'join' } }
});
const { ctx, state } = slowRemove;
const call = ctx.call;
ctx.call = async (action, params) => {
if (action === 'attendees.remove') await new Promise(resolve => setTimeout(resolve, 120)); // lands late
return call(action, params);
};
const { svc } = service({ timeoutMs: 50 });
await receiveLeave(svc, ctx, leave);
await new Promise(resolve => setTimeout(resolve, 300));
expect(state.items).toEqual([]);
});
test('KNOWN LIMIT (documented in updateAttendance): an older Join whose add lands after a newer Leave stays applied until the next act', async () => {
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 120 });
const { svc } = service({ timeoutMs: 50 });
const j = receiveJoin(svc, ctx, join);
await new Promise(resolve => setTimeout(resolve, 10));
const l = receiveLeave(svc, ctx, leave);
await Promise.all([j, l]);
await new Promise(resolve => setTimeout(resolve, 250));
expect(state.ledger[BOB].change).toBe('leave'); // the newer act is the recorded one
expect(state.items).toEqual([BOB]); // the late add of the older Join stands — no re-alignment
});
});
describe("registration — a Join emitted from the joiner's outbox adds the meeting point's real organizer to her contacts", () => {
const bobActor = { id: BOB, type: 'Person', 'apods:contacts': BOB_CONTACTS };
const added = calls => calls.filter(c => c.action === 'pod-wac-groups.addMember').map(c => c.params);
test('the normal case: the stored creator of a meeting point in her own pod', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } });
await emitJoin(service().svc, ctx, join);
expect(added(calls)).toEqual([{ groupUri: `${BOB_CONTACTS}-group`, memberUri: ALICE, actorUri: BOB }]);
});
test('a creator named in the webhook body is ignored (Mallory cannot get into Bob\'s contacts)', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } });
await emitJoin(service().svc, ctx, { ...join, object: { id: MP, type: 'Event', 'dc:creator': MALLORY } });
expect(added(calls).map(p => p.memberUri)).toEqual([ALICE]);
});
test('a stored creator who does not own the pod holding the meeting point is refused', async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'dc:creator': MALLORY }), [BOB]: bobActor } });
await emitJoin(service().svc, ctx, join);
expect(added(calls)).toEqual([]);
});
test('a Join stored in another actor\'s pod is not Bob\'s', async () => {
const forged = `${MALLORY}/data/activities/j`;
const { ctx, calls } = world({ stored: { [forged]: { ...join, id: forged }, [MP]: meetingPoint(), [BOB]: bobActor } });
await emitJoin(service().svc, ctx, forged);
expect(added(calls)).toEqual([]);
});
test("a contacts collection outside the joiner's pod is not written", async () => {
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: { ...bobActor, 'apods:contacts': `${MALLORY}/data/c` } } });
await emitJoin(service().svc, ctx, join);
expect(added(calls)).toEqual([]);
});
});
describe('registration — matchers never throw', () => {
test('a fetch failure while matching reports no match', async () => {
const { svc, logs } = service();
const failing = async () => {
throw new Error('proxy unreachable');
};
for (const key of ['join', 'leave']) {
const result = await registration.activities[key].match.call(svc, { type: key === 'join' ? 'Join' : 'Leave', object: MP }, failing);
expect(result.match).toBe(false);
}
expect(logs.error).toHaveLength(2);
});
});
describe('attendance-ledger — the order of acts', () => {
const at = (published, activityUri = JOIN) => ({ activityUri, published });
test('a later stamp is newer; an earlier one is not; a tie is broken by the activity URI', () => {
expect(isNewerAct(at('2026-10-13T10:00:01Z'), at('2026-10-13T10:00:00Z'))).toBe(true);
expect(isNewerAct(at('2026-10-13T09:59:59Z'), at('2026-10-13T10:00:00Z'))).toBe(false);
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/b`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(true);
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/a`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(false); // the same act twice
});
test('the first act is always newer; an unstamped act never is', () => {
expect(isNewerAct(at('2026-10-13T10:00:00Z'), undefined)).toBe(true);
expect(isNewerAct(at(undefined), undefined)).toBe(false);
expect(isNewerAct(at('not a date'), undefined)).toBe(false);
});
test('one entry per (meeting point, participant), under a URN the store accepts', () => {
expect(attendanceSubject(MP, BOB)).not.toBe(attendanceSubject(MP, MALLORY));
expect(attendanceSubject(MP, BOB)).toMatch(/^urn:festipod:attendance:[^<>"{}|^`\\ ]+$/);
expect(() => lastAttendanceQuery(MP, BOB)).not.toThrow();
});
});