// 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 // " " (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:`. */ 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 };