diff --git a/backend/services/attendance-ledger.js b/backend/services/attendance-ledger.js new file mode 100644 index 0000000..5e57dbc --- /dev/null +++ b/backend/services/attendance-ledger.js @@ -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 +}; diff --git a/backend/services/attendees.service.js b/backend/services/attendees.service.js index d6a7c01..6a4d6b8 100644 --- a/backend/services/attendees.service.js +++ b/backend/services/attendees.service.js @@ -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}`); + } } } }; diff --git a/backend/services/catalog.service.js b/backend/services/catalog.service.js index bb98c1d..ee11d9b 100644 --- a/backend/services/catalog.service.js +++ b/backend/services/catalog.service.js @@ -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 () => { diff --git a/backend/services/catalog.service.test.js b/backend/services/catalog.service.test.js index 2d98202..76de35b 100644 --- a/backend/services/catalog.service.test.js +++ b/backend/services/catalog.service.test.js @@ -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) } }); diff --git a/backend/services/collection-attach.js b/backend/services/collection-attach.js new file mode 100644 index 0000000..71ad096 --- /dev/null +++ b/backend/services/collection-attach.js @@ -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 }; diff --git a/backend/services/collection-handlers.test.js b/backend/services/collection-handlers.test.js new file mode 100644 index 0000000..c78b5a3 --- /dev/null +++ b/backend/services/collection-handlers.test.js @@ -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([]); + }); +}); diff --git a/backend/services/contacts.service.js b/backend/services/contacts.service.js index 635b621..f8a5f2c 100644 --- a/backend/services/contacts.service.js +++ b/backend/services/contacts.service.js @@ -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 } }; diff --git a/backend/services/core/core.service.js b/backend/services/core/core.service.js index 99c7aca..a404b3c 100644 --- a/backend/services/core/core.service.js +++ b/backend/services/core/core.service.js @@ -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 } }; diff --git a/backend/services/emitted-activity.js b/backend/services/emitted-activity.js new file mode 100644 index 0000000..67dd7a2 --- /dev/null +++ b/backend/services/emitted-activity.js @@ -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 +}; diff --git a/backend/services/emitted-activity.test.js b/backend/services/emitted-activity.test.js new file mode 100644 index 0000000..63837ca --- /dev/null +++ b/backend/services/emitted-activity.test.js @@ -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}`); + }); +}); diff --git a/backend/services/keyed-serializer.js b/backend/services/keyed-serializer.js new file mode 100644 index 0000000..2740d36 --- /dev/null +++ b/backend/services/keyed-serializer.js @@ -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 }; diff --git a/backend/services/keyed-serializer.test.js b/backend/services/keyed-serializer.test.js new file mode 100644 index 0000000..5553c18 --- /dev/null +++ b/backend/services/keyed-serializer.test.js @@ -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]]); + }); +}); diff --git a/backend/services/public-read-acl.js b/backend/services/public-read-acl.js new file mode 100644 index 0000000..8bcd129 --- /dev/null +++ b/backend/services/public-read-acl.js @@ -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:` on an authorization, under its compacted or full-IRI key (raw, not expanded). */ +const rawValuesOf = (authorization, name) => [ + ...arrayOf(authorization['acl:' + name]), + ...arrayOf(authorization[ACL + name]) +]; + +const expandedValuesOf = (authorization, name) => rawValuesOf(authorization, name).map(expand); + +/** True when the authorization is a Read rule that lets any agent (foaf:Agent) read. */ +const isPublicRead = authorization => + expandedValuesOf(authorization, 'mode').includes(ACL + 'Read') && + expandedValuesOf(authorization, 'agentClass').includes(FOAF_AGENT); + +/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */ +const grantsPublicRead = authorizations => arrayOf(authorizations).some(isPublicRead); + +/** The fragment of an authorization's id (`Read` for `…#Read`, `DefaultRead` for an inherited rule). */ +const suffixOf = authorization => String(authId(authorization)).split('#')[1]; + +/** An authorization is the resource's OWN (not an inherited `#Default*`) when its id ends `#`. */ +const isOwnAuthorization = authorization => OWN_SUFFIXES.includes(suffixOf(authorization)); + +/** True when the resource's OWN `#Read` rule lets any agent (foaf:Agent) read — what `revocationGraph` removes. */ +const hasOwnPublicRead = authorizations => + arrayOf(authorizations).some(authorization => suffixOf(authorization) === 'Read' && isPublicRead(authorization)); + +/** True when a rule that is NOT the resource's own (an inherited `#Default*`) lets any agent read. */ +const hasInheritedPublicRead = authorizations => + arrayOf(authorizations).some(authorization => !isOwnAuthorization(authorization) && isPublicRead(authorization)); + +/** + * aclContext(aclUri) — the JSON-LD context to PUT with a revocation graph: the `acl:`/`foaf:` + * prefixes and the id-typed agent/accessTo/mode terms, based at `aclUri` so `#Read` resolves to the + * authorization fragment. Mirrors the pod's own `getAclContext` so the PUT round-trips identically. + */ +const aclContext = aclUri => ({ + '@base': aclUri, + acl: ACL, + foaf: FOAF, + 'acl:agent': { '@type': '@id' }, + 'acl:agentClass': { '@type': '@id' }, + 'acl:agentGroup': { '@type': '@id' }, + 'acl:origin': { '@type': '@id' }, + 'acl:mode': { '@type': '@id' }, + 'acl:accessTo': { '@type': '@id' } +}); + +/** + * revocationGraph(authorizations, resourceUri, ownerUri) → a JSON-LD document `{ '@context', '@graph' }` + * to PUT on the resource's ACL so it keeps every one of its OWN agent triples EXCEPT + * `acl:agentClass foaf:Agent` on its `#Read` authorization (a `foaf:Agent` on any other own rule stays). Inherited `#Default*` rules are not emitted + * (a PUT does not touch them). When removing the public-read triple would leave the own set empty, + * the owner is emitted as `acl:agent` of `#Read` so the PUT is accepted without changing any right. + * `aclUri` is where the document will be PUT (used only for the context `@base`). + */ +const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => { + const graph = []; + for (const authorization of arrayOf(authorizations)) { + if (!isOwnAuthorization(authorization)) continue; + const id = authId(authorization); + const isReadRule = suffixOf(authorization) === 'Read'; + const node = { '@id': id, '@type': 'acl:Authorization', 'acl:accessTo': resourceUri }; + for (const mode of rawValuesOf(authorization, 'mode')) { + node['acl:mode'] = node['acl:mode'] ? [].concat(node['acl:mode'], mode) : mode; + } + let kept = 0; + for (const name of AGENT_PREDICATES) { + const values = rawValuesOf(authorization, name).filter( + value => !(isReadRule && name === 'agentClass' && expand(value) === FOAF_AGENT) + ); + if (values.length > 0) { + node['acl:' + name] = values.length === 1 ? values[0] : values; + kept += values.length; + } + } + if (kept > 0) graph.push(node); + } + // The pod refuses an empty own rule set; keep the owner as a reader (already true via the default). + if (graph.length === 0) { + graph.push({ '@id': '#Read', '@type': 'acl:Authorization', 'acl:accessTo': resourceUri, 'acl:mode': 'acl:Read', 'acl:agent': ownerUri }); + } + return { '@context': aclContext(aclUri), '@graph': graph }; +}; + +module.exports = { + grantsPublicRead, + hasOwnPublicRead, + hasInheritedPublicRead, + revocationGraph, + isPublicRead, + expand, + FOAF_AGENT, + ACL +}; diff --git a/backend/services/public-read-acl.test.js b/backend/services/public-read-acl.test.js new file mode 100644 index 0000000..73b7391 --- /dev/null +++ b/backend/services/public-read-acl.test.js @@ -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); + }); +}); diff --git a/backend/services/public-read-ledger.js b/backend/services/public-read-ledger.js new file mode 100644 index 0000000..73887de --- /dev/null +++ b/backend/services/public-read-ledger.js @@ -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 }; diff --git a/backend/services/public-read.service.js b/backend/services/public-read.service.js index 53c9a9c..150e5a0 100644 --- a/backend/services/public-read.service.js +++ b/backend/services/public-read.service.js @@ -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 }; } } }; diff --git a/backend/services/public-read.service.test.js b/backend/services/public-read.service.test.js new file mode 100644 index 0000000..61a8fa7 --- /dev/null +++ b/backend/services/public-read.service.test.js @@ -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 = /<([^>]+)> /.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 + }); +}); diff --git a/backend/services/public-surface.js b/backend/services/public-surface.js new file mode 100644 index 0000000..dcc9bb5 --- /dev/null +++ b/backend/services/public-surface.js @@ -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 /`, +// `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/ the app actor's PUBLIC key — every pod verifying an app signature +// GET /interop/access-need-group/, /interop/access-need/ +// 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/ 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 }; diff --git a/backend/services/public-surface.test.js b/backend/services/public-surface.test.js new file mode 100644 index 0000000..72d1e2b --- /dev/null +++ b/backend/services/public-surface.test.js @@ -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); + }); +}); diff --git a/backend/services/registration.service.js b/backend/services/registration.service.js index b590f2b..c4b9c15 100644 --- a/backend/services/registration.service.js +++ b/backend/services/registration.service.js @@ -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' } + ) } } }; diff --git a/backend/services/registration.service.test.js b/backend/services/registration.service.test.js new file mode 100644 index 0000000..df8b56d --- /dev/null +++ b/backend/services/registration.service.test.js @@ -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(); + }); +});