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:
ddidderr committed 2026-09-02 22:37:40 +02:00
1 parent f9c64d7c18
commit 43b69a0f87
2 files changed
+36 -5

No files matched your search

@@ -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');