Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404. Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const registration = require('./registration.service');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { lastAttendanceQuery, recordAttendanceUpdate, isNewerAct, attendanceSubject } = require('./attendance-ledger');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice'; // the organizer
|
||||
const BOB = 'http://localhost:3000/bob'; // the joiner
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const C = `${ALICE}/data/collections/mp1-attendees`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const LEAVE = `${BOB}/data/activities/leave-1`;
|
||||
const BOB_CONTACTS = `${BOB}/data/collections/contacts`;
|
||||
|
||||
const meetingPoint = (extra = {}) => ({ id: MP, type: 'Event', name: 'Entrée nord', 'dc:creator': ALICE, 'apods:attendees': C, to: 'as:Public', ...extra });
|
||||
// The pod stamps `published` on every activity posted to an outbox.
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:00.000Z' };
|
||||
const leave = { id: LEAVE, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:05.000Z' };
|
||||
|
||||
/**
|
||||
* A pod (uri → stored body, 404 otherwise) plus the collection's state and the backend's attendance
|
||||
* ledger (answering exactly the queries `attendance-ledger.js` builds); records every call.
|
||||
* `addDelay` makes `attendees.add` slow; `readDelays` (uri → ms) makes some pod reads slow, to expose
|
||||
* interleavings. `ledger` seeds the last act applied per member.
|
||||
*/
|
||||
function world({ stored = {}, items = [], addDelay = 0, readDelays = {}, ledger = {} } = {}) {
|
||||
const calls = [];
|
||||
const state = { items: [...items], ledger: { ...ledger } };
|
||||
const members = [BOB, MALLORY, ALICE];
|
||||
// Every act the store could be asked to record: the stored Join/Leave activities.
|
||||
const acts = Object.values(stored)
|
||||
.filter(body => body.type === 'Join' || body.type === 'Leave')
|
||||
.map(body => ({ activityUri: body.id, published: body.published, change: body.type.toLowerCase() }));
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
switch (action) {
|
||||
case 'pod-resources.get': {
|
||||
if (readDelays[params.resourceUri]) await new Promise(resolve => setTimeout(resolve, readDelays[params.resourceUri]));
|
||||
const body = stored[params.resourceUri];
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
}
|
||||
case 'triplestore.query': {
|
||||
const member = members.find(m => params.query === lastAttendanceQuery(MP, m));
|
||||
if (!member) throw new Error(`unexpected query ${params.query}`);
|
||||
const last = state.ledger[member];
|
||||
return last ? [{ act: { value: last.activityUri }, published: { value: last.published }, change: { value: last.change } }] : [];
|
||||
}
|
||||
case 'triplestore.update': {
|
||||
for (const m of members) {
|
||||
const act = acts.find(a => params.query === recordAttendanceUpdate(MP, m, a));
|
||||
if (act) {
|
||||
state.ledger[m] = act;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected update ${params.query}`);
|
||||
}
|
||||
case 'pod-collections.getItems':
|
||||
return [...state.items];
|
||||
case 'attendees.add':
|
||||
if (addDelay) await new Promise(resolve => setTimeout(resolve, addDelay));
|
||||
state.items.push(params.itemUri);
|
||||
return undefined;
|
||||
case 'attendees.remove':
|
||||
state.items = state.items.filter(item => item !== params.itemUri);
|
||||
return undefined;
|
||||
case 'pod-wac-groups.getUriFromCollectionUri':
|
||||
return `${params.collectionUri}-group`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
return { ctx, calls, state };
|
||||
}
|
||||
|
||||
function service({ timeoutMs } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const svc = { name: 'registration', logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
|
||||
if (registration.created) registration.created.call(svc);
|
||||
if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs });
|
||||
for (const [name, fn] of Object.entries(registration.methods)) svc[name] = fn.bind(svc);
|
||||
return { svc, logs };
|
||||
}
|
||||
|
||||
const receiveJoin = (svc, ctx, hint) => registration.activities.join.onReceive.call(svc, ctx, hint, ALICE);
|
||||
const receiveLeave = (svc, ctx, hint) => registration.activities.leave.onReceive.call(svc, ctx, hint, ALICE);
|
||||
const emitJoin = (svc, ctx, hint) => registration.activities.join.onEmit.call(svc, ctx, hint, BOB);
|
||||
const adds = calls => calls.filter(c => c.action === 'attendees.add').map(c => c.params);
|
||||
|
||||
describe("registration — a Join received in the organizer's inbox adds the joiner the pod names", () => {
|
||||
test('the normal case: the stored joiner is added to the STORED meeting point\'s collection', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]);
|
||||
});
|
||||
|
||||
test('the webhook body is ignored: neither its actor nor its embedded attendees collection is used', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } });
|
||||
const body = { id: JOIN, type: 'Join', actor: MALLORY, object: { id: MP, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } };
|
||||
await receiveJoin(service().svc, ctx, body);
|
||||
expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]);
|
||||
});
|
||||
|
||||
test('a Join the pod does not store (webhook only) adds no one', async () => {
|
||||
const { ctx, calls } = world({ stored: { [MP]: meetingPoint() } });
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, { ...join, object: meetingPoint() });
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('ignored a Join');
|
||||
});
|
||||
|
||||
test('a Join about a meeting point outside the organizer\'s pod adds no one', async () => {
|
||||
const bobsMp = `${BOB}/data/events/mp`;
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: { ...join, object: bobsMp }, [bobsMp]: { ...meetingPoint(), id: bobsMp } } });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a stored meeting point naming a collection outside the organizer\'s pod: nothing written', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'apods:attendees': `${MALLORY}/data/c` }) } });
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a stored object that is not an event (a Note) adds no one', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: { id: MP, type: 'Note', 'apods:attendees': C } } });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an already attending joiner is not added twice', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration — a Leave received in the organizer's inbox removes only its stored emitter", () => {
|
||||
test('the stored leaver is removed; a body claiming another actor changes nothing to who is removed', async () => {
|
||||
const { ctx, state } = world({ stored: { [LEAVE]: leave, [MP]: meetingPoint() }, items: [BOB, MALLORY] });
|
||||
await receiveLeave(service().svc, ctx, { ...leave, actor: MALLORY });
|
||||
expect(state.items).toEqual([MALLORY]);
|
||||
});
|
||||
|
||||
test('a Leave the pod does not store removes no one', async () => {
|
||||
const { ctx, state } = world({ stored: { [MP]: meetingPoint() }, items: [BOB] });
|
||||
await receiveLeave(service().svc, ctx, leave);
|
||||
expect(state.items).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('a Join and a Leave of one meeting point never interleave (the join cannot land after the leave)', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 30 });
|
||||
const { svc } = service();
|
||||
await Promise.all([receiveJoin(svc, ctx, join), receiveLeave(svc, ctx, leave)]);
|
||||
expect(state.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — attendance follows the participant\'s LATEST act, whatever order notifications run in', () => {
|
||||
// The reviewer's probe: the Join's verification read is slower, so the Leave reaches the lock first.
|
||||
test('join then leave, the Leave handled first: ends NOT attending', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, readDelays: { [JOIN]: 40 } });
|
||||
const { svc } = service();
|
||||
const j = receiveJoin(svc, ctx, { id: JOIN }); // the Join webhook first
|
||||
await new Promise(resolve => setTimeout(resolve, 5)); // the Leave webhook 5 ms later
|
||||
const l = receiveLeave(svc, ctx, { id: LEAVE });
|
||||
await Promise.all([j, l]);
|
||||
expect(state.items).toEqual([]);
|
||||
});
|
||||
|
||||
test('leave then join, the Join handled first: ends attending', async () => {
|
||||
const earlyLeave = { ...leave, published: '2026-10-13T09:00:00.000Z' }; // she left, then joined again
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: earlyLeave, [MP]: meetingPoint() }, items: [BOB], readDelays: { [LEAVE]: 40 } });
|
||||
const { svc } = service();
|
||||
const l = receiveLeave(svc, ctx, { id: LEAVE });
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const j = receiveJoin(svc, ctx, { id: JOIN });
|
||||
await Promise.all([l, j]);
|
||||
expect(state.items).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('a replayed older Join after a later Leave changes nothing (the ledger says the Leave is newer)', async () => {
|
||||
const { ctx, state, calls } = world({
|
||||
stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() },
|
||||
ledger: { [BOB]: { activityUri: LEAVE, published: leave.published, change: 'leave' } }
|
||||
});
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, { id: JOIN });
|
||||
expect(state.items).toEqual([]);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(logs.info.some(m => m.includes('not newer than the last act applied'))).toBe(true);
|
||||
});
|
||||
|
||||
test('every applied act is recorded, even when the collection already matched it', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] });
|
||||
await receiveJoin(service().svc, ctx, { id: JOIN });
|
||||
expect(state.ledger[BOB]).toEqual({ activityUri: JOIN, published: join.published, change: 'join' });
|
||||
});
|
||||
|
||||
test('an act without a pod stamp cannot be ordered: nothing written', async () => {
|
||||
const unstamped = { ...join, published: undefined };
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: unstamped, [MP]: meetingPoint() } });
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, { id: JOIN });
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(calls.some(c => c.action === 'triplestore.update')).toBe(false);
|
||||
expect(logs.warn.some(m => m.includes('no pod stamp'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — a write in flight at the timeout (no re-alignment: decided limit)', () => {
|
||||
// Bob joined then left (Leave L0 recorded). He acts once more; that act's write is sent before the
|
||||
// 50 ms timeout and lands at 120 ms; nothing follows. The late write is the latest act: it must stand.
|
||||
const L0 = { id: `${BOB}/data/activities/leave-0`, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' };
|
||||
const J0 = { id: `${BOB}/data/activities/join-0`, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' };
|
||||
|
||||
test('a late, unrecorded REJOIN is not undone by the older recorded Leave: ends attending', async () => {
|
||||
const { ctx, state } = world({
|
||||
stored: { [JOIN]: join, [MP]: meetingPoint(), [L0.id]: L0 },
|
||||
addDelay: 120,
|
||||
ledger: { [BOB]: { activityUri: L0.id, published: L0.published, change: 'leave' } }
|
||||
});
|
||||
const { svc } = service({ timeoutMs: 50 });
|
||||
await receiveJoin(svc, ctx, join);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(state.items).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('the mirror case: a late, unrecorded LEAVE is not undone by the older recorded Join: ends NOT attending', async () => {
|
||||
const slowRemove = world({
|
||||
stored: { [LEAVE]: leave, [MP]: meetingPoint(), [J0.id]: J0 },
|
||||
items: [BOB],
|
||||
ledger: { [BOB]: { activityUri: J0.id, published: J0.published, change: 'join' } }
|
||||
});
|
||||
const { ctx, state } = slowRemove;
|
||||
const call = ctx.call;
|
||||
ctx.call = async (action, params) => {
|
||||
if (action === 'attendees.remove') await new Promise(resolve => setTimeout(resolve, 120)); // lands late
|
||||
return call(action, params);
|
||||
};
|
||||
const { svc } = service({ timeoutMs: 50 });
|
||||
await receiveLeave(svc, ctx, leave);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(state.items).toEqual([]);
|
||||
});
|
||||
|
||||
test('KNOWN LIMIT (documented in updateAttendance): an older Join whose add lands after a newer Leave stays applied until the next act', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 120 });
|
||||
const { svc } = service({ timeoutMs: 50 });
|
||||
const j = receiveJoin(svc, ctx, join);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const l = receiveLeave(svc, ctx, leave);
|
||||
await Promise.all([j, l]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(state.ledger[BOB].change).toBe('leave'); // the newer act is the recorded one
|
||||
expect(state.items).toEqual([BOB]); // the late add of the older Join stands — no re-alignment
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration — a Join emitted from the joiner's outbox adds the meeting point's real organizer to her contacts", () => {
|
||||
const bobActor = { id: BOB, type: 'Person', 'apods:contacts': BOB_CONTACTS };
|
||||
const added = calls => calls.filter(c => c.action === 'pod-wac-groups.addMember').map(c => c.params);
|
||||
|
||||
test('the normal case: the stored creator of a meeting point in her own pod', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, join);
|
||||
expect(added(calls)).toEqual([{ groupUri: `${BOB_CONTACTS}-group`, memberUri: ALICE, actorUri: BOB }]);
|
||||
});
|
||||
|
||||
test('a creator named in the webhook body is ignored (Mallory cannot get into Bob\'s contacts)', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, { ...join, object: { id: MP, type: 'Event', 'dc:creator': MALLORY } });
|
||||
expect(added(calls).map(p => p.memberUri)).toEqual([ALICE]);
|
||||
});
|
||||
|
||||
test('a stored creator who does not own the pod holding the meeting point is refused', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'dc:creator': MALLORY }), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, join);
|
||||
expect(added(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a Join stored in another actor\'s pod is not Bob\'s', async () => {
|
||||
const forged = `${MALLORY}/data/activities/j`;
|
||||
const { ctx, calls } = world({ stored: { [forged]: { ...join, id: forged }, [MP]: meetingPoint(), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, forged);
|
||||
expect(added(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a contacts collection outside the joiner's pod is not written", async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: { ...bobActor, 'apods:contacts': `${MALLORY}/data/c` } } });
|
||||
await emitJoin(service().svc, ctx, join);
|
||||
expect(added(calls)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — matchers never throw', () => {
|
||||
test('a fetch failure while matching reports no match', async () => {
|
||||
const { svc, logs } = service();
|
||||
const failing = async () => {
|
||||
throw new Error('proxy unreachable');
|
||||
};
|
||||
for (const key of ['join', 'leave']) {
|
||||
const result = await registration.activities[key].match.call(svc, { type: key === 'join' ? 'Join' : 'Leave', object: MP }, failing);
|
||||
expect(result.match).toBe(false);
|
||||
}
|
||||
expect(logs.error).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attendance-ledger — the order of acts', () => {
|
||||
const at = (published, activityUri = JOIN) => ({ activityUri, published });
|
||||
test('a later stamp is newer; an earlier one is not; a tie is broken by the activity URI', () => {
|
||||
expect(isNewerAct(at('2026-10-13T10:00:01Z'), at('2026-10-13T10:00:00Z'))).toBe(true);
|
||||
expect(isNewerAct(at('2026-10-13T09:59:59Z'), at('2026-10-13T10:00:00Z'))).toBe(false);
|
||||
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/b`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(true);
|
||||
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/a`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(false); // the same act twice
|
||||
});
|
||||
test('the first act is always newer; an unstamped act never is', () => {
|
||||
expect(isNewerAct(at('2026-10-13T10:00:00Z'), undefined)).toBe(true);
|
||||
expect(isNewerAct(at(undefined), undefined)).toBe(false);
|
||||
expect(isNewerAct(at('not a date'), undefined)).toBe(false);
|
||||
});
|
||||
test('one entry per (meeting point, participant), under a URN the store accepts', () => {
|
||||
expect(attendanceSubject(MP, BOB)).not.toBe(attendanceSubject(MP, MALLORY));
|
||||
expect(attendanceSubject(MP, BOB)).toMatch(/^urn:festipod:attendance:[^<>"{}|^`\\ ]+$/);
|
||||
expect(() => lastAttendanceQuery(MP, BOB)).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user