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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user