908f7ea973
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.
54 lines
2.9 KiB
JavaScript
54 lines
2.9 KiB
JavaScript
// 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 };
|