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