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

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

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

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

56 lines
3.1 KiB
JavaScript

// 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 };