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.
301 lines
17 KiB
JavaScript
301 lines
17 KiB
JavaScript
const { test, expect, describe } = require('bun:test');
|
|
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
|
|
|
const ALICE = 'http://localhost:3000/alice';
|
|
const BOB = 'http://localhost:3000/bob';
|
|
|
|
// The activity and object URIs a genuine Festipod creation produces: both under the actor's pod.
|
|
const ACT = `${ALICE}/outbox/act-1`;
|
|
const OBJ = `${ALICE}/data/events/e1`;
|
|
|
|
/** A `read(uri)` built from a map of uri → { status, body }; a missing uri answers 404. */
|
|
const reader = pod => async uri => pod[uri] || { status: 404, body: undefined };
|
|
|
|
const createActivity = { id: ACT, type: 'Create', actor: ALICE, object: OBJ };
|
|
const event = (extra = {}) => ({ id: OBJ, type: 'Event', name: 'Concert', ...extra });
|
|
|
|
describe('verifyEmittedActivity — the webhook is a hint; the pod is the truth', () => {
|
|
test('the normal case: a Create emitted by the actor, re-read, is accepted with the STORED object', async () => {
|
|
const stored = event({ to: 'as:Public', name: 'the real, stored name' });
|
|
const read = reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: stored } });
|
|
// The webhook copy carries a DIFFERENT, attacker-chosen object body — it must be ignored.
|
|
const webhookActivity = { id: ACT, type: 'Create', actor: ALICE, object: { id: OBJ, type: 'Event', to: 'as:Public', name: 'forged' } };
|
|
const r = await verifyEmittedActivity({ activity: webhookActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.objectUri).toBe(OBJ);
|
|
expect(r.object.name).toBe('the real, stored name');
|
|
});
|
|
|
|
test('the activity is re-read even when the webhook embeds a full activity node', async () => {
|
|
const calls = [];
|
|
const read = async uri => {
|
|
calls.push(uri);
|
|
return uri === ACT ? { status: 200, body: createActivity } : { status: 200, body: event() };
|
|
};
|
|
const embedded = { id: ACT, type: 'Create', actor: ALICE, object: event() };
|
|
const r = await verifyEmittedActivity({ activity: embedded, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(true);
|
|
expect(calls).toEqual([ACT, OBJ]); // both re-read from the pod, nothing taken from the body
|
|
});
|
|
|
|
test('FORGERY: a victim\'s private event named through the attacker\'s outbox is refused', async () => {
|
|
// The attacker (Alice) owns her webhook URL; she forges a Create naming her own outbox as
|
|
// target and a VICTIM\'s private event as the object, addressed to Public. The watcher resolves
|
|
// actorUri = Alice (her outbox). The embedded object claims BOB\'s event is public.
|
|
const victimEvent = `${BOB}/data/events/private`;
|
|
const forged = { id: `${ALICE}/outbox/forged`, type: 'Create', actor: ALICE, object: { id: victimEvent, type: 'Event', to: 'as:Public' } };
|
|
// Even if the pod happens to return the forged activity (it is in Alice\'s pod), its object is
|
|
// in BOB\'s pod → refused at the object-ownership check, before any read of BOB\'s event.
|
|
const read = reader({ [`${ALICE}/outbox/forged`]: { status: 200, body: forged } });
|
|
const r = await verifyEmittedActivity({ activity: forged, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('not in the pod of');
|
|
});
|
|
|
|
test('a Create the pod does not actually store (only in the webhook body) is refused', async () => {
|
|
const read = reader({}); // the pod returns 404 for the activity
|
|
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('does not return the activity');
|
|
});
|
|
|
|
test('a stored activity attributed to someone else is refused', async () => {
|
|
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Create', actor: BOB, object: OBJ } } });
|
|
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('not attributed to');
|
|
});
|
|
|
|
test('a stored activity of the wrong type is refused', async () => {
|
|
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Announce', actor: ALICE, object: OBJ } } });
|
|
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('is not a Create');
|
|
});
|
|
|
|
test('an activity with no id (never stored) is refused before any read', async () => {
|
|
let reads = 0;
|
|
const read = async () => { reads++; return { status: 404 }; };
|
|
const r = await verifyEmittedActivity({ activity: { type: 'Create', actor: ALICE, object: event() }, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('no id');
|
|
expect(reads).toBe(0);
|
|
});
|
|
|
|
test('a Delete is accepted only when the pod says the object is gone or a Tombstone', async () => {
|
|
const del = { id: `${ALICE}/outbox/del`, type: 'Delete', actor: ALICE, object: OBJ };
|
|
const act = { [`${ALICE}/outbox/del`]: { status: 200, body: del } };
|
|
// Object gone → accepted.
|
|
let r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 404 } }) });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.objectUri).toBe(OBJ);
|
|
// Tombstone → accepted.
|
|
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: { id: OBJ, type: 'Tombstone' } } }) });
|
|
expect(r.ok).toBe(true);
|
|
// Object still live → refused (the deletion is not true).
|
|
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: event() } }) });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('not deleted');
|
|
});
|
|
});
|
|
|
|
describe('verifiedEmit — runs the handler only on an accepted activity, never throws', () => {
|
|
function svc(pod) {
|
|
const logs = { info: [], warn: [], error: [] };
|
|
const ran = [];
|
|
const service = {
|
|
name: 'test',
|
|
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
|
};
|
|
const ctx = { call: async (action, { resourceUri }) => (pod[resourceUri] || { status: 404 }) };
|
|
const onEmit = verifiedEmit('Create', async function (_ctx, verdict, actorUri) {
|
|
ran.push({ objectUri: verdict.objectUri, actorUri });
|
|
}, { objectType: 'Event' }).bind(service);
|
|
return { onEmit, ctx, logs, ran };
|
|
}
|
|
|
|
test('an accepted Create runs the handler with the stored object and the watcher\'s actor', async () => {
|
|
const { onEmit, ctx, ran } = svc({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: event() } });
|
|
await onEmit(ctx, createActivity, ALICE);
|
|
expect(ran).toEqual([{ objectUri: OBJ, actorUri: ALICE }]);
|
|
});
|
|
|
|
test('a refused Create logs a warning and does NOT run the handler', async () => {
|
|
const { onEmit, ctx, logs, ran } = svc({}); // pod returns 404 for the activity
|
|
await onEmit(ctx, createActivity, ALICE);
|
|
expect(ran).toEqual([]);
|
|
expect(logs.warn).toHaveLength(1);
|
|
expect(logs.warn[0]).toContain('ignored a Create');
|
|
});
|
|
|
|
test('a throwing read is caught and logged, never propagated', async () => {
|
|
const service = { name: 'test', logger: { info() {}, warn() {}, error(m) { this._e = m; } } };
|
|
const onEmit = verifiedEmit('Create', async () => {}, { objectType: 'Event' }).bind(service);
|
|
const ctx = { call: async () => { throw new Error('proxy down'); } };
|
|
await expect(onEmit(ctx, createActivity, ALICE)).resolves.toBeUndefined();
|
|
expect(service.logger._e).toContain('proxy down');
|
|
});
|
|
});
|
|
|
|
describe('neverThrowingMatcher', () => {
|
|
test('a throwing matcher reports no match and logs', async () => {
|
|
const service = { name: 'test', logger: { error(m) { this._e = m; } } };
|
|
const wrapped = neverThrowingMatcher(async () => { throw new Error('boom'); }).bind(service);
|
|
const r = await wrapped({ id: ACT }, async () => false);
|
|
expect(r.match).toBe(false);
|
|
expect(service.logger._e).toContain('boom');
|
|
});
|
|
});
|
|
|
|
describe('the stored object must be of the type the handler acts on', () => {
|
|
test('a public Note re-read at the object URI is refused for an Event handler; an Event is accepted', async () => {
|
|
const note = { id: OBJ, type: 'Note', to: 'as:Public', content: 'not an event' };
|
|
let r = await verifyEmittedActivity({
|
|
activity: createActivity,
|
|
actorUri: ALICE,
|
|
type: 'Create',
|
|
objectType: 'Event',
|
|
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: note } })
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('is not a Event');
|
|
for (const type of ['Event', 'as:Event', 'https://www.w3.org/ns/activitystreams#Event', ['Event', 'apods:MeetingPoint']]) {
|
|
r = await verifyEmittedActivity({
|
|
activity: createActivity,
|
|
actorUri: ALICE,
|
|
type: 'Create',
|
|
objectType: 'Event',
|
|
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: { id: OBJ, type } } })
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('a Profile handler refuses an Event and accepts a Profile', async () => {
|
|
const pod = body => reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body } });
|
|
const asProfile = { activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Profile' };
|
|
expect((await verifyEmittedActivity({ ...asProfile, read: pod(event()) })).ok).toBe(false);
|
|
expect((await verifyEmittedActivity({ ...asProfile, read: pod({ id: OBJ, type: ['as:Profile', 'vcard:Individual'] }) })).ok).toBe(true);
|
|
});
|
|
|
|
test('a type is required for anything but a Delete (a forgotten check is a programming error)', async () => {
|
|
await expect(verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', read: reader({}) })).rejects.toThrow('objectType is required');
|
|
});
|
|
|
|
test('the pod answering with another resource than the activity asked for is refused', async () => {
|
|
const read = reader({ [ACT]: { status: 200, body: { ...createActivity, id: `${ALICE}/outbox/other` } }, [OBJ]: { status: 200, body: event() } });
|
|
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('another resource');
|
|
});
|
|
});
|
|
|
|
describe('verifyActivity — an activity RECEIVED in an inbox (emitter = the actor whose pod holds it)', () => {
|
|
// Bob joins Alice's meeting point: the Join lies in Bob's pod, its object in Alice's pod; the
|
|
// notification comes from ALICE's inbox (Alice is the box owner, `actorUri`).
|
|
const MP = `${ALICE}/data/events/mp1`;
|
|
const JOIN = `${BOB}/data/activities/join-1`;
|
|
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE };
|
|
const meetingPoint = { id: MP, type: 'Event', 'apods:attendees': `${ALICE}/data/collections/mp1-attendees` };
|
|
const received = read => verifyActivity({ activity: JOIN, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
|
|
|
|
test('the normal case: a Join stored in the joiner\'s pod, about the inbox owner\'s meeting point', async () => {
|
|
const r = await received(reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } }));
|
|
expect(r.ok).toBe(true);
|
|
expect(r.emitterUri).toBe(BOB);
|
|
expect(r.object).toEqual(meetingPoint);
|
|
});
|
|
|
|
test('an activity naming an actor whose pod does not hold it is refused (Mallory cannot join as Bob)', async () => {
|
|
const MALLORY = 'http://localhost:3000/mallory';
|
|
const forgedUri = `${MALLORY}/data/activities/join-as-bob`;
|
|
const read = reader({ [forgedUri]: { status: 200, body: { ...join, id: forgedUri, actor: BOB } }, [MP]: { status: 200, body: meetingPoint } });
|
|
const r = await verifyActivity({ activity: forgedUri, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('not in the pod of the actor it names');
|
|
});
|
|
|
|
test('a Join about an object outside the inbox owner\'s pod is refused (an embedded attendees URI never reaches a write)', async () => {
|
|
const elsewhere = `${BOB}/data/events/bobs-mp`;
|
|
const read = reader({ [JOIN]: { status: 200, body: { ...join, object: { id: elsewhere, type: 'Event', 'apods:attendees': 'http://localhost:3000/carol/data/c' } } } });
|
|
const r = await received(read);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain(`not in the pod of ${ALICE}`);
|
|
});
|
|
|
|
test('an activity with several actors, or a Leave where a Join is expected, is refused', async () => {
|
|
let r = await received(reader({ [JOIN]: { status: 200, body: { ...join, actor: [BOB, ALICE] } }, [MP]: { status: 200, body: meetingPoint } }));
|
|
expect(r.ok).toBe(false);
|
|
r = await received(reader({ [JOIN]: { status: 200, body: { ...join, type: 'Leave' } }, [MP]: { status: 200, body: meetingPoint } }));
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
test('a location rule must be stated: ANY_POD explicitly, never by omission', async () => {
|
|
await expect(verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectType: 'Event', read: reader({}) })).rejects.toThrow('objectOwnerUri is required');
|
|
const read = reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } });
|
|
const r = await verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectOwnerUri: ANY_POD, objectType: 'Event', read });
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
|
|
const MP = `${ALICE}/data/events/mp1`;
|
|
const JOIN = `${BOB}/data/activities/join-1`;
|
|
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
|
const pod = { [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: { id: MP, type: 'Event' } } };
|
|
const service = () => ({ name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error(m) { this.e = m; } } });
|
|
const ctx = { call: async (action, { resourceUri }) => pod[resourceUri] || { status: 404 } };
|
|
|
|
test('an inbox Join runs the handler with the emitter read from the pod, not from the webhook body', async () => {
|
|
const ran = [];
|
|
const onReceive = verifiedReceive('Join', async (_ctx, verdict, actorUri) => ran.push([verdict.emitterUri, verdict.objectUri, actorUri]), { objectType: 'Event' }).bind(service());
|
|
// The webhook body claims Carol joined; the stored activity says Bob.
|
|
await onReceive(ctx, { id: JOIN, type: 'Join', actor: 'http://localhost:3000/carol', object: MP }, ALICE);
|
|
expect(ran).toEqual([[BOB, MP, ALICE]]);
|
|
});
|
|
|
|
test('an outbox Join about an event in another pod passes with objectIn "any", and is refused by default', async () => {
|
|
const ran = [];
|
|
const handle = async (_ctx, verdict) => ran.push(verdict.objectUri);
|
|
await verifiedEmit('Join', handle, { objectType: 'Event', objectIn: 'any' }).call(service(), ctx, JOIN, BOB);
|
|
const svc = service();
|
|
await verifiedEmit('Join', handle, { objectType: 'Event' }).call(svc, ctx, JOIN, BOB);
|
|
expect(ran).toEqual([MP]);
|
|
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
|
|
});
|
|
});
|
|
|
|
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
|
|
const MP = `${ALICE}/data/events/mp1`;
|
|
const JOIN = `${BOB}/outbox/join-1`;
|
|
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
|
|
|
test('the activity AND the meeting point are read through the outbox owner\'s access, the stored object handed over', async () => {
|
|
const reads = [];
|
|
const stored = { [JOIN]: join, [MP]: { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` } };
|
|
const ctx = {
|
|
call: async (action, params) => {
|
|
reads.push([action, params.resourceUri, params.actorUri]);
|
|
const body = stored[params.resourceUri];
|
|
return body ? { status: 200, body } : { status: 404 };
|
|
}
|
|
};
|
|
const ran = [];
|
|
const service = { name: 'test', logger: { info() {}, warn() {}, error() {} } };
|
|
// The webhook body claims another object: only the stored one counts.
|
|
await verifiedEmit('Join', async (_ctx, verdict) => ran.push(verdict.object), { objectType: 'Event', objectIn: 'any' })
|
|
.call(service, ctx, { ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } }, BOB);
|
|
expect(reads).toEqual([['pod-resources.get', JOIN, BOB], ['pod-resources.get', MP, BOB]]);
|
|
expect(ran).toEqual([stored[MP]]);
|
|
});
|
|
|
|
test('a Join whose stored actor is not the outbox owner is refused', async () => {
|
|
const ctx = { call: async (_action, { resourceUri }) => (resourceUri === JOIN ? { status: 200, body: { ...join, actor: ALICE } } : { status: 200, body: { id: MP, type: 'Event' } }) };
|
|
const ran = [];
|
|
const service = { name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error() {} } };
|
|
await verifiedEmit('Join', async () => ran.push(true), { objectType: 'Event', objectIn: 'any' }).call(service, ctx, join, BOB);
|
|
expect(ran).toEqual([]);
|
|
expect(service.logger.w).toContain('not attributed to');
|
|
});
|
|
});
|