fix(call-to-play): key actors by stable peer identity

Participant maps and creator authorization previously used display names, so
two peers left at the default Commander name collapsed into one participant
and could exercise each other's creator controls through the normal client.

Carry a stable actor_id separately from actor_name. The peer overwrites actor_id
on every local publish, and live event envelopes are accepted only when the
known peer, source, and event actor match. The frontend keys participants and
authorization by actor_id while retaining actor_name for display. This follows
the trusted-LAN model and is not cryptographic authentication against a hostile
peer.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-07-21 22:40:59 +02:00
1 parent 0f53bc4b78
commit 29eacabcc0
19 files changed
+197 -69

No files matched your search

@@ -10,7 +10,7 @@ import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
username: string;
actorId: string | null;
actions: CallToPlayActions;
focusId: string | null;
transportReady: boolean;
@@ -24,7 +24,7 @@ interface Props {
export const CallToPlayOverlay = ({
nominations,
games,
username,
actorId,
actions,
focusId,
transportReady,
@@ -88,7 +88,7 @@ export const CallToPlayOverlay = ({
key={nomination.id}
nomination={nomination}
game={game}
username={username}
actorId={actorId}
actions={actions}
focused={nomination.id === focusId}
thumbnailUrl={getThumbnail(game.id)}
@@ -33,14 +33,15 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
const entries = Object.entries(nomination.participants);
return (
<span className="ctp-ticker-bubbles">
{entries.slice(0, 6).map(([name, participant]) => {
{entries.slice(0, 6).map(([participantId, participant]) => {
const name = participant.name;
const ready = isReady(participant, now);
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const remaining = (participant.readyAt ?? now) - now;
return (
<span
key={name}
key={participantId}
className="ctp-mini"
data-state={state}
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
@@ -6,12 +6,12 @@ import { Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
username: string;
actorId: string | null;
disabled: boolean;
onSend: (text: string) => void;
}
export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
export const CtpChat = ({ nomination, actorId, disabled, onSend }: Props) => {
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState(nomination.messages.length);
const [draft, setDraft] = useState('');
@@ -58,7 +58,7 @@ export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
{nomination.messages.map(message => (
<div
key={message.id}
className={`ctp-chat-msg ${message.from === username ? 'is-me' : ''}`}
className={`ctp-chat-msg ${message.fromId === actorId ? 'is-me' : ''}`}
>
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
<span className="ctp-chat-time">{formatClock(message.at)}</span>
@@ -21,7 +21,7 @@ import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
game: Game;
username: string;
actorId: string | null;
actions: CallToPlayActions;
focused: boolean;
thumbnailUrl?: string | null;
@@ -60,7 +60,7 @@ const AvatarChip = ({
export const NominationCard = ({
nomination,
game,
username,
actorId,
actions,
focused,
thumbnailUrl,
@@ -77,9 +77,9 @@ export const NominationCard = ({
const entries = Object.entries(nomination.participants);
const readyCount = readyCountOf(nomination, now);
const inCount = inCountOf(nomination, now);
const myStatus = nomination.participants[username];
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
const isMe = myStatus !== undefined;
const isCreator = nomination.creator === username;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const phase = phaseOf(nomination, now);
@@ -168,8 +168,13 @@ export const NominationCard = ({
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, participant]) => (
<AvatarChip key={name} name={name} participant={participant} now={now} />
{entries.map(([participantId, participant]) => (
<AvatarChip
key={participantId}
name={participant.name}
participant={participant}
now={now}
/>
))}
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
.map((_, index) => (
@@ -182,7 +187,7 @@ export const NominationCard = ({
<CardActions
nomination={nomination}
game={game}
username={username}
actorId={actorId}
actions={actions}
onLaunch={onLaunch}
now={now}
@@ -191,7 +196,7 @@ export const NominationCard = ({
<CtpChat
nomination={nomination}
username={username}
actorId={actorId}
disabled={isStarted}
onSend={text => actions.sendMessage(nomination.id, text)}
/>
@@ -225,7 +230,7 @@ export const NominationCard = ({
interface CardActionsProps {
nomination: Nomination;
game: Game;
username: string;
actorId: string | null;
actions: CallToPlayActions;
onLaunch: (game: Game) => void;
now: number;
@@ -255,14 +260,14 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
const CardActions = ({
nomination,
game,
username,
actorId,
actions,
onLaunch,
now,
}: CardActionsProps) => {
const myStatus = nomination.participants[username];
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
const isMe = myStatus !== undefined;
const isCreator = nomination.creator === username;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;