Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism): a sign-up is a private Join naming the meeting point, addressed to nobody, handled by the backend on emission. Attendee lists hold member URNs hashed from (WebID, event, a secret kept in the participant's profile), so counts stay public and only contacts recognise anyone; the creator has no read right and is notified anonymously. Each person's participations collection, created by the backend at the first sign-up, is readable by their /contacts WAC group only, never world-readable. The member URN is sticky per (meeting point, participant) and every collection write is verified by reading back, so a refused write leaves the act unrecorded and retryable. The EventRef mechanism and its browser-side provisioning are gone. The backend now runs with WebACL on, as the reference apps do, on its own secure datasets festipod / settings-festipod instead of the pod provider's settings dataset; pod-notifications needs it. The compose header and the deployment leaf say how to reset a stack whose datasets predate this. The rights matrix covers the new mechanism live (148 hard lines), including a forged contact Accept between throwaway accounts: the stranger enters the contacts list (platform defect, reported upstream) but is refused the profile and the participations. Tests may attack as long as they never break the dev environment (rule_tests-never-break-the-dev-environment). Also lands the coherence-pass work that was still uncommitted: contacts as the Pod's social graph and invite links, image upload to the Pod, the meeting-point form, profile completion gate, and the doctrine for them. Superseded briefs and decisions are deleted rather than annotated.
This commit is contained in:
@@ -47,6 +47,12 @@ module.exports = {
|
||||
accessMode: ['acl:Read', 'acl:Write'],
|
||||
preferredScope: 'interop:AllFromRegistry'
|
||||
},
|
||||
{
|
||||
// The images users upload (event covers, avatars): the Pod's `file` container.
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/File'),
|
||||
accessMode: ['acl:Read', 'acl:Write'],
|
||||
preferredScope: 'interop:AllFromRegistry'
|
||||
},
|
||||
{
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
|
||||
accessMode: 'acl:Read',
|
||||
@@ -56,7 +62,6 @@ module.exports = {
|
||||
'apods:ReadOutbox',
|
||||
'apods:PostOutbox',
|
||||
'apods:QuerySparqlEndpoint',
|
||||
'apods:CreateWacGroup',
|
||||
'apods:CreateCollection',
|
||||
'apods:UpdateWebId'
|
||||
],
|
||||
@@ -124,29 +129,52 @@ module.exports = {
|
||||
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
|
||||
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
|
||||
// needs from that link — an empty consent registers the app with zero grants and the
|
||||
// register flow loops. Idempotent: only patches when the link is missing.
|
||||
// register flow loops. The heal itself can ALSO lose the race: it may run before the
|
||||
// mixin has created the group (observed 2026-10-26 on a fresh dataset — the one-shot
|
||||
// heal found no group and silently did nothing), so it polls for the group instead of
|
||||
// checking once. Idempotent: only patches when the link is missing.
|
||||
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
|
||||
const actor = await this.broker.call('ldp.resource.get', {
|
||||
resourceUri: actorUri,
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
const link = actor && actor['interop:hasAccessNeedGroup'];
|
||||
const hasLink = Array.isArray(link) ? link.length > 0 : Boolean(link);
|
||||
if (!hasLink) {
|
||||
const actorHasLink = async () => {
|
||||
const actor = await this.broker.call('ldp.resource.get', {
|
||||
resourceUri: actorUri,
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
const link = actor && actor['interop:hasAccessNeedGroup'];
|
||||
return Array.isArray(link) ? link.length > 0 : Boolean(link);
|
||||
};
|
||||
const findGroup = async () => {
|
||||
const groups = await this.broker.call('access-needs-groups.list', {
|
||||
filters: {
|
||||
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
|
||||
},
|
||||
accept: 'application/ld+json'
|
||||
});
|
||||
const group = groups && groups['ldp:contains'] && groups['ldp:contains'][0];
|
||||
if (group) {
|
||||
await this.broker.call('actors.attachAccessNeedGroup', {
|
||||
accessNeedGroupUri: group.id || group['@id']
|
||||
});
|
||||
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
|
||||
return groups && groups['ldp:contains'] && groups['ldp:contains'][0];
|
||||
};
|
||||
// A started() handler must not block the broker's boot, but the link must
|
||||
// exist before the first consent/register flow; ~1 min is far past the
|
||||
// observed gap (seconds) while the dataset is empty.
|
||||
const deadline = Date.now() + 60000;
|
||||
for (;;) {
|
||||
try {
|
||||
if (await actorHasLink()) break;
|
||||
const group = await findGroup();
|
||||
if (group) {
|
||||
await this.broker.call('actors.attachAccessNeedGroup', {
|
||||
accessNeedGroupUri: group.id || group['@id']
|
||||
});
|
||||
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Access-need-group heal attempt failed: ${e.message}`);
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
this.logger.error('No access-need group appeared within 60s; the actor stays unlinked — consents will register zero grants');
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The last participation act applied for each (meeting point, participant) — what makes attendance
|
||||
// independent of the order notifications arrive in.
|
||||
// The last participation act applied for each (meeting point, participant WebID) — what makes attendance
|
||||
// independent of the order notifications arrive in, and what remembers which member URN that act put in
|
||||
// (or took out of) the meeting point's attendee list.
|
||||
//
|
||||
// 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
|
||||
@@ -16,30 +17,61 @@
|
||||
// attending, "leave then join" ends attending. All acts of one participant are stamped by her own pod,
|
||||
// so they share one clock.
|
||||
//
|
||||
// THE MEMBER is STICKY per pair. The URN listed for a participant is computed from the secret on their
|
||||
// profile, which they may rewrite between acts. So the entry remembers the URN once chosen and reuses it
|
||||
// for every later act of the pair — `member` is persisted (`persistMember`) BEFORE the first attendee-list
|
||||
// write, WRITE-ONCE (inserted only when absent, never replaced, not rewritten when an act is recorded),
|
||||
// and never recomputed afterwards: a later Join keeps it, a Leave removes that one. This is what
|
||||
// keeps the pair listed under exactly ONE URN even when an act fails half-way (the add lands, a later step
|
||||
// throws, the act is left unrecorded) and the participant then rewrites their secret and acts again —
|
||||
// without it, the second act would compute a second URN and the list would carry both.
|
||||
//
|
||||
// The member and the act are persisted SEPARATELY: `member` the moment a URN is first chosen for the pair,
|
||||
// the act (its URI, its stamp, its kind) only once BOTH collections match it. So the entry may hold a
|
||||
// member with no act yet (a first act that failed before recording) — `lastAttendance` returns it so the
|
||||
// retry reuses the URN, while the absent act makes the retry the "first" act for ordering (`isNewerAct`).
|
||||
//
|
||||
// THE KEY is the participant's WebID, not the member URN. This exposes nothing new at rest: the entry
|
||||
// already holds the act's URI, which lies in the participant's pod and so already identifies them.
|
||||
//
|
||||
// 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.
|
||||
// `urn:festipod:attendance:` subject, with four Festipod predicates: the act's URI, its stamp, its kind,
|
||||
// the member URN applied.
|
||||
|
||||
const { iriRef, stringLiteral } = require('./catalog-store');
|
||||
|
||||
const ACT = 'http://festipod.org/attendanceAct';
|
||||
const PUBLISHED = 'http://festipod.org/attendancePublished';
|
||||
const CHANGE = 'http://festipod.org/attendanceChange';
|
||||
const MEMBER = 'http://festipod.org/attendanceMember';
|
||||
|
||||
/** 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)}`;
|
||||
function attendanceSubject(meetingPointUri, participantUri) {
|
||||
return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(participantUri)}`;
|
||||
}
|
||||
|
||||
/** 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`;
|
||||
// The pair's entry, anchored on its sticky `member`: a row iff a URN has been chosen for the pair, the
|
||||
// act fields bound only once an act was recorded (OPTIONAL — a member may be persisted with no act yet).
|
||||
function lastAttendanceQuery(meetingPointUri, participantUri) {
|
||||
const s = iriRef(attendanceSubject(meetingPointUri, participantUri));
|
||||
return `SELECT ?act ?published ?change ?member WHERE { ${s} <${MEMBER}> ?member . OPTIONAL { ${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));
|
||||
// Set the pair's sticky member URN ONLY IF IT HAS NONE: the member is written once and never replaced,
|
||||
// so a write still in flight from an act that timed out cannot overwrite the URN a later act already
|
||||
// chose and listed. The caller reads the entry back and applies whatever member it holds.
|
||||
function persistMemberUpdate(meetingPointUri, participantUri, member) {
|
||||
const s = iriRef(attendanceSubject(meetingPointUri, participantUri));
|
||||
return `
|
||||
INSERT { ${s} <${MEMBER}> ${stringLiteral(member)} }
|
||||
WHERE { FILTER NOT EXISTS { ${s} <${MEMBER}> ?member } }
|
||||
`;
|
||||
}
|
||||
|
||||
/** Replace the pair's recorded act `{ activityUri, published, change }`; its member is left as persisted. */
|
||||
function recordAttendanceUpdate(meetingPointUri, participantUri, { activityUri, published, change }) {
|
||||
const s = iriRef(attendanceSubject(meetingPointUri, participantUri));
|
||||
return `
|
||||
DELETE { ${s} ?p ?o }
|
||||
INSERT { ${s} <${ACT}> ${iriRef(activityUri)} ; <${PUBLISHED}> ${stringLiteral(published)} ; <${CHANGE}> ${stringLiteral(change)} }
|
||||
@@ -66,23 +98,41 @@ 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' });
|
||||
/**
|
||||
* The pair's entry, `{ activityUri, published, change, member }`, or undefined when no URN was ever
|
||||
* chosen for it. `member` is always set when a row is returned; the act fields are undefined when a
|
||||
* member was persisted but no act recorded yet (a first act that failed before recording).
|
||||
*/
|
||||
async function lastAttendance(ctx, { dataset, meetingPointUri, participantUri }) {
|
||||
const rows = await ctx.call('triplestore.query', { query: lastAttendanceQuery(meetingPointUri, participantUri), 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) };
|
||||
return { activityUri: termValue(row.act), published: termValue(row.published), change: termValue(row.change), member: termValue(row.member) };
|
||||
}
|
||||
|
||||
async function recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act }) {
|
||||
await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, memberUri, act), dataset, webId: 'system' });
|
||||
/**
|
||||
* Persist `member` as the pair's sticky URN unless it already has one — before any attendee-list write
|
||||
* (see the header) — then resolve the member the entry holds NOW: `member` itself, or the one another
|
||||
* act persisted first. The caller applies that one, never its own candidate.
|
||||
*/
|
||||
async function persistMember(ctx, { dataset, meetingPointUri, participantUri, member }) {
|
||||
await ctx.call('triplestore.update', { query: persistMemberUpdate(meetingPointUri, participantUri, member), dataset, webId: 'system' });
|
||||
const entry = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
|
||||
if (!entry || !entry.member) throw new Error(`the member URN of ${participantUri} on ${meetingPointUri} could not be persisted`);
|
||||
return entry.member;
|
||||
}
|
||||
|
||||
async function recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act }) {
|
||||
await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, participantUri, act), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attendanceSubject,
|
||||
lastAttendanceQuery,
|
||||
persistMemberUpdate,
|
||||
recordAttendanceUpdate,
|
||||
isNewerAct,
|
||||
lastAttendance,
|
||||
persistMember,
|
||||
recordAttendance
|
||||
};
|
||||
|
||||
@@ -4,14 +4,17 @@ const CONFIG = require('../config/config');
|
||||
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.
|
||||
// Attaches an `apods:attendees` collection 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 participant's MEMBER URN to it (never a WebID — see
|
||||
// registration.service.js, member-urn.js and decision_2026-10-29_participation-privacy-mechanism),
|
||||
// so the collection's members are opaque URNs only readers of the participant's secret (they, their
|
||||
// contacts, the app) can recognise, and no WAC group mirrors them.
|
||||
//
|
||||
// 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
|
||||
// platform default, observed 2026-10-12, not a choice made here. The collection is the PUBLIC
|
||||
// surface of a participation: a count of opaque members, no identity (the model, per
|
||||
// knowledge_data-scopes-and-discovery — not an open question). What this service settles: 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
|
||||
@@ -26,7 +29,9 @@ module.exports = {
|
||||
ordered: false,
|
||||
summary: 'Meeting point attendees'
|
||||
},
|
||||
createWacGroup: true
|
||||
// Members are member URNs, not agents — a mirrored WAC group would be meaningless
|
||||
// (decision_2026-10-29_participation-privacy-mechanism).
|
||||
createWacGroup: false
|
||||
},
|
||||
dependencies: ['public-read'],
|
||||
// The mixin's create handler would act on the object embedded in the (unauthenticated) webhook
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The catalog index, persisted in the backend's own triplestore so it survives a
|
||||
* backend restart (it used to be process memory).
|
||||
*
|
||||
* WHERE IT LIVES. In the backend's main dataset (`settings`), in the DEFAULT graph.
|
||||
* WHERE IT LIVES. In the backend's main dataset (`SEMAPPS_MAIN_DATASET`), in the DEFAULT graph.
|
||||
* Not in a dedicated named graph: that dataset is assembled from TDB2 graphs, and only
|
||||
* the graphs its assembler declares are TDB2-backed — a triple written to any other
|
||||
* named graph is held in memory by Fuseki and lost when Fuseki restarts (verified on
|
||||
|
||||
@@ -22,6 +22,19 @@ function arrayOf(value) {
|
||||
*/
|
||||
const isListable = item => hasListedPublicIntent(item);
|
||||
|
||||
/**
|
||||
* The creator attribution a catalog item carries — `dc:creator` as the event
|
||||
* resource serves it, publicly: a bare IRI string or an embedded `{ id }`.
|
||||
* An item without one was indexed from a resource that names no creator; it
|
||||
* can never match an owner filter.
|
||||
*/
|
||||
function creatorOf(item) {
|
||||
const creator = item?.['dc:creator'];
|
||||
if (typeof creator === 'string') return creator;
|
||||
if (creator && typeof creator === 'object' && typeof creator.id === 'string') return creator.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'catalog',
|
||||
mixins: [PodResourcesHandlerMixin],
|
||||
@@ -109,11 +122,16 @@ module.exports = {
|
||||
},
|
||||
actions: {
|
||||
discover: {
|
||||
// An `owner` narrows the listing to what that actor declared — the
|
||||
// catalog snapshot IS the event resource as the pod served it, with its
|
||||
// public `dc:creator` attribution, so the filter judges the same body the
|
||||
// listing serves. Without `owner`, every listed item is served, unchanged.
|
||||
async handler(ctx) {
|
||||
const { owner } = ctx.params ?? {};
|
||||
const { items, invalid } = await listEntries(ctx, { dataset: CONFIG.MAIN_DATASET });
|
||||
if (invalid.length > 0) this.logger.warn(`Catalog skipped unreadable entries: ${invalid.join(', ')}`);
|
||||
// Filtered on every read, whatever wrote the store: only listed public items are served.
|
||||
const listed = items.filter(isListable);
|
||||
const listed = items.filter(item => isListable(item) && (owner === undefined || creatorOf(item) === owner));
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Collection',
|
||||
|
||||
@@ -403,6 +403,56 @@ describe('catalog listing — only listed public items are served, shape unchang
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog discover — the owner filter narrows the listing to what an actor declared', () => {
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
// Declared events carry the creator attribution the pod serves publicly, as
|
||||
// a bare IRI or embedded — both forms must be matched.
|
||||
const alices = { ...listedEvent(ALICE), 'dc:creator': ALICE };
|
||||
const alicesEmbedded = { ...listedEvent(ALICE), id: `${ALICE}/data/e2`, 'dc:creator': { id: ALICE } };
|
||||
const bobs = { ...listedEvent(BOB), 'dc:creator': BOB };
|
||||
const noCreator = listedEvent('http://localhost:3000/carol');
|
||||
// Not listed, even though it is Alice's: the owner filter applies AFTER the
|
||||
// listing rule, so a non-public event of hers is never served to anyone.
|
||||
const alicesUnlisted = { ...event(ALICE, { to: BOB, content: 'only for a friend' }), 'dc:creator': ALICE };
|
||||
const stored = () => [rowOf(alices), rowOf(alicesEmbedded), rowOf(bobs), rowOf(noCreator), rowOf(alicesUnlisted)];
|
||||
|
||||
test('with an owner, only their listed items are served — dc:creator as a string or an embedded id', async () => {
|
||||
const { svc } = service();
|
||||
const ctx = Object.assign(store({ rows: stored() }), { params: { owner: ALICE } });
|
||||
const result = await catalog.actions.discover.handler.call(svc, ctx);
|
||||
expect(result.items).toEqual([alices, alicesEmbedded]);
|
||||
expect(result.totalItems).toBe(2);
|
||||
expect(JSON.stringify(result)).not.toContain('only for a friend');
|
||||
});
|
||||
|
||||
test('another owner gets only their own; the unlisted and creator-less items never match', async () => {
|
||||
const { svc } = service();
|
||||
const result = await catalog.actions.discover.handler.call(
|
||||
svc,
|
||||
Object.assign(store({ rows: stored() }), { params: { owner: BOB } })
|
||||
);
|
||||
expect(result.items).toEqual([bobs]);
|
||||
expect(result.totalItems).toBe(1);
|
||||
|
||||
// An owner nobody matches: an honest empty listing.
|
||||
const nobody = await catalog.actions.discover.handler.call(
|
||||
svc,
|
||||
Object.assign(store({ rows: stored() }), { params: { owner: 'http://localhost:3000/dan' } })
|
||||
);
|
||||
expect(nobody.items).toEqual([]);
|
||||
expect(nobody.totalItems).toBe(0);
|
||||
});
|
||||
|
||||
test('without owner (or without params at all) every listed item is served, unchanged', async () => {
|
||||
const { svc } = service();
|
||||
const all = [alices, alicesEmbedded, bobs, noCreator];
|
||||
const bare = await catalog.actions.discover.handler.call(svc, store({ rows: stored() }));
|
||||
expect(bare.items).toEqual(all);
|
||||
const emptyParams = await catalog.actions.discover.handler.call(svc, Object.assign(store({ rows: stored() }), { params: {} }));
|
||||
expect(emptyParams.items).toEqual(all);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog startup — entries it would not serve are removed from its store', () => {
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The `createAndAttach` action every Festipod collection handler uses (attendees, contacts), in place
|
||||
// of the one `PodCollectionsHandlerMixin` (@activitypods/app 2.3.2) gives them.
|
||||
// The `createAndAttach` action every Festipod collection handler uses (attendees, contacts,
|
||||
// participations), 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
|
||||
|
||||
@@ -2,6 +2,7 @@ const { test, expect, describe } = require('bun:test');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const attendees = require('./attendees.service');
|
||||
const contacts = require('./contacts.service');
|
||||
const participations = require('./participations.service');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
@@ -11,7 +12,7 @@ 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 } = {}) {
|
||||
function bind(schema, pod, { attached, groupFails = false, recordFails = false } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
@@ -23,6 +24,7 @@ function bind(schema, pod, { attached, groupFails = false } = {}) {
|
||||
}
|
||||
if (action === 'pod-collections.createAndAttach') return attached;
|
||||
if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed');
|
||||
if (action === 'public-read.recordCreated' && recordFails) throw new Error('record failed');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -45,25 +47,25 @@ const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object
|
||||
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 () => {
|
||||
test('a public event: collection attached, recorded, then reconciled — NO WAC group (members are member URNs, not agents)', 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(actions(calls)).not.toContain('pod-wac-groups.create');
|
||||
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 });
|
||||
test('the reconcile still runs when the recording right after the attach fails', async () => {
|
||||
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, recordFails: 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);
|
||||
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'public-read.reconcile']);
|
||||
expect(logs.error.some(m => m.includes('record failed'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => {
|
||||
@@ -125,3 +127,29 @@ describe('contacts — the webhook is a hint, and nothing attached creates no gr
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('participations — attached at a sign-up only, through the guarded createAndAttach', () => {
|
||||
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
|
||||
|
||||
test('a profile Create attaches nothing: the service handles no activity', () => {
|
||||
expect(Object.keys(participations.activities)).toEqual([]);
|
||||
});
|
||||
|
||||
test('createAndAttach on the profile, with the participations predicate, and no WAC group', async () => {
|
||||
const { svc, calls } = bind(participations, { [PROFILE]: profile }, { attached: `${ALICE}/data/as/collection/p1` });
|
||||
const uri = await svc.actions.createAndAttach({ resourceUri: PROFILE, actorUri: ALICE });
|
||||
expect(uri).toBe(`${ALICE}/data/as/collection/p1`);
|
||||
expect(calls.find(c => c.action === 'pod-collections.createAndAttach').params).toMatchObject({
|
||||
resourceUri: PROFILE,
|
||||
attachPredicate: 'http://festipod.org/participations',
|
||||
actorUri: ALICE
|
||||
});
|
||||
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
||||
});
|
||||
|
||||
test('nothing attached: undefined, nothing thrown', async () => {
|
||||
const { svc, logs } = bind(participations, { [PROFILE]: profile }, { attached: undefined });
|
||||
await expect(svc.actions.createAndAttach({ resourceUri: PROFILE, actorUri: ALICE })).resolves.toBeUndefined();
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// The items of a pod collection, read and checked with the pod's answer SEEN.
|
||||
//
|
||||
// The platform's `pod-collections` service (@activitypods/app 2.3.2) hides a failure of the pod:
|
||||
// `getItems` answers `[]` whatever the GET returned (it never looks at the status), and `add` /
|
||||
// `remove` resolve normally when the pod refuses the PATCH (a status ≥ 400 is only logged). A
|
||||
// decision taken on such an answer is wrong without anything saying so: a Leave read against a
|
||||
// failed read finds nobody to remove, and an act whose write was refused is recorded as applied.
|
||||
//
|
||||
// So the backend reads a collection through `pod-resources.get`, where the status is visible, and
|
||||
// verifies every write by reading the collection back. The writes themselves stay the platform's
|
||||
// calls (`pod-collections.add` / `.remove`, through the collection handlers' `add` / `remove`).
|
||||
//
|
||||
// readItems(ctx, { collectionUri, actorUri }) → the item URIs; throws unless the pod answers 200
|
||||
// with that very collection, its items inline (a paged collection is not read here: its items
|
||||
// are not in the root, and reading them as "none" is the defect this module exists to avoid).
|
||||
// expectItem(ctx, { collectionUri, itemUri, actorUri, present }) → resolves when the collection,
|
||||
// read again, holds `itemUri` (`present`) or does not (`!present`); throws otherwise — the write
|
||||
// just made did not take effect.
|
||||
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const { uriOf } = require('./emitted-activity');
|
||||
|
||||
const AS = 'https://www.w3.org/ns/activitystreams#';
|
||||
|
||||
/** The item URIs a collection node lists inline, under any spelling of `items` / `orderedItems`. */
|
||||
function itemsOf(collection) {
|
||||
const keys = ['items', 'orderedItems', 'as:items', 'as:orderedItems', `${AS}items`, `${AS}orderedItems`];
|
||||
const value = keys.map(key => collection[key]).find(v => v !== undefined);
|
||||
return arrayOf(value)
|
||||
.map(uriOf)
|
||||
.filter(uri => typeof uri === 'string');
|
||||
}
|
||||
|
||||
/** Whether a collection root sends its items to pages (`first`) instead of listing them. */
|
||||
const isPaged = collection =>
|
||||
['first', 'as:first', `${AS}first`].some(key => collection[key] !== undefined) && itemsOf(collection).length === 0;
|
||||
|
||||
async function readItems(ctx, { collectionUri, actorUri }) {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: collectionUri, actorUri });
|
||||
if (status !== 200 || !body || typeof body !== 'object') {
|
||||
throw new Error(`the pod does not return the collection ${collectionUri} (HTTP ${status})`);
|
||||
}
|
||||
if (uriOf(body) !== collectionUri) throw new Error(`the pod returned another resource for ${collectionUri}`);
|
||||
if (isPaged(body)) throw new Error(`the collection ${collectionUri} is paged: its items cannot be read from its root`);
|
||||
return itemsOf(body);
|
||||
}
|
||||
|
||||
async function expectItem(ctx, { collectionUri, itemUri, actorUri, present }) {
|
||||
const holds = (await readItems(ctx, { collectionUri, actorUri })).includes(itemUri);
|
||||
if (holds !== present) {
|
||||
throw new Error(`${collectionUri} ${present ? 'does not hold' : 'still holds'} ${itemUri} after the ${present ? 'add' : 'remove'}: the pod did not apply it`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { itemsOf, readItems, expectItem };
|
||||
@@ -0,0 +1,64 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { itemsOf, readItems, expectItem } = require('./collection-items');
|
||||
|
||||
const C = 'http://localhost:3000/alice/data/as/collection/c1';
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const A = 'urn:festipod:userref:aa';
|
||||
const B = 'urn:festipod:userref:bb';
|
||||
|
||||
/** A ctx whose `pod-resources.get` answers `reply` (a `{ status, body }`, or a function of the call). */
|
||||
const pod = reply => {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action !== 'pod-resources.get') throw new Error(`unexpected call ${action}`);
|
||||
return typeof reply === 'function' ? reply(params) : reply;
|
||||
}
|
||||
};
|
||||
};
|
||||
const collection = extra => ({ status: 200, body: { id: C, type: 'Collection', ...extra } });
|
||||
|
||||
describe('itemsOf — the item URIs a collection lists inline', () => {
|
||||
test('every spelling of items / orderedItems; strings and nodes; none', () => {
|
||||
expect(itemsOf({ items: [A, B] })).toEqual([A, B]);
|
||||
expect(itemsOf({ items: A })).toEqual([A]);
|
||||
expect(itemsOf({ items: [{ id: A }, { '@id': B }] })).toEqual([A, B]);
|
||||
expect(itemsOf({ orderedItems: [A] })).toEqual([A]);
|
||||
expect(itemsOf({ 'as:items': [A] })).toEqual([A]);
|
||||
expect(itemsOf({ 'https://www.w3.org/ns/activitystreams#items': [{ '@id': A }] })).toEqual([A]);
|
||||
expect(itemsOf({ totalItems: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readItems — a failed read throws, it never means "empty"', () => {
|
||||
test('a 200 with the collection: its items, read as the given actor', async () => {
|
||||
const ctx = pod(collection({ items: [A, B] }));
|
||||
expect(await readItems(ctx, { collectionUri: C, actorUri: ALICE })).toEqual([A, B]);
|
||||
expect(ctx.calls).toEqual([{ action: 'pod-resources.get', params: { resourceUri: C, actorUri: ALICE } }]);
|
||||
expect(await readItems(pod(collection({ totalItems: 0 })), { collectionUri: C, actorUri: ALICE })).toEqual([]);
|
||||
});
|
||||
|
||||
test('any other status, no body, another resource, a paged root: throws', async () => {
|
||||
for (const status of [0, 401, 403, 404, 500]) {
|
||||
await expect(readItems(pod({ status }), { collectionUri: C, actorUri: ALICE })).rejects.toThrow(`(HTTP ${status})`);
|
||||
}
|
||||
await expect(readItems(pod({ status: 200 }), { collectionUri: C, actorUri: ALICE })).rejects.toThrow('does not return the collection');
|
||||
await expect(readItems(pod({ status: 200, body: { id: `${C}-other`, items: [A] } }), { collectionUri: C, actorUri: ALICE })).rejects.toThrow('another resource');
|
||||
await expect(readItems(pod(collection({ first: `${C}?page=1`, totalItems: 3 })), { collectionUri: C, actorUri: ALICE })).rejects.toThrow('is paged');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectItem — a write is checked by reading the collection back', () => {
|
||||
test('present after an add, absent after a remove: resolves', async () => {
|
||||
await expect(expectItem(pod(collection({ items: [A] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: true })).resolves.toBeUndefined();
|
||||
await expect(expectItem(pod(collection({ items: [B] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: false })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('a write that did not take effect, or a read-back that fails: throws', async () => {
|
||||
await expect(expectItem(pod(collection({ items: [B] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: true })).rejects.toThrow(`does not hold ${A} after the add`);
|
||||
await expect(expectItem(pod(collection({ items: [A] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: false })).rejects.toThrow(`still holds ${A} after the remove`);
|
||||
await expect(expectItem(pod({ status: 500 }), { collectionUri: C, itemUri: A, actorUri: ALICE, present: false })).rejects.toThrow('(HTTP 500)');
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,12 @@ 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.
|
||||
// In front of EVERY route — these below and the ones CoreService's services add, the LDP
|
||||
// catch-all among them: only the allow-listed public endpoints pass, anything else is a 404 for
|
||||
// every caller. WebACL already filters what the catch-all serves (the main dataset is a secure
|
||||
// one); the gate is defence in depth, so that a resource whose rights are too wide is still not
|
||||
// served over HTTP — the backend's own services reach its data through internal calls. See
|
||||
// public-surface.js.
|
||||
use: [createPublicSurfaceGate(BASE_PATH)],
|
||||
routes: [
|
||||
{
|
||||
@@ -81,12 +83,10 @@ module.exports = {
|
||||
}
|
||||
]
|
||||
},
|
||||
webacl: 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.
|
||||
// No public SPARQL endpoint. CoreService mounts one at `/sparql` unless this is `false`; WebACL
|
||||
// would filter its answers by the caller's rights, but nothing in Festipod queries it, so it is
|
||||
// not mounted at all (defence in depth, like the gate above).
|
||||
sparqlEndpoint: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,7 +71,11 @@ function asTypesOf(node) {
|
||||
});
|
||||
}
|
||||
|
||||
const hasAsType = (node, name) => asTypesOf(node).includes(AS + name);
|
||||
// `name` is an AS name (`Event`), an `as:` name, or a full IRI: only bare names get the AS namespace.
|
||||
const hasAsType = (node, name) => {
|
||||
const iri = name.startsWith('as:') ? AS + name.slice('as:'.length) : name.includes(':') ? name : AS + name;
|
||||
return asTypesOf(node).includes(iri);
|
||||
};
|
||||
|
||||
const ACTIVITY_TYPES = new Set(['Create', 'Update', 'Delete', 'Join', 'Leave']);
|
||||
|
||||
@@ -86,8 +90,8 @@ const ANY_POD = Symbol('any pod');
|
||||
* `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).
|
||||
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the
|
||||
* stored object must declare — an ActivityStreams name or a full IRI; 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.
|
||||
*/
|
||||
@@ -195,7 +199,8 @@ function verifiedHandler(box, type, handle, verify) {
|
||||
* 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).
|
||||
* or `'any'` (the handler checks where the object lies, e.g. the meeting point a Join names, in its
|
||||
* creator's pod).
|
||||
*/
|
||||
function verifiedEmit(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
||||
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
||||
|
||||
@@ -264,3 +264,37 @@ describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
|
||||
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/outbox/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
||||
|
||||
test('the activity AND the meeting point are read through the outbox owner\'s access, the stored object handed over', async () => {
|
||||
const reads = [];
|
||||
const stored = { [JOIN]: join, [MP]: { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` } };
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
reads.push([action, params.resourceUri, params.actorUri]);
|
||||
const body = stored[params.resourceUri];
|
||||
return body ? { status: 200, body } : { status: 404 };
|
||||
}
|
||||
};
|
||||
const ran = [];
|
||||
const service = { name: 'test', logger: { info() {}, warn() {}, error() {} } };
|
||||
// The webhook body claims another object: only the stored one counts.
|
||||
await verifiedEmit('Join', async (_ctx, verdict) => ran.push(verdict.object), { objectType: 'Event', objectIn: 'any' })
|
||||
.call(service, ctx, { ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } }, BOB);
|
||||
expect(reads).toEqual([['pod-resources.get', JOIN, BOB], ['pod-resources.get', MP, BOB]]);
|
||||
expect(ran).toEqual([stored[MP]]);
|
||||
});
|
||||
|
||||
test('a Join whose stored actor is not the outbox owner is refused', async () => {
|
||||
const ctx = { call: async (_action, { resourceUri }) => (resourceUri === JOIN ? { status: 200, body: { ...join, actor: ALICE } } : { status: 200, body: { id: MP, type: 'Event' } }) };
|
||||
const ran = [];
|
||||
const service = { name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error() {} } };
|
||||
await verifiedEmit('Join', async () => ran.push(true), { objectType: 'Event', objectIn: 'any' }).call(service, ctx, join, BOB);
|
||||
expect(ran).toEqual([]);
|
||||
expect(service.logger.w).toContain('not attributed to');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
|
||||
// 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
|
||||
// for a fresh collection, the participant's participations collection made ready (a profile and an
|
||||
// ACL read; at its creation, the create-and-attach, a profile re-read, one ACL write and one group
|
||||
// grant), a read, two writes and their two read-backs (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;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// The member URN an attendee list records for a participant, and the secret it is derived from
|
||||
// (decision_2026-10-29_participation-privacy-mechanism).
|
||||
//
|
||||
// THE URN. `urn:festipod:userref:` + the lowercase-hex sha256 of the string
|
||||
// "<participant WebID> <event URI> <secret>" (single spaces), where the event URI is the meeting
|
||||
// point's anchor (`as:context`). It is the same for one person on every meeting point of one event —
|
||||
// so the per-event count of distinct URNs is publicly computable — and unrelated from one event to
|
||||
// another. Only whoever can read the secret can compute it: the person, their contacts, the app. The
|
||||
// backend computes it from the secret; a client never supplies one, so it cannot inflate a count.
|
||||
// ("festipod" here is the URN namespace, unrelated to the http://festipod.org/ vocabulary.)
|
||||
//
|
||||
// THE SECRET lives in the participant's `as:Profile` under `http://festipod.org/participationSecret`:
|
||||
// 32 random bytes as 64 lowercase hex characters, written by the participant's browser before the
|
||||
// first sign-up. Anything else — absent, several values, another length, uppercase — is no secret.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MEMBER_URN_PREFIX = 'urn:festipod:userref:';
|
||||
const PARTICIPATION_SECRET = 'http://festipod.org/participationSecret';
|
||||
const SECRET_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The string a JSON-LD value holds: a string, or a value node's `@value`; undefined otherwise. */
|
||||
const stringOf = value => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object' && typeof value['@value'] === 'string') return value['@value'];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* participationSecretOf(profile) → the participant's secret (64 lowercase hex characters), or
|
||||
* undefined when the profile carries none that is usable. Reads the predicate under its full IRI or
|
||||
* the `fp:` CURIE; exactly one value is accepted.
|
||||
*/
|
||||
function participationSecretOf(profile) {
|
||||
if (!profile || typeof profile !== 'object') return undefined;
|
||||
const values = listOf(profile[PARTICIPATION_SECRET] ?? profile['fp:participationSecret']);
|
||||
if (values.length !== 1) return undefined;
|
||||
const secret = stringOf(values[0]);
|
||||
return secret !== undefined && SECRET_PATTERN.test(secret) ? secret : undefined;
|
||||
}
|
||||
|
||||
/** memberUrn({ participantUri, eventUri, secret }) → `urn:festipod:userref:<sha256 hex>`. */
|
||||
function memberUrn({ participantUri, eventUri, secret }) {
|
||||
for (const [name, value] of Object.entries({ participantUri, eventUri, secret })) {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new Error(`memberUrn needs a ${name}`);
|
||||
}
|
||||
const digest = crypto.createHash('sha256').update(`${participantUri} ${eventUri} ${secret}`, 'utf8').digest('hex');
|
||||
return MEMBER_URN_PREFIX + digest;
|
||||
}
|
||||
|
||||
module.exports = { MEMBER_URN_PREFIX, PARTICIPATION_SECRET, participationSecretOf, memberUrn };
|
||||
@@ -0,0 +1,50 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const crypto = require('crypto');
|
||||
const { MEMBER_URN_PREFIX, PARTICIPATION_SECRET, participationSecretOf, memberUrn } = require('./member-urn');
|
||||
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
const CAROL = 'http://localhost:3000/carol';
|
||||
const EVENT = 'http://localhost:3000/alice/data/events/e1';
|
||||
const OTHER_EVENT = 'http://localhost:3000/alice/data/events/e2';
|
||||
const SECRET = '0123456789abcdef'.repeat(4);
|
||||
|
||||
describe('memberUrn — urn:festipod:userref:<sha256("<WebID> <event URI> <secret>")>', () => {
|
||||
test('the contract: the lowercase-hex sha256 of the three values joined by single spaces', () => {
|
||||
const expected = crypto.createHash('sha256').update(`${BOB} ${EVENT} ${SECRET}`).digest('hex');
|
||||
expect(memberUrn({ participantUri: BOB, eventUri: EVENT, secret: SECRET })).toBe(`urn:festipod:userref:${expected}`);
|
||||
expect(MEMBER_URN_PREFIX).toBe('urn:festipod:userref:');
|
||||
expect(memberUrn({ participantUri: BOB, eventUri: EVENT, secret: SECRET })).toMatch(/^urn:festipod:userref:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('deterministic for one person and one event; different for another event, person or secret', () => {
|
||||
const base = { participantUri: BOB, eventUri: EVENT, secret: SECRET };
|
||||
const urn = memberUrn(base);
|
||||
expect(memberUrn({ ...base })).toBe(urn);
|
||||
expect(memberUrn({ ...base, eventUri: OTHER_EVENT })).not.toBe(urn);
|
||||
expect(memberUrn({ ...base, participantUri: CAROL })).not.toBe(urn);
|
||||
expect(memberUrn({ ...base, secret: 'f'.repeat(64) })).not.toBe(urn);
|
||||
});
|
||||
|
||||
test('a missing value is a programming error, never a URN', () => {
|
||||
expect(() => memberUrn({ participantUri: BOB, eventUri: undefined, secret: SECRET })).toThrow('eventUri');
|
||||
expect(() => memberUrn({ participantUri: BOB, eventUri: EVENT, secret: '' })).toThrow('secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('participationSecretOf — 64 lowercase hex characters under fp:participationSecret, or nothing', () => {
|
||||
test('read under the full IRI, the CURIE, or as a value node', () => {
|
||||
expect(PARTICIPATION_SECRET).toBe('http://festipod.org/participationSecret');
|
||||
expect(participationSecretOf({ [PARTICIPATION_SECRET]: SECRET })).toBe(SECRET);
|
||||
expect(participationSecretOf({ 'fp:participationSecret': SECRET })).toBe(SECRET);
|
||||
expect(participationSecretOf({ [PARTICIPATION_SECRET]: { '@value': SECRET } })).toBe(SECRET);
|
||||
expect(participationSecretOf({ [PARTICIPATION_SECRET]: [SECRET] })).toBe(SECRET);
|
||||
});
|
||||
|
||||
test('absent, malformed or ambiguous: no secret', () => {
|
||||
for (const value of [undefined, '', SECRET.toUpperCase(), SECRET.slice(1), `${SECRET}0`, 'g'.repeat(64), 42, { id: SECRET }, [SECRET, 'f'.repeat(64)]]) {
|
||||
expect(participationSecretOf({ [PARTICIPATION_SECRET]: value })).toBeUndefined();
|
||||
}
|
||||
expect(participationSecretOf(undefined)).toBeUndefined();
|
||||
expect(participationSecretOf({ name: 'Bob' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// TEST SUPPORT ONLY (required by registration.service.test.js and participations.service.test.js):
|
||||
// a fake ActivityPods stack for the participation flow — the pods' resources, collections and ACLs,
|
||||
// the backend's attendance ledger and the notifications sent — with the registration and
|
||||
// participations services bound to it the way moleculer binds them, so that a call from one service
|
||||
// to the other runs the other's real code. Every call is recorded.
|
||||
//
|
||||
// The platform calls answer as @activitypods/app 2.3.2 does, failures included: `pod-resources.get`
|
||||
// carries the pod's status (no body on a failure); `pod-collections.getItems` reads through it and
|
||||
// answers `[]` whatever the status; `pod-collections.add` / `.remove` (and the attendees handler's
|
||||
// `add` / `remove`, which run them) resolve normally when the pod refuses the PATCH; and
|
||||
// `pod-collections.createAndAttach` returns the collection it created even when the pod refuses the
|
||||
// PATCH that links it to the resource.
|
||||
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const registration = require('./registration.service');
|
||||
const participations = require('./participations.service');
|
||||
|
||||
const HOST = 'http://localhost:3000';
|
||||
const APP = 'http://localhost:4000/as/actor/app';
|
||||
const ALICE = `${HOST}/alice`; // creates the meeting points
|
||||
const BOB = `${HOST}/bob`; // participates
|
||||
const CAROL = `${HOST}/carol`; // participates too
|
||||
const MALLORY = `${HOST}/mallory`;
|
||||
|
||||
const EVENT1 = `${HOST}/dave/data/events/festival`;
|
||||
const EVENT2 = `${HOST}/dave/data/events/concert`;
|
||||
const FP_SECRET = 'http://festipod.org/participationSecret';
|
||||
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
|
||||
const SECRETS = { [BOB]: 'b0'.repeat(32), [CAROL]: 'c0'.repeat(32), [ALICE]: 'a0'.repeat(32) };
|
||||
|
||||
const profileOf = actorUri => `${actorUri}/data/profiles/me`;
|
||||
const contactsOf = actorUri => `${actorUri}/contacts`;
|
||||
const groupOf = actorUri => `${HOST}/_groups${new URL(actorUri).pathname}/contacts`;
|
||||
const attendeesOf = meetingPointUri => `${meetingPointUri}-attendees`;
|
||||
|
||||
const actor = (uri, extra = {}) => ({ id: uri, type: 'Person', url: profileOf(uri), 'apods:contacts': contactsOf(uri), ...extra });
|
||||
const profile = (uri, extra = {}) => ({ id: profileOf(uri), type: 'Profile', name: uri.split('/').pop(), [FP_SECRET]: SECRETS[uri], ...extra });
|
||||
const meetingPoint = (uri, extra = {}) => ({
|
||||
id: uri,
|
||||
type: 'Event',
|
||||
name: `Rendez-vous ${uri.split('/').pop()}`,
|
||||
context: EVENT1,
|
||||
'dc:creator': ALICE,
|
||||
'apods:attendees': attendeesOf(uri),
|
||||
to: 'as:Public',
|
||||
...extra
|
||||
});
|
||||
// The pod stamps `published` on every activity posted to an outbox.
|
||||
const activity = (type, actorUri, objectUri, published, n = 1) => ({
|
||||
id: `${actorUri}/data/activities/${type.toLowerCase()}-${objectUri.split('/').pop()}-${n}`,
|
||||
type,
|
||||
actor: actorUri,
|
||||
object: objectUri,
|
||||
published
|
||||
});
|
||||
|
||||
/**
|
||||
* The ACL the pod gives a collection the app creates: an OWN public read (observed 2026-10-12) and
|
||||
* an OWN `#Control` for the app (observed 2026-10-29 on a participations collection).
|
||||
*/
|
||||
const createdCollectionAcl = uri => [
|
||||
{ '@id': `${HOST}/_acl${new URL(uri).pathname}#Read`, 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' },
|
||||
{ '@id': `${HOST}/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
|
||||
];
|
||||
|
||||
const ACT = 'http://festipod.org/attendanceAct';
|
||||
const MEMBER = 'http://festipod.org/attendanceMember';
|
||||
|
||||
/**
|
||||
* world(options) → { ctx, calls, state, registration, participations, logs, emit }
|
||||
*
|
||||
* options: `stored` (uri → body; the default actors and profiles are added), `items` (collection →
|
||||
* items; every collection a stored resource names under `apods:attendees` exists, empty when unset),
|
||||
* `acls` (collection → authorizations), `ledger` (attendance subject → act with its member URN),
|
||||
* `delays` (`read:<uri>`, `create`, `attendees.add`, `attendees.remove`, `pod-collections.add`,
|
||||
* `pod-collections.remove` → ms), `fail`, `timeoutMs` (the registration lock's time limit).
|
||||
*
|
||||
* `fail` (read at each call, so a test may change it between acts): `read` (uri → the status a GET of
|
||||
* it answers, with no body), `attendeesWrite` / `participationsWrite` (the pod refuses every PATCH of
|
||||
* an attendee list / a participations collection: the call resolves, nothing changes, the attempt is
|
||||
* kept in `state.refusedWrites`), `attach` (the pod creates the collection but refuses the PATCH that
|
||||
* links it), `putStatus` (the status of an ACL PUT), `grant`, `aclRead`, `notify`.
|
||||
*
|
||||
* A collection (a key of `state.items`) answers a GET as the pod serves an unpaged collection: its
|
||||
* `items` inline.
|
||||
*/
|
||||
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, timeoutMs } = {}) {
|
||||
const calls = [];
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const logger = { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) };
|
||||
const state = {
|
||||
stored: {
|
||||
[ALICE]: actor(ALICE),
|
||||
[BOB]: actor(BOB),
|
||||
[CAROL]: actor(CAROL),
|
||||
[profileOf(ALICE)]: profile(ALICE),
|
||||
[profileOf(BOB)]: profile(BOB),
|
||||
[profileOf(CAROL)]: profile(CAROL),
|
||||
...stored
|
||||
},
|
||||
items: JSON.parse(JSON.stringify(items)),
|
||||
acls: JSON.parse(JSON.stringify(acls)),
|
||||
ledger: { ...ledger },
|
||||
notifications: [],
|
||||
created: [],
|
||||
refusedWrites: []
|
||||
};
|
||||
// The attendees handler attached each meeting point's list when the meeting point was created.
|
||||
for (const body of Object.values(state.stored)) {
|
||||
const list = body && body['apods:attendees'];
|
||||
if (typeof list === 'string' && !(list in state.items)) state.items[list] = [];
|
||||
}
|
||||
const wait = ms => (ms ? new Promise(resolve => setTimeout(resolve, ms)) : undefined);
|
||||
const resourceOfAcl = url => url.replace(`${HOST}/_acl`, HOST);
|
||||
/** A GET as the pod answers it (`pod-resources.get`): the status, and the body on a 200 only. */
|
||||
const read = uri => {
|
||||
const failed = fail.read && fail.read[uri];
|
||||
if (failed) return { ok: false, status: failed };
|
||||
if (Object.prototype.hasOwnProperty.call(state.items, uri)) {
|
||||
return { ok: true, status: 200, body: { id: uri, type: 'Collection', items: [...state.items[uri]] } };
|
||||
}
|
||||
const body = state.stored[uri];
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
};
|
||||
|
||||
let registrationSvc;
|
||||
let participationsSvc;
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action.startsWith('participations.')) {
|
||||
return participationsSvc.actions[action.slice('participations.'.length)](params);
|
||||
}
|
||||
switch (action) {
|
||||
case 'app.get':
|
||||
return { id: APP };
|
||||
case 'pod-resources.get':
|
||||
await wait(delays[`read:${params.resourceUri}`]);
|
||||
return read(params.resourceUri);
|
||||
case 'pod-collections.getItems': {
|
||||
// As the platform does: the body's items, whatever the status — `[]` on a failed read.
|
||||
const { body } = read(params.collectionUri);
|
||||
return arrayOf(body?.items || body?.orderedItems);
|
||||
}
|
||||
case 'attendees.add':
|
||||
case 'pod-collections.add':
|
||||
case 'attendees.remove':
|
||||
case 'pod-collections.remove': {
|
||||
// The attendees handler's add/remove run `pod-collections.add`/`.remove` as the creator; the
|
||||
// participations handler's, as the participant. A refused PATCH resolves, as on the platform.
|
||||
await wait(delays[action]);
|
||||
if (fail[action.startsWith('attendees.') ? 'attendeesWrite' : 'participationsWrite']) {
|
||||
state.refusedWrites.push({ action, ...params });
|
||||
return undefined;
|
||||
}
|
||||
const list = (state.items[params.collectionUri] = state.items[params.collectionUri] || []);
|
||||
if (action.endsWith('.add')) {
|
||||
if (!list.includes(params.itemUri)) list.push(params.itemUri);
|
||||
} else {
|
||||
state.items[params.collectionUri] = list.filter(item => item !== params.itemUri);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case 'pod-collections.createAndAttach': {
|
||||
// As the platform does: read the resource, then create, then attach — with a gap between
|
||||
// the read and the attach, where a concurrent creation can slip in. The attach PATCH is not
|
||||
// checked: the created collection's URI is returned even when the pod refused it.
|
||||
const before = state.stored[params.resourceUri];
|
||||
await wait(delays.create === undefined ? 10 : delays.create);
|
||||
if (!before || before[params.attachPredicate] !== undefined) return undefined;
|
||||
const uri = `${params.actorUri}/data/as/collection/participations-${state.created.length + 1}`;
|
||||
state.created.push(uri);
|
||||
state.items[uri] = [];
|
||||
state.acls[uri] = createdCollectionAcl(uri);
|
||||
if (fail.attach) return uri;
|
||||
const current = state.stored[params.resourceUri];
|
||||
const link = { id: uri };
|
||||
const existing = current[params.attachPredicate];
|
||||
state.stored[params.resourceUri] = { ...current, [params.attachPredicate]: existing === undefined ? link : [].concat(existing, link) };
|
||||
return uri;
|
||||
}
|
||||
case 'pod-wac-groups.getUriFromCollectionUri': {
|
||||
const { origin, pathname } = new URL(params.collectionUri);
|
||||
return `${origin}/_groups${pathname}`;
|
||||
}
|
||||
case 'pod-permissions.fetch': {
|
||||
if (fail.aclRead) return { status: 500 };
|
||||
const authorizations = state.acls[resourceOfAcl(params.url)];
|
||||
return authorizations ? { status: 200, body: { '@graph': authorizations } } : { status: 404 };
|
||||
}
|
||||
case 'pod-permissions.add': {
|
||||
if (fail.grant) return false;
|
||||
const acl = (state.acls[params.uri] = state.acls[params.uri] || []);
|
||||
const id = `${HOST}/_acl${new URL(params.uri).pathname}#Read`;
|
||||
let rule = acl.find(a => a['@id'] === id);
|
||||
if (!rule) acl.push((rule = { '@id': id, 'acl:mode': 'acl:Read' }));
|
||||
rule[params.agentPredicate] = [...new Set([].concat(rule[params.agentPredicate] ?? [], params.agentUri))];
|
||||
return true;
|
||||
}
|
||||
case 'signature.proxy.query': {
|
||||
if (params.method !== 'PUT') throw new Error(`unexpected proxy ${params.method} ${params.url}`);
|
||||
if (fail.putStatus) return { status: fail.putStatus };
|
||||
const uri = resourceOfAcl(params.url);
|
||||
const inherited = (state.acls[uri] || []).filter(a => String(a['@id']).includes('#Default'));
|
||||
state.acls[uri] = [...inherited, ...JSON.parse(params.body)['@graph']];
|
||||
return { status: 204 };
|
||||
}
|
||||
case 'triplestore.query': {
|
||||
// As the real SELECT, anchored on `<member> ?member`: no row for an entry without a member;
|
||||
// the act fields unbound (absent) until an act is recorded.
|
||||
const subject = /<(urn:festipod:attendance:[^>]+)>/.exec(params.query)[1];
|
||||
const last = state.ledger[subject];
|
||||
if (!last || last.member === undefined) return [];
|
||||
const row = { member: { value: last.member } };
|
||||
if (last.activityUri !== undefined) Object.assign(row, { act: { value: last.activityUri }, published: { value: last.published }, change: { value: last.change } });
|
||||
return [row];
|
||||
}
|
||||
case 'triplestore.update': {
|
||||
const subject = /<(urn:festipod:attendance:[^>]+)>/.exec(params.query)[1];
|
||||
const entry = (state.ledger[subject] = state.ledger[subject] || {});
|
||||
// `recordAttendance`: replaces the act fields, leaves the member as persisted.
|
||||
const act = new RegExp(`<${ACT}> <([^>]+)> ; <[^>]+> "([^"]*)" ; <[^>]+> "([^"]*)"`).exec(params.query);
|
||||
if (act) {
|
||||
Object.assign(entry, { activityUri: act[1], published: act[2], change: act[3] });
|
||||
return undefined;
|
||||
}
|
||||
// `persistMember`: INSERT … WHERE { FILTER NOT EXISTS { member } } — set only when absent.
|
||||
const member = new RegExp(`<${MEMBER}> "([^"]*)"`).exec(params.query);
|
||||
if (member && entry.member === undefined) entry.member = member[1];
|
||||
return undefined;
|
||||
}
|
||||
case 'pod-notifications.send':
|
||||
if (fail.notify) throw new Error('the app outbox is down');
|
||||
state.notifications.push(params);
|
||||
return undefined;
|
||||
default:
|
||||
throw new Error(`unexpected call ${action}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The participations service, bound like collection-handlers.test.js binds a collection handler.
|
||||
participationsSvc = {
|
||||
name: participations.name,
|
||||
settings: { ...PodCollectionsHandlerMixin.settings, ...participations.settings },
|
||||
logger,
|
||||
actions: {}
|
||||
};
|
||||
participations.created.call(participationsSvc);
|
||||
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...participations.methods })) {
|
||||
participationsSvc[name] = fn.bind(participationsSvc);
|
||||
}
|
||||
for (const [name, def] of Object.entries({ ...PodCollectionsHandlerMixin.actions, ...participations.actions })) {
|
||||
participationsSvc.actions[name] = (params, opts) => def.handler.call(participationsSvc, { ...opts, params, call: ctx.call });
|
||||
}
|
||||
|
||||
registrationSvc = { name: registration.name, logger };
|
||||
registration.created.call(registrationSvc);
|
||||
if (timeoutMs) {
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
registrationSvc.serialize = createKeyedSerializer({ timeoutMs });
|
||||
}
|
||||
for (const [name, fn] of Object.entries(registration.methods)) registrationSvc[name] = fn.bind(registrationSvc);
|
||||
|
||||
/** Notifies the registration service of an activity emitted in `actorUri`'s outbox. */
|
||||
const emit = (key, hint, actorUri) => registration.activities[key].onEmit.call(registrationSvc, ctx, hint, actorUri);
|
||||
|
||||
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, emit };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
world,
|
||||
activity,
|
||||
meetingPoint,
|
||||
actor,
|
||||
profile,
|
||||
profileOf,
|
||||
contactsOf,
|
||||
groupOf,
|
||||
attendeesOf,
|
||||
createdCollectionAcl,
|
||||
HOST,
|
||||
APP,
|
||||
ALICE,
|
||||
BOB,
|
||||
CAROL,
|
||||
MALLORY,
|
||||
EVENT1,
|
||||
EVENT2,
|
||||
SECRETS,
|
||||
FP_SECRET,
|
||||
FP_PARTICIPATIONS
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { uriOf } = require('./emitted-activity');
|
||||
const { guardedCreateAndAttach } = require('./collection-attach');
|
||||
const { hasInheritedPublicRead, hasOwnPublicRead, revocationGraph, ownReaders, aclUriOf, readAcl, putOwnRights } = require('./public-read-acl');
|
||||
|
||||
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
|
||||
|
||||
/** The collection URIs a profile links under `fp:participations` (full IRI or CURIE). */
|
||||
const participationsLinksOf = profile =>
|
||||
arrayOf(profile[FP_PARTICIPATIONS] ?? profile['fp:participations'])
|
||||
.map(uriOf)
|
||||
.filter(uri => typeof uri === 'string');
|
||||
|
||||
// A person's participations collection (decision_2026-10-29_participation-privacy-mechanism): the
|
||||
// person-side record of the meeting points they joined, for themself and for their contacts. An
|
||||
// unordered `as:Collection` in the participant's pod, attached to their `as:Profile` under
|
||||
// `http://festipod.org/participations`, whose items are meeting point URIs — added on a Join and
|
||||
// removed on a Leave by registration.service.js, acting as the participant.
|
||||
//
|
||||
// Who may read it: its owner, the app, and the participant's `/contacts` WAC group — through ONE
|
||||
// `acl:agentGroup` grant on the collection's own `#Read` rule (the welcometomyplace pattern), the
|
||||
// group URI derived from the `apods:contacts` collection (`pod-wac-groups.getUriFromCollectionUri`).
|
||||
// The GROUP, not the `apods:contacts` LIST: a received `Accept` of a contact `Offer` is added to the
|
||||
// list with no prior request checked (platform defect reported upstream), so the list is forgeable —
|
||||
// a stranger could grant themself read; the group gains a member only through the person's OWN
|
||||
// emitted activities (a contact request they send, an acceptance they emit), which a forged received
|
||||
// activity cannot do. (The group does admit the target of a request the person SENT before it is
|
||||
// accepted, and keeps a removed contact — platform behaviours, both deliberate here: see the e2e.)
|
||||
// And never anyone else: the pod creates a collection world-readable (`acl:Read` for `foaf:Agent`,
|
||||
// observed 2026-10-12 on the attendee lists), and a participations collection readable by strangers
|
||||
// would list a person's events to anyone.
|
||||
//
|
||||
// `syncReaders` makes that true from the collection's ACL as the pod serves it NOW, read as the
|
||||
// participant: the pod's own public read goes (the `revocationGraph` PUT of `public-read-acl.js` —
|
||||
// `pod-permissions.remove` cannot do it, see there), then the `/contacts` group gets its grant if it
|
||||
// has none (`pod-permissions.add`, the welcometomyplace grant call). It runs on every Join (`ensure`,
|
||||
// before the meeting point is added). The grant names a STABLE group URI, so it never needs revisiting
|
||||
// when the person's contacts change — the pod keeps the group's membership, nothing here to re-sync.
|
||||
// Idempotent: on a collection already in that state, one ACL read and nothing written. A failure
|
||||
// throws — the act that needed it is not recorded, the next one retries.
|
||||
//
|
||||
// Created at a person's first sign-up, never on a profile's Create (no activity handler here): a
|
||||
// profile with no collection has no participation to list. One run at a time per person — two Joins of
|
||||
// one person on two meeting points run concurrently and would otherwise both find no collection and
|
||||
// attach one each.
|
||||
module.exports = {
|
||||
name: 'participations',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
|
||||
attachPredicate: FP_PARTICIPATIONS,
|
||||
collectionOptions: {
|
||||
ordered: false,
|
||||
summary: 'Festipod participations'
|
||||
},
|
||||
// Items are meeting points, not agents: no mirrored WAC group.
|
||||
createWacGroup: false
|
||||
},
|
||||
dependencies: ['pod-permissions'],
|
||||
// Replaces the mixin's whole map (moleculer does not merge a custom schema key): no handler, so
|
||||
// no collection is attached to a profile when it is created.
|
||||
activities: {},
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
actions: {
|
||||
// Nothing attached → no WAC group (see collection-attach.js).
|
||||
createAndAttach: guardedCreateAndAttach,
|
||||
/** The collection the participant's profile links now, or undefined (see `collectionOf`). */
|
||||
collectionOf: {
|
||||
params: {
|
||||
actorUri: { type: 'string' },
|
||||
profileUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.collectionOf(ctx, ctx.params);
|
||||
}
|
||||
},
|
||||
/** The participant's collection, created if none and readable by their `/contacts` group only (see `ensure`). */
|
||||
ensure: {
|
||||
params: {
|
||||
actorUri: { type: 'string' },
|
||||
profileUri: { type: 'string' },
|
||||
contactsUri: { type: 'string', optional: true }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.ensure(ctx, ctx.params);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* The collection `profileUri` (the profile of `actorUri`) links NOW — read from the pod as the
|
||||
* participant — or undefined when it links none. Throws when the profile cannot be read, links
|
||||
* several, or links one outside the participant's pod (ignored: nothing is written to it).
|
||||
*/
|
||||
async collectionOf(ctx, { actorUri, profileUri }) {
|
||||
if (!isInActorPod(profileUri, actorUri)) throw new Error(`the profile ${profileUri} is not in the pod of ${actorUri}`);
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: profileUri, actorUri });
|
||||
if (status !== 200 || !body || uriOf(body) !== profileUri) {
|
||||
throw new Error(`the pod does not return the profile ${profileUri} (HTTP ${status})`);
|
||||
}
|
||||
const links = participationsLinksOf(body);
|
||||
if (links.length > 1) throw new Error(`the profile ${profileUri} links several participations collections`);
|
||||
if (links.length === 1 && !isInActorPod(links[0], actorUri)) {
|
||||
throw new Error(`the profile ${profileUri} links a participations collection outside the pod of ${actorUri}: ignored`);
|
||||
}
|
||||
return links[0];
|
||||
},
|
||||
/**
|
||||
* The participant's collection, ready to receive a meeting point: the one the profile links, or
|
||||
* one created and attached now when it links none; then readable by the participant's `/contacts`
|
||||
* WAC group and by no one else (`syncReaders`). One run at a time per participant. Resolves the
|
||||
* collection URI; throws when any of this cannot be made true — nothing may then be added to it.
|
||||
*
|
||||
* After an attach, the collection used is the one the profile links when READ AGAIN, never the
|
||||
* URI the attach returned: the platform's `createAndAttach` returns the collection it created even
|
||||
* when the pod refused the profile PATCH that links it (it does not check that write), and returns
|
||||
* nothing when the profile gained a link meanwhile (another app). A created collection the profile
|
||||
* does not link is left unused, empty — its contacts could never find it.
|
||||
*/
|
||||
async ensure(ctx, { actorUri, profileUri, contactsUri }) {
|
||||
return this.serialize(actorUri, async signal => {
|
||||
let collectionUri = await this.collectionOf(ctx, { actorUri, profileUri });
|
||||
if (!collectionUri) {
|
||||
const attached = await this.actions.createAndAttach({ resourceUri: profileUri, actorUri }, { parentCtx: ctx });
|
||||
collectionUri = await this.collectionOf(ctx, { actorUri, profileUri });
|
||||
if (!collectionUri) {
|
||||
throw new Error(
|
||||
attached
|
||||
? `the pod created ${attached} but did not link it to ${profileUri}: left unused`
|
||||
: `no participations collection could be attached to ${profileUri}`
|
||||
);
|
||||
}
|
||||
if (attached === collectionUri) this.logger.info(`Attached the participations collection ${collectionUri} to ${profileUri}`);
|
||||
else if (attached) this.logger.warn(`${profileUri} links ${collectionUri}, not the collection ${attached} just created: that one is left unused`);
|
||||
}
|
||||
await this.syncReaders(ctx, { collectionUri, actorUri, contactsUri, signal });
|
||||
return collectionUri;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Makes `collectionUri` readable by the participant's `/contacts` WAC group (one `acl:agentGroup`
|
||||
* grant on its own `#Read` rule, the group URI derived from their `apods:contacts` collection) and
|
||||
* by no one else: the pod's own public read is removed first (the `revocationGraph` PUT, one call,
|
||||
* the app keeping its `#Control`), then the group is granted read if it has none. The grant names a
|
||||
* stable group URI whose membership the pod keeps, so it is set once and never re-synced. Gives up
|
||||
* before any write once its time is out (`signal`). Idempotent: on a collection already in that
|
||||
* state, one ACL read and nothing written. Throws when the ACL cannot be read, the pod refuses a
|
||||
* change, or the collection stays world-readable through a rule inherited from its container (not
|
||||
* removable here) — the Join that needed it is then not recorded, and retries.
|
||||
*/
|
||||
async syncReaders(ctx, { collectionUri, actorUri, contactsUri, signal }) {
|
||||
if (!contactsUri || !isInActorPod(contactsUri, actorUri)) {
|
||||
throw new Error(`${actorUri} names no contacts collection in their pod: who may read ${collectionUri} is unknown`);
|
||||
}
|
||||
const groupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
|
||||
const acl = await readAcl(ctx, collectionUri, actorUri);
|
||||
if (!acl.authorizations) throw new Error(`the ACL of ${collectionUri} could not be read (HTTP ${acl.status})`);
|
||||
if (hasInheritedPublicRead(acl.authorizations)) {
|
||||
throw new Error(`${collectionUri} is world-readable through a rule inherited from its container: not removable here`);
|
||||
}
|
||||
if (hasOwnPublicRead(acl.authorizations)) {
|
||||
if (signal && signal.aborted) throw new Error(`out of time before removing the public read of ${collectionUri}`);
|
||||
const document = revocationGraph(acl.authorizations, collectionUri, actorUri, aclUriOf(collectionUri));
|
||||
const status = await putOwnRights(ctx, collectionUri, document);
|
||||
if (status !== 204 && status !== 200) {
|
||||
throw new Error(`the pod refused to remove the public read of ${collectionUri} (HTTP ${status})`);
|
||||
}
|
||||
this.logger.info(`Removed the public read of ${collectionUri}`);
|
||||
}
|
||||
if (!ownReaders(acl.authorizations).agentGroup.includes(groupUri)) {
|
||||
if (signal && signal.aborted) throw new Error(`out of time before granting the contacts group read on ${collectionUri}`);
|
||||
const added = await ctx.call('pod-permissions.add', {
|
||||
uri: collectionUri,
|
||||
agentPredicate: 'acl:agentGroup',
|
||||
agentUri: groupUri,
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
if (!added) throw new Error(`the pod refused the read grant of the contacts group ${groupUri} on ${collectionUri}`);
|
||||
this.logger.info(`Granted the contacts group ${groupUri} read on ${collectionUri}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const participations = require('./participations.service');
|
||||
const { hasOwnPublicRead, ownReaders } = require('./public-read-acl');
|
||||
const { world, profile, profileOf, contactsOf, groupOf, createdCollectionAcl, APP, ALICE, BOB, MALLORY, FP_PARTICIPATIONS } = require('./participation-world');
|
||||
|
||||
const ensure = (w, extra = {}) => w.participations.actions.ensure({ actorUri: BOB, profileUri: profileOf(BOB), contactsUri: contactsOf(BOB), ...extra });
|
||||
const collectionOf = w => w.participations.actions.collectionOf({ actorUri: BOB, profileUri: profileOf(BOB) });
|
||||
const EXISTING = `${BOB}/data/as/collection/participations-existing`;
|
||||
const linked = uri => ({ [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: { id: uri } }) });
|
||||
const aclId = (uri, rule) => `http://localhost:3000/_acl${new URL(uri).pathname}#${rule}`;
|
||||
const appControl = uri => ({ '@id': aclId(uri, 'Control'), 'acl:mode': 'acl:Control', 'acl:agent': APP });
|
||||
/** The /contacts WAC group that may read Bob's participations collection (what the grant names). */
|
||||
const GROUP = groupOf(BOB);
|
||||
/** A collection readable by Bob's /contacts group, the app keeping its Control. */
|
||||
const groupRead = uri => ({ '@id': aclId(uri, 'Read'), 'acl:mode': 'acl:Read', 'acl:agentGroup': GROUP });
|
||||
const protectedAcl = uri => [groupRead(uri), appControl(uri)];
|
||||
/** The collection created at the first sign-up (the world names the first one `participations-1`). */
|
||||
const CREATED = `${BOB}/data/as/collection/participations-1`;
|
||||
/** Who the collection's own #Read rule lets read, plus every inherited rule's readers. */
|
||||
const readersOf = (w, uri) => ownReaders(w.state.acls[uri]);
|
||||
const writes = w => w.calls.filter(c => ['pod-collections.createAndAttach', 'pod-permissions.add', 'signature.proxy.query'].includes(c.action));
|
||||
|
||||
describe('participations — the collection is attached at the first sign-up, never on a profile Create', () => {
|
||||
test('no activity handler, the full-IRI attach predicate, unordered, no mirrored group', () => {
|
||||
expect(participations.activities).toEqual({});
|
||||
expect(participations.settings.attachPredicate).toBe('http://festipod.org/participations');
|
||||
expect(participations.settings.collectionOptions.ordered).toBe(false);
|
||||
expect(participations.settings.createWacGroup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('participations.collectionOf — the collection the profile links now', () => {
|
||||
test('none, one, one outside the pod, several, an unreadable profile', async () => {
|
||||
expect(await collectionOf(world())).toBeUndefined();
|
||||
expect(await collectionOf(world({ stored: linked(EXISTING) }))).toBe(EXISTING);
|
||||
expect(await collectionOf(world({ stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: EXISTING }) } }))).toBe(EXISTING); // a plain IRI value
|
||||
await expect(collectionOf(world({ stored: linked(`${MALLORY}/data/as/collection/x`) }))).rejects.toThrow('outside the pod');
|
||||
await expect(collectionOf(world({ stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: [{ id: EXISTING }, { id: `${EXISTING}-2` }] }) } }))).rejects.toThrow('several');
|
||||
await expect(collectionOf(world({ stored: { [profileOf(BOB)]: undefined } }))).rejects.toThrow('does not return the profile');
|
||||
});
|
||||
|
||||
test('a profile outside the participant\'s pod is never read', async () => {
|
||||
const w = world();
|
||||
await expect(w.participations.actions.collectionOf({ actorUri: BOB, profileUri: profileOf(MALLORY) })).rejects.toThrow('not in the pod');
|
||||
expect(w.calls.some(c => c.action === 'pod-resources.get')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('participations.ensure — a collection readable by the participant\'s /contacts group', () => {
|
||||
test('no collection: one is created and attached, the /contacts group granted read, the pod\'s public read removed', async () => {
|
||||
const w = world();
|
||||
const uri = await ensure(w);
|
||||
expect(uri).toBe(CREATED);
|
||||
expect(w.state.created).toEqual([uri]);
|
||||
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toEqual({ id: uri });
|
||||
const attach = w.calls.find(c => c.action === 'pod-collections.createAndAttach');
|
||||
expect(attach.params).toMatchObject({ resourceUri: profileOf(BOB), attachPredicate: FP_PARTICIPATIONS, actorUri: BOB });
|
||||
const grants = w.calls.filter(c => c.action === 'pod-permissions.add').map(c => c.params);
|
||||
expect(grants).toEqual([{ uri, agentPredicate: 'acl:agentGroup', agentUri: GROUP, mode: 'acl:Read', actorUri: BOB }]);
|
||||
expect(hasOwnPublicRead(w.state.acls[uri])).toBe(false);
|
||||
expect(readersOf(w, uri)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
|
||||
// The group URI is DERIVED from her apods:contacts collection; the list itself is never read.
|
||||
expect(w.calls.find(c => c.action === 'pod-wac-groups.getUriFromCollectionUri').params).toEqual({ collectionUri: contactsOf(BOB) });
|
||||
expect(w.calls.some(c => c.action === 'pod-collections.getItems')).toBe(false);
|
||||
});
|
||||
|
||||
test('the public read goes FIRST (one PUT, the app keeping its Control), then the group grant', async () => {
|
||||
const w = world();
|
||||
const uri = await ensure(w);
|
||||
const order = writes(w).map(c => c.action);
|
||||
expect(order).toEqual(['pod-collections.createAndAttach', 'signature.proxy.query', 'pod-permissions.add']);
|
||||
const put = w.calls.find(c => c.action === 'signature.proxy.query');
|
||||
expect(put.params.url).toBe(`http://localhost:3000/_acl${new URL(uri).pathname}`);
|
||||
expect(put.params.actorUri).toBe(APP);
|
||||
const graph = JSON.parse(put.params.body)['@graph'];
|
||||
expect(JSON.stringify(graph)).not.toContain('foaf:Agent');
|
||||
expect(graph.some(n => n['@id'].endsWith('#Control') && n['acl:agent'] === APP)).toBe(true);
|
||||
});
|
||||
|
||||
test('no contact yet: the group is granted all the same — its membership is the pod\'s business', async () => {
|
||||
const w = world();
|
||||
const uri = await ensure(w);
|
||||
expect(readersOf(w, uri)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
|
||||
// No per-contact acl:agent grant is ever made; the group carries everyone.
|
||||
expect(w.calls.every(c => c.action !== 'pod-permissions.add' || c.params.agentPredicate === 'acl:agentGroup')).toBe(true);
|
||||
});
|
||||
|
||||
test('idempotent: on a collection already readable by the group, the ACL reads, nothing written', async () => {
|
||||
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: protectedAcl(EXISTING) } });
|
||||
expect(await ensure(w)).toBe(EXISTING);
|
||||
expect(await ensure(w)).toBe(EXISTING);
|
||||
expect(writes(w)).toEqual([]);
|
||||
expect(w.calls.filter(c => c.action === 'pod-permissions.fetch')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('an existing collection still world-readable: reused, the public read removed, the group granted', async () => {
|
||||
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: createdCollectionAcl(EXISTING) } });
|
||||
expect(await ensure(w)).toBe(EXISTING);
|
||||
expect(w.state.created).toEqual([]);
|
||||
expect(hasOwnPublicRead(w.state.acls[EXISTING])).toBe(false);
|
||||
expect(readersOf(w, EXISTING)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
|
||||
});
|
||||
|
||||
test('an existing collection granting the group but still world-readable: the public read goes, the grant is not repeated', async () => {
|
||||
const bothOnRead = { '@id': aclId(EXISTING, 'Read'), 'acl:mode': 'acl:Read', 'acl:agentGroup': GROUP, 'acl:agentClass': 'foaf:Agent' };
|
||||
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: [bothOnRead, appControl(EXISTING)] } });
|
||||
await ensure(w);
|
||||
expect(hasOwnPublicRead(w.state.acls[EXISTING])).toBe(false);
|
||||
expect(readersOf(w, EXISTING)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
|
||||
expect(writes(w).map(c => c.action)).toEqual(['signature.proxy.query']); // the PUT only, no repeated grant
|
||||
});
|
||||
|
||||
test('one creation per participant: two concurrent calls attach ONE collection', async () => {
|
||||
const w = world({ delays: { create: 30 } });
|
||||
const [a, b] = await Promise.all([ensure(w), ensure(w)]);
|
||||
expect(a).toBe(b);
|
||||
expect(w.state.created).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('nothing attached (the profile gained a link meanwhile): the linked collection is used', async () => {
|
||||
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: protectedAcl(EXISTING) } });
|
||||
// collectionOf saw no link at first: simulate by an attach that finds one.
|
||||
const original = w.participations.collectionOf;
|
||||
let first = true;
|
||||
w.participations.collectionOf = async (...args) => (first ? ((first = false), undefined) : original(...args));
|
||||
expect(await ensure(w)).toBe(EXISTING);
|
||||
expect(w.state.created).toEqual([]);
|
||||
});
|
||||
|
||||
test('a collection the pod creates but does not link (the profile PATCH refused): ensure throws, the collection is never used', async () => {
|
||||
const w = world({ fail: { attach: true } });
|
||||
await expect(ensure(w)).rejects.toThrow(`the pod created ${CREATED} but did not link it`);
|
||||
expect(w.state.created).toEqual([CREATED]);
|
||||
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toBeUndefined();
|
||||
// Not protected, not granted: nothing is done with a collection no contact could find.
|
||||
expect(writes(w).map(c => c.action)).toEqual(['pod-collections.createAndAttach']);
|
||||
// The profile is read again after the attach: the link counts, not what the attach returned.
|
||||
expect(w.calls.filter(c => c.action === 'pod-resources.get' && c.params.resourceUri === profileOf(BOB))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('every failure throws — nothing may then be added to the collection', async () => {
|
||||
await expect(ensure(world({ fail: { putStatus: 403 } }))).rejects.toThrow('refused to remove the public read');
|
||||
await expect(ensure(world({ stored: linked(EXISTING), acls: { [EXISTING]: [appControl(EXISTING)] }, fail: { grant: true } }))).rejects.toThrow('refused the read grant of the contacts group');
|
||||
await expect(ensure(world({ fail: { aclRead: true } }))).rejects.toThrow('could not be read');
|
||||
await expect(ensure(world(), { contactsUri: undefined })).rejects.toThrow('names no contacts collection');
|
||||
await expect(ensure(world(), { contactsUri: `${MALLORY}/contacts` })).rejects.toThrow('names no contacts collection');
|
||||
const inherited = world({
|
||||
stored: linked(EXISTING),
|
||||
acls: { [EXISTING]: [...protectedAcl(EXISTING), { '@id': 'http://localhost:3000/_acl/bob/data/as/collection#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' }] }
|
||||
});
|
||||
await expect(ensure(inherited)).rejects.toThrow('inherited');
|
||||
expect(writes(inherited)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
// 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.
|
||||
// own agent triples (e.g. the `/contacts` group a participations collection grants read to) 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
|
||||
@@ -28,6 +28,15 @@
|
||||
// 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.
|
||||
//
|
||||
// `ownReaders` says who a resource's own `#Read` rule lets read (its agents, groups and classes):
|
||||
// participations.service.js uses it to tell whether the `/contacts` WAC group it grants read to a
|
||||
// participations collection is already granted.
|
||||
//
|
||||
// Two pod calls go with the graphs: `readAcl` (the ACL as the pod serves it to the app, a failure
|
||||
// told apart from "no public read") and `putOwnRights` (the PUT, as the app actor). Every Festipod
|
||||
// caller that reads or replaces an ACL goes through them: public-read (events and their attendee
|
||||
// lists) and participations (a person's participations collection).
|
||||
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
|
||||
@@ -98,12 +107,13 @@ const aclContext = aclUri => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
* revocationGraph(authorizations, resourceUri, ownerUri, aclUri) → 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). The other own rules (`#Write`, `#Append`, `#Control`) stay exactly as they are. 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 = [];
|
||||
@@ -134,13 +144,78 @@ const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => {
|
||||
return { '@context': aclContext(aclUri), '@graph': graph };
|
||||
};
|
||||
|
||||
/**
|
||||
* ownReaders(authorizations) → `{ agent, agentClass, agentGroup }`: the values (full IRIs, without
|
||||
* duplicates) the resource's OWN `#Read` authorization grants read to — not the inherited rules.
|
||||
*/
|
||||
const ownReaders = authorizations => {
|
||||
const readers = { agent: [], agentClass: [], agentGroup: [] };
|
||||
for (const authorization of arrayOf(authorizations)) {
|
||||
if (suffixOf(authorization) !== 'Read' || !expandedValuesOf(authorization, 'mode').includes(ACL + 'Read')) continue;
|
||||
for (const name of Object.keys(readers)) {
|
||||
for (const iri of expandedValuesOf(authorization, name)) {
|
||||
if (typeof iri === 'string' && !readers[name].includes(iri)) readers[name].push(iri);
|
||||
}
|
||||
}
|
||||
}
|
||||
return readers;
|
||||
};
|
||||
|
||||
/** 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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* readAcl(ctx, resourceUri, actorUri) → `{ 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 `putOwnRights` writes (`aclUriOf`), through the pod owner's
|
||||
* access (`actorUri`).
|
||||
*/
|
||||
async function 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] };
|
||||
}
|
||||
|
||||
/**
|
||||
* putOwnRights(ctx, resourceUri, document) → the HTTP status of the PUT of `document` (a
|
||||
* `revocationGraph`) on the resource's ACL, sent as the APP ACTOR through the platform's signature
|
||||
* proxy — the actor that holds `acl:Control` on the resources Festipod grants or revokes on.
|
||||
* Resolves the status (0 when none); the caller decides what a refusal means.
|
||||
*/
|
||||
async function putOwnRights(ctx, resourceUri, document) {
|
||||
const app = await ctx.call('app.get');
|
||||
const { status } =
|
||||
(await ctx.call('signature.proxy.query', {
|
||||
url: aclUriOf(resourceUri),
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify(document),
|
||||
actorUri: app.id || app['@id']
|
||||
})) || {};
|
||||
return typeof status === 'number' ? status : 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
grantsPublicRead,
|
||||
hasOwnPublicRead,
|
||||
hasInheritedPublicRead,
|
||||
revocationGraph,
|
||||
ownReaders,
|
||||
isPublicRead,
|
||||
expand,
|
||||
aclUriOf,
|
||||
readAcl,
|
||||
putOwnRights,
|
||||
FOAF_AGENT,
|
||||
ACL
|
||||
};
|
||||
|
||||
@@ -5,15 +5,18 @@ 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 {
|
||||
grantsPublicRead,
|
||||
hasOwnPublicRead,
|
||||
hasInheritedPublicRead,
|
||||
revocationGraph,
|
||||
aclUriOf,
|
||||
readAcl,
|
||||
putOwnRights,
|
||||
FOAF_AGENT
|
||||
} = require('./public-read-acl');
|
||||
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
|
||||
|
||||
/** 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}`;
|
||||
};
|
||||
|
||||
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. */
|
||||
@@ -165,22 +168,9 @@ module.exports = {
|
||||
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`).
|
||||
*/
|
||||
/** The resource's ACL as the pod serves it to the app (`public-read-acl.js` `readAcl`). */
|
||||
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] };
|
||||
return readAcl(ctx, resourceUri, actorUri);
|
||||
},
|
||||
/**
|
||||
* Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was
|
||||
@@ -277,17 +267,9 @@ module.exports = {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aclUri = aclUriOf(resourceUri);
|
||||
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUri);
|
||||
const app = await ctx.call('app.get');
|
||||
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUriOf(resourceUri));
|
||||
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']
|
||||
});
|
||||
const status = await putOwnRights(ctx, resourceUri, document);
|
||||
if (status !== 204 && status !== 200) {
|
||||
throw new Error(`The pod refused to revoke the public-read grant on ${resourceUri} (HTTP ${status})`);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// The backend's whole HTTP surface, as an allow-list checked in front of every route.
|
||||
//
|
||||
// WHY. The backend runs SemApps' CoreService with `webacl: false`, so nothing checks rights on the
|
||||
// resources it stores, and CoreService mounts routes Festipod never asked for — above all the LDP
|
||||
// catch-all (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`,
|
||||
// `authorization: false`), which served the backend's `settings` dataset to anyone: the app actor's
|
||||
// PRIVATE key (`GET /key`, observed 2026-10-13), the user registrations and grants the app holds
|
||||
// (`/interop/…`), the webhook listeners. A signed request is no better: any pod user can sign one.
|
||||
// Rather than chase each route (deny-list), the gateway serves ONLY what a caller legitimately needs
|
||||
// and refuses everything else with a 404 — for every caller, anonymous or signed, since none needs
|
||||
// more over HTTP: the backend's own services reach its data through internal calls, never through
|
||||
// these routes.
|
||||
// WHY. CoreService mounts routes Festipod never asked for — above all the LDP catch-all
|
||||
// (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`), over the dataset that
|
||||
// holds the app actor's PRIVATE key (`/key`), the user registrations and grants the app holds
|
||||
// (`/interop/…`), the catalog store and the ledgers. WebACL filters what those routes serve (the main
|
||||
// dataset is a secure one, see core.service.js), so each resource is only as private as the rights
|
||||
// its container gave it; this gate does not depend on those rights being right. A signed request is
|
||||
// no better than an anonymous one here: 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)
|
||||
|
||||
@@ -1,53 +1,122 @@
|
||||
const { ACTIVITY_TYPES, OBJECT_TYPES, matchActivity } = require('@semapps/activitypub');
|
||||
const { ACTIVITY_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 { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
|
||||
const { neverThrowingMatcher, verifiedEmit, singleUriOf, uriOf } = require('./emitted-activity');
|
||||
const { participationSecretOf, memberUrn } = require('./member-urn');
|
||||
const { readItems, expectItem } = require('./collection-items');
|
||||
|
||||
const AS = 'https://www.w3.org/ns/activitystreams#';
|
||||
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';
|
||||
// Routing matches the activity TYPE alone: routing is a hint, the verified handler re-reads the
|
||||
// activity and its object from the pod and decides (see emitted-activity.js).
|
||||
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN };
|
||||
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE };
|
||||
|
||||
/** 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];
|
||||
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]);
|
||||
/** The single URI a node names under the first of `keys` it carries (compacted, CURIE or full IRI). */
|
||||
const linkOf = (node, ...keys) => singleUriOf(keys.map(key => node[key]).find(value => value !== undefined));
|
||||
|
||||
// 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
|
||||
/** The first string a property holds (a string, a value node, or a list of either). */
|
||||
const firstStringOf = value =>
|
||||
arrayOf(value)
|
||||
.map(v => (typeof v === 'string' ? v : v && typeof v === 'object' ? v['@value'] : undefined))
|
||||
.find(v => typeof v === 'string' && v.trim().length > 0);
|
||||
|
||||
/**
|
||||
* What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by
|
||||
* `verifiedEmit`) anchored to an event by `as:context` — whose creator is its `dc:creator`, believed
|
||||
* only because 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). Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
function meetingPointTarget(meetingPoint, meetingPointUri) {
|
||||
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`);
|
||||
if (!eventUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single as:context (its event)` };
|
||||
const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
|
||||
if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` };
|
||||
if (!isInActorPod(meetingPointUri, creatorUri)) {
|
||||
return { ok: false, reason: `the meeting point ${meetingPointUri} is not in the pod of its creator ${creatorUri}` };
|
||||
}
|
||||
return { ok: true, eventUri, creatorUri };
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification template for the creator of a meeting point (`pod-notifications.send`, rendered in
|
||||
* the creator's language): always ANONYMOUS, the meeting point named through `meetingPointName`, the
|
||||
* link to its event's meeting points (`eventUri`). Every value travels as a template PARAMETER, never
|
||||
* inside the template, so no user text is compiled.
|
||||
*
|
||||
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
|
||||
* participant's contacts, and the only one the app can read — the participant's `apods:contacts` list —
|
||||
* is forgeable (a stranger's received `Accept` of a contact `Offer` is added to it with no prior
|
||||
* request checked; platform defect reported upstream). Until that list can be trusted, every
|
||||
* notification is anonymous.
|
||||
*/
|
||||
function notificationTemplate(change) {
|
||||
const title =
|
||||
change === 'join'
|
||||
? {
|
||||
en: 'A participant joined your meeting point "{{{meetingPointName}}}"',
|
||||
fr: 'Un participant a rejoint votre point de rencontre « {{{meetingPointName}}} »'
|
||||
}
|
||||
: {
|
||||
en: 'A participant left your meeting point "{{{meetingPointName}}}"',
|
||||
fr: 'Un participant a quitté votre point de rencontre « {{{meetingPointName}}} »'
|
||||
};
|
||||
return {
|
||||
title,
|
||||
actions: [{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }]
|
||||
};
|
||||
}
|
||||
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism): a person joins (leaves) a
|
||||
// meeting point with an `as:Join` (`as:Leave`) posted to their OWN outbox, naming the meeting point,
|
||||
// addressed to no one — so only its author and the app read it. The creator's inbox receives nothing.
|
||||
// The backend acts on EMISSION (`onEmit`, the welcometomyplace registration pattern), and records:
|
||||
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
|
||||
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
|
||||
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
|
||||
// - in the participant's participations collection (their pod, acting as the participant): the
|
||||
// meeting point URI — the collection created at the first Join and kept readable by the
|
||||
// participant's `/contacts` WAC group only (`participations.service.js`).
|
||||
// Then the creator is notified ANONYMOUSLY — always: naming would need a trustworthy "is the creator a
|
||||
// contact of the participant" signal, and the only one the app can read (the participant's
|
||||
// `apods:contacts` list) is forgeable (platform defect reported upstream, see `notificationTemplate`).
|
||||
// The creator has no other right on the participant: they learn a count and opaque URNs.
|
||||
// The webhook that reports the act is a hint (emitted-activity.js): the 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.
|
||||
// - the participant is the owner of the outbox the webhook fired on, and the stored activity's actor;
|
||||
// - the creator is the meeting point's `dc:creator`, accepted only when the meeting point lies in
|
||||
// that creator's pod (`meetingPointTarget`);
|
||||
// - the collections written to are the ones the STORED meeting point and the participant's STORED
|
||||
// profile 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.
|
||||
// meeting point (`attendance-ledger.js`, persisted, which also holds the member URN that act applied). Whatever order the notifications run in, the
|
||||
// participant ends in the state of their latest act. The member URN is STICKY per (meeting point,
|
||||
// participant): chosen once and persisted BEFORE any attendee-list write, so every later act reuses it —
|
||||
// the pair is never listed under two URNs, even after a half-way failure and a secret rewrite. An act is
|
||||
// recorded only once BOTH collections, READ BACK, show it (collection-items.js — the platform's own
|
||||
// collection calls answer "empty" on a failed read and resolve on a refused write); one that fails
|
||||
// half-way is not, nor notified, and the participant's next act on that meeting point re-applies it
|
||||
// (the two writes are not atomic).
|
||||
module.exports = {
|
||||
name: 'registration',
|
||||
mixins: [PodActivitiesHandlerMixin],
|
||||
dependencies: ['participations'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
@@ -55,6 +124,65 @@ module.exports = {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, the outbox
|
||||
* owner: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
|
||||
*/
|
||||
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: meetingPoint }, participantUri) {
|
||||
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
|
||||
const target = meetingPointTarget(meetingPoint, meetingPointUri);
|
||||
if (!target.ok) return refuse(target.reason);
|
||||
const participant = await this.participantOf(ctx, participantUri);
|
||||
if (!participant.ok) return refuse(participant.reason);
|
||||
const outcome = await this.updateAttendance(ctx, {
|
||||
change,
|
||||
eventUri: target.eventUri,
|
||||
secret: participant.secret,
|
||||
meetingPointUri,
|
||||
meetingPoint,
|
||||
activity,
|
||||
creatorUri: target.creatorUri,
|
||||
participantUri,
|
||||
profileUri: participant.profileUri,
|
||||
contactsUri: participant.contactsUri
|
||||
});
|
||||
if (outcome && outcome.changed) {
|
||||
await this.notifyCreator(ctx, {
|
||||
change,
|
||||
meetingPointUri,
|
||||
meetingPoint,
|
||||
eventUri: target.eventUri,
|
||||
creatorUri: target.creatorUri,
|
||||
participantUri
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The participant as their pod describes them, read as the participant: their actor document
|
||||
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
|
||||
* profile, which must carry a usable participation secret. Resolves `{ ok, profileUri,
|
||||
* contactsUri, secret }` or a refusal. `contactsUri` is the participant's `apods:contacts`
|
||||
* collection — whose `/contacts` WAC group is what may read their participations collection.
|
||||
*/
|
||||
async participantOf(ctx, participantUri) {
|
||||
const read = async uri => {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri: participantUri });
|
||||
return status === 200 && body && uriOf(body) === uri ? body : undefined;
|
||||
};
|
||||
const actor = await read(participantUri);
|
||||
if (!actor) return { ok: false, reason: `the pod does not return the actor ${participantUri}` };
|
||||
const profileUri = linkOf(actor, 'url', 'as:url', `${AS}url`);
|
||||
if (!profileUri || !isInActorPod(profileUri, participantUri)) {
|
||||
return { ok: false, reason: `${participantUri} names no single profile in their pod (${profileUri})` };
|
||||
}
|
||||
const profile = await read(profileUri);
|
||||
if (!profile) return { ok: false, reason: `the pod does not return the profile ${profileUri}` };
|
||||
const secret = participationSecretOf(profile);
|
||||
if (!secret) return { ok: false, reason: `the profile ${profileUri} carries no usable participation secret` };
|
||||
const contactsUri = linkOf(actor, 'apods:contacts', APODS_CONTACTS);
|
||||
return { ok: true, profileUri, contactsUri, secret };
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
@@ -79,11 +207,37 @@ module.exports = {
|
||||
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`).
|
||||
* The participant's participations collection, under the meeting point's lock: for a Join, the one
|
||||
* `participations.ensure` makes ready (created if none, contacts-only); for a Leave, the one the
|
||||
* profile links now, if any. Resolves `{ ok, collectionUri }` — `collectionUri` undefined for a
|
||||
* Leave without a collection — or `{ ok: false }` once the failure is logged: the act must then
|
||||
* write nothing and stay unrecorded, so that the next act retries.
|
||||
*/
|
||||
async participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri }) {
|
||||
try {
|
||||
const collectionUri =
|
||||
change === 'join'
|
||||
? await ctx.call('participations.ensure', { actorUri: participantUri, profileUri, contactsUri })
|
||||
: await ctx.call('participations.collectionOf', { actorUri: participantUri, profileUri });
|
||||
return { ok: true, collectionUri };
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`The participations collection of ${participantUri} is not ready (${e.message}): the ${change} is NOT recorded, the next act retries`
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Applies one participant act (`join` / `leave`, the stored activity `activity`) — under the
|
||||
* meeting point's lock, and only when the act is newer than the last one applied for that member
|
||||
* there (see the header). A stale act changes nothing; an act without a pod stamp cannot be placed
|
||||
* and changes nothing. The act is applied to the meeting point's attendees (as the creator), then
|
||||
* to the participant's participations collection (as the participant), each write read back
|
||||
* (`applyToAttendees`, `applyToParticipations`), and recorded once both show it; a failed read, a
|
||||
* refused write or a write that does not show leaves the act unrecorded (logged). Gives up before
|
||||
* any write once its time is out (`signal`). Resolves `{ changed }` when the act was applied —
|
||||
* `changed` when it added or removed the member, or flipped the state the ledger held — and
|
||||
* undefined when it was not.
|
||||
*
|
||||
* 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
|
||||
@@ -93,37 +247,116 @@ module.exports = {
|
||||
* 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 }) {
|
||||
async updateAttendance(ctx, { change, eventUri, secret, meetingPointUri, meetingPoint, activity, creatorUri, participantUri, profileUri, contactsUri }) {
|
||||
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;
|
||||
this.logger.warn(`${change} ${act.activityUri} of ${participantUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
|
||||
return undefined;
|
||||
}
|
||||
await this.serialize(meetingPointUri, async signal => {
|
||||
return this.serialize(meetingPointUri, async signal => {
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
const last = await lastAttendance(ctx, { dataset, meetingPointUri, memberUri });
|
||||
const last = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
|
||||
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;
|
||||
this.logger.info(`${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
|
||||
return undefined;
|
||||
}
|
||||
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri);
|
||||
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri);
|
||||
if (!collectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
|
||||
return undefined;
|
||||
}
|
||||
if (signal.aborted) return undefined;
|
||||
const participations = await this.participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri });
|
||||
if (!participations.ok || signal.aborted) return undefined;
|
||||
// The member URN the act applies is STICKY per (meeting point, participant): the one the ledger
|
||||
// already holds for the pair, else one computed from the secret on the profile now and persisted
|
||||
// write-once BEFORE any attendee-list write — the act then applies whatever member the ledger holds
|
||||
// after that write (another act's, if one landed first) — so that every later act (Join or Leave,
|
||||
// after any half-way failure, timeout or secret rewrite) uses that same URN and the pair is never
|
||||
// listed under two.
|
||||
if (signal.aborted) return undefined;
|
||||
let wrote;
|
||||
try {
|
||||
const memberUri =
|
||||
last && last.member
|
||||
? last.member
|
||||
: await persistMember(ctx, { dataset, meetingPointUri, participantUri, member: memberUrn({ participantUri, eventUri, secret }) });
|
||||
if (signal.aborted) return undefined;
|
||||
wrote = await this.applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal });
|
||||
await this.applyToParticipations(ctx, { change, collectionUri: participations.collectionUri, meetingPointUri, participantUri, signal });
|
||||
} catch (e) {
|
||||
this.logger.error(`The ${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is NOT recorded (${e.message}): the next act retries`);
|
||||
return undefined;
|
||||
}
|
||||
if (signal.aborted) return undefined;
|
||||
await recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act });
|
||||
const wasAttending = Boolean(last && last.change === 'join');
|
||||
return { changed: wrote || wasAttending !== (change === 'join') };
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Applies `change` for `memberUri` to the meeting point's attendee list `collectionUri`, in the
|
||||
* creator's pod and as the creator: reads the list — a failed read throws, it never means "not
|
||||
* listed" — then adds the member on a Join when absent, removes it on a Leave when present (the
|
||||
* attendees handler's `add` / `remove`, the reference-app calls) and reads the list back: throws
|
||||
* unless the write shows. Resolves whether it wrote (false when the list already matched the act).
|
||||
* Throws instead of writing once its time is out (`signal`).
|
||||
*/
|
||||
async applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal }) {
|
||||
const joining = change === 'join';
|
||||
const attending = (await readItems(ctx, { collectionUri, actorUri: creatorUri })).includes(memberUri);
|
||||
if (attending === joining) {
|
||||
this.logger.info(`${memberUri} already ${joining ? 'attends' : 'does not attend'} ${meetingPointUri}`);
|
||||
return false;
|
||||
}
|
||||
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
|
||||
await ctx.call(joining ? 'attendees.add' : 'attendees.remove', { collectionUri, itemUri: memberUri, actorUri: creatorUri });
|
||||
await expectItem(ctx, { collectionUri, itemUri: memberUri, actorUri: creatorUri, present: joining });
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Applies `change` to the participant's participations collection `collectionUri`, in their pod and
|
||||
* as them: adds the meeting point on a Join, removes it on a Leave (the participations handler's
|
||||
* `add` / `remove`, the reference-app calls — adding an item already there or removing one that is
|
||||
* not changes nothing, so no read first), then reads the collection back: throws unless it shows
|
||||
* the act. Nothing to do on a Leave when the participant has no collection (`collectionUri`
|
||||
* undefined). Throws instead of writing once its time is out (`signal`).
|
||||
*/
|
||||
async applyToParticipations(ctx, { change, collectionUri, meetingPointUri, participantUri, signal }) {
|
||||
if (!collectionUri) {
|
||||
this.logger.info(`${participantUri} has no participations collection: nothing to remove ${meetingPointUri} from`);
|
||||
return;
|
||||
}
|
||||
const joining = change === 'join';
|
||||
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
|
||||
await ctx.call(joining ? 'participations.add' : 'participations.remove', { collectionUri, itemUri: meetingPointUri, actorUri: participantUri });
|
||||
await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining });
|
||||
},
|
||||
/**
|
||||
* Tells the creator of the meeting point that a participant joined or left it — ALWAYS anonymously.
|
||||
* The participant is never named: the only membership signal the app can read (the participant's
|
||||
* `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported
|
||||
* upstream, see `notificationTemplate`). The notification carries no `activity`: given one,
|
||||
* `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting
|
||||
* point is not notified. A failure is logged, never thrown: the act stands.
|
||||
*/
|
||||
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
|
||||
try {
|
||||
if (creatorUri === participantUri) {
|
||||
this.logger.info(`${participantUri} acted on their own meeting point ${meetingPointUri}: no notification`);
|
||||
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 });
|
||||
});
|
||||
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
|
||||
await ctx.call('pod-notifications.send', {
|
||||
template: notificationTemplate(change),
|
||||
recipientUri: creatorUri,
|
||||
context: meetingPointUri,
|
||||
meetingPointName: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
|
||||
eventUri
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not notify ${creatorUri} of a ${change} on ${meetingPointUri}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
@@ -131,48 +364,26 @@ module.exports = {
|
||||
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.
|
||||
// The participant's outbox: `actorUri` is the participant; the meeting point may lie in any pod
|
||||
// (its creator's — checked by `meetingPointTarget`).
|
||||
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 });
|
||||
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: contactsUri });
|
||||
await ctx.call('pod-wac-groups.addMember', { groupUri: contactsGroupUri, memberUri: organizerUri, actorUri });
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, 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: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
||||
}),
|
||||
onReceive: verifiedReceive(
|
||||
onEmit: verifiedEmit(
|
||||
'Leave',
|
||||
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
|
||||
await this.updateAttendance(ctx, { change: 'leave', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user