fix(frontend): scope Call-to-Play event keys by author

Security audit finding Codex #14 ("cross-author event-ID collisions can
suppress Call-to-Play entries"). The Rust side guarantees that an event
nonce is unique within one authenticated author's history and preserves
`author_id` on every projected event, but the frontend reducer
deduplicated the merged view with `new Map(events.map(e => [e.id, e]))`
and tracked chat messages by `event.id` alone. A peer could therefore
publish, say, a Respond event reusing the nonce of another user's
Create event and make that call vanish from every viewer, or shadow
other users' chat messages.

`eventKeyOf` now builds `author_id + NUL + id` and is used for view
deduplication, event ordering ties, message deduplication and the
message id that CtpChat uses as its React key. Nomination ids
(`call_id`) were already creator scoped and are unchanged.

Test plan: `just frontend-test`. The new test feeds a Create from
Alice and a Respond from Bob sharing one nonce and expects both to
apply, then two same-nonce messages from different authors and expects
two distinct messages.

Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
This commit is contained in:
2026-09-02 22:37:40 +02:00
parent f9c64d7c18
commit 43b69a0f87
2 changed files with 36 additions and 5 deletions
@@ -50,8 +50,17 @@ interface MutableNomination extends Nomination {
messageIds: Set<string>;
}
/**
* Event nonces are only unique within one author's history: the backend
* validates them per authenticated author, so two authors may legitimately
* (or deliberately) publish events with the same nonce. Every place that
* deduplicates or keys events therefore scopes the nonce by author.
*/
export const eventKeyOf = (event: Pick<CallToPlayViewEvent, 'author_id' | 'id'>): string =>
`${event.author_id}\u0000${event.id}`;
const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number =>
a.at - b.at || a.id.localeCompare(b.id);
a.at - b.at || eventKeyOf(a).localeCompare(eventKeyOf(b));
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
@@ -135,7 +144,7 @@ export const reduceCallToPlayEvents = (
const groupEvents = (
input: ReadonlyArray<CallToPlayViewEvent>,
): Map<string, CallToPlayViewEvent[]> => {
const unique = new Map(input.map(event => [event.id, event]));
const unique = new Map(input.map(event => [eventKeyOf(event), event]));
const byCall = new Map<string, CallToPlayViewEvent[]>();
for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? [];
@@ -218,10 +227,11 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent):
}
const message = messagePayload(action);
if (message && !nomination.messageIds.has(event.id)) {
nomination.messageIds.add(event.id);
const messageKey = eventKeyOf(event);
if (message && !nomination.messageIds.has(messageKey)) {
nomination.messageIds.add(messageKey);
nomination.messages.push({
id: event.id,
id: messageKey,
fromId: event.author_id,
from: event.author_name,
text: message.text,
@@ -54,6 +54,27 @@ const create = (
},
});
Deno.test('events from different authors never collapse on a shared nonce', () => {
const respond = event('create', 'Bob', { Respond: { ready_at: null } }, NOW + 1);
const [nomination] = reduceCallToPlayEvents([create(), respond], NOW + 2);
assert(nomination, 'the creator event must survive a colliding nonce from another author');
assertEquals(nomination.creator, 'Alice', 'creator');
assertEquals(Object.keys(nomination.participants).length, 2, 'both participants applied');
const aliceMessage = event('m1', 'Alice', { SendMessage: { text: 'hi' } }, NOW + 3);
const bobMessage = event('m1', 'Bob', { SendMessage: { text: 'yo' } }, NOW + 4);
const [withMessages] = reduceCallToPlayEvents(
[create(), respond, aliceMessage, bobMessage],
NOW + 5,
);
assert(withMessages, 'call should exist');
assertEquals(withMessages.messages.length, 2, 'messages with a shared nonce are distinct');
assert(
withMessages.messages[0].id !== withMessages.messages[1].id,
'message keys are author scoped',
);
});
Deno.test('play-now call starts with its creator ready', () => {
const [nomination] = reduceCallToPlayEvents([create()], NOW);
assert(nomination, 'call should exist');