From 329b8a51921aabff59c5689bbfc636cf0dbf22a8 Mon Sep 17 00:00:00 2001 From: AnnieScigliano Date: Fri, 4 Sep 2026 00:05:27 -0300 Subject: [PATCH 1/6] feat(session): add attendee identity gate and stage exit --- e2e/fixtures/auth.ts | 3 +- e2e/tests/accessibility.spec.ts | 15 ++ e2e/tests/attendee-identity.spec.ts | 103 +++++++++++ e2e/tests/stage-invitation.spec.ts | 24 ++- .../migration.sql | 5 + prisma/schema.prisma | 39 ++-- src/app/api/auth/ticket/route.ts | 1 + .../[id]/entry/__tests__/route.test.ts | 81 ++++++++- .../scheduled-sessions/[id]/entry/route.ts | 115 ++++++++++-- .../[id]/hand/__tests__/route.test.ts | 36 ++++ .../api/scheduled-sessions/[id]/hand/route.ts | 10 +- src/app/api/test-login/route.ts | 5 + src/app/login/LoginClient.tsx | 4 + .../[id]/__tests__/media-continuity.test.tsx | 1 + src/app/session/[id]/__tests__/page.test.tsx | 131 ++++++++++++++ src/app/session/[id]/page.tsx | 168 ++++++++++++++++-- .../session/SessionIdentityGate.tsx | 131 ++++++++++++++ .../__tests__/SessionIdentityGate.test.tsx | 94 ++++++++++ .../__tests__/attendee-display-name.test.ts | 125 +++++++++++++ .../__tests__/public-session-access.test.ts | 1 + src/lib/__tests__/room-entitlement.test.ts | 32 +++- src/lib/__tests__/stage-control.test.ts | 77 ++++++++ src/lib/attendee-display-name.ts | 139 +++++++++++++++ src/lib/i18n.ts | 63 +++++++ src/lib/promo-invitation.ts | 1 + src/lib/public-session-access.ts | 1 + src/lib/room-entitlement.ts | 11 +- src/lib/stage-control.ts | 63 ++++++- 28 files changed, 1414 insertions(+), 65 deletions(-) create mode 100644 e2e/tests/attendee-identity.spec.ts create mode 100644 prisma/migrations/20260903150000_confirm_attendee_display_name/migration.sql create mode 100644 src/components/session/SessionIdentityGate.tsx create mode 100644 src/components/session/__tests__/SessionIdentityGate.test.tsx create mode 100644 src/lib/__tests__/attendee-display-name.test.ts create mode 100644 src/lib/attendee-display-name.ts diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index f1c88b11..c6f65ce0 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -17,9 +17,10 @@ export async function loginViaDashboard( role: DashboardRole, name: string, landing: string, + options: { nameConfirmed?: boolean } = {}, ): Promise { const response = await page.request.post('/api/test-login', { - data: { name, role, landing }, + data: { name, role, landing, ...options }, }); expect( response.ok(), diff --git a/e2e/tests/accessibility.spec.ts b/e2e/tests/accessibility.spec.ts index 3511b9ac..8b258b34 100644 --- a/e2e/tests/accessibility.spec.ts +++ b/e2e/tests/accessibility.spec.ts @@ -76,6 +76,21 @@ test.describe('public surfaces', () => { }); stackTest.describe('role surfaces', () => { + stackTest('attendee name confirmation is accessible before LiveKit mounts', async ({ page }, testInfo) => { + await loginViaDashboard( + page, + 'ATTENDEE', + 'Participante', + ROUTES.session(SESSION_ES.id), + { nameConfirmed: false }, + ); + await expect(page.getByRole('textbox', { + name: /Tu nombre visible|Your visible name/i, + })).toBeVisible(); + await expect(page.getByTestId('connection-state')).toHaveCount(0); + await assertAccessible(page, 'attendee-name-confirmation', testInfo); + }); + stackTest('attendee session shell is accessible', async ({ page }, testInfo) => { // Doors open so the attendee reaches the real shell; without LiveKit // the deterministic connection-error card is checked instead. diff --git a/e2e/tests/attendee-identity.spec.ts b/e2e/tests/attendee-identity.spec.ts new file mode 100644 index 00000000..7f5866ea --- /dev/null +++ b/e2e/tests/attendee-identity.spec.ts @@ -0,0 +1,103 @@ +import { expect, stackTest } from '../fixtures/stack'; +import { loginAttendeeWithTicket, loginViaDashboard } from '../fixtures/auth'; +import { requireDirectDb, withSessionStatus } from '../fixtures/db'; +import { ROUTES, SESSION_ES, TICKETS } from '../fixtures/test-data'; + +stackTest('an unconfirmed attendee alias blocks LiveKit until it is corrected, then survives refresh', async ({ + browser, +}, testInfo) => { + const db = requireDirectDb(testInfo); + await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await loginViaDashboard( + page, + 'ATTENDEE', + 'Participante', + ROUTES.session(SESSION_ES.id), + { nameConfirmed: false }, + ); + + const input = page.getByRole('textbox', { name: /Tu nombre visible|Your visible name/i }); + await expect(input).toBeVisible(); + await expect(page.getByTestId('connection-state')).toHaveCount(0); + await input.fill('Anahí 李'); + await page.getByRole('button', { name: /Confirmar y continuar|Confirm and continue/i }).click(); + + await expect(page.getByTestId('connection-state')).toHaveAttribute( + 'data-state', + 'connected', + { timeout: 20_000 }, + ); + await expect(page.getByTestId('viewer-identity')).toContainText('Anahí 李'); + + await page.reload(); + await expect(page.getByTestId('connection-state')).toHaveAttribute( + 'data-state', + 'connected', + { timeout: 20_000 }, + ); + await expect(input).toHaveCount(0); + await expect(page.getByTestId('viewer-identity')).toContainText('Anahí 李'); + } finally { + await context.close(); + } + }); +}); + +stackTest('a second device can correct the stable event alias used by the hand queue', async ({ + browser, +}, testInfo) => { + stackTest.slow(); + const db = requireDirectDb(testInfo); + await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => { + const firstContext = await browser.newContext(); + const secondContext = await browser.newContext(); + const staffContext = await browser.newContext(); + const first = await firstContext.newPage(); + const second = await secondContext.newPage(); + const staff = await staffContext.newPage(); + try { + await loginAttendeeWithTicket(first, { + name: 'Primer nombre', + email: TICKETS.esBound.email, + code: TICKETS.esBound.code, + }); + await expect(first.getByTestId('viewer-identity')).toContainText('Primer nombre', { + timeout: 20_000, + }); + + await loginAttendeeWithTicket(second, { + name: 'Anahí 李', + email: TICKETS.esBound.email, + code: TICKETS.esBound.code, + }); + await expect(second.getByTestId('viewer-identity')).toContainText('Anahí 李', { + timeout: 20_000, + }); + + await loginViaDashboard( + staff, + 'OPERATOR', + 'Identity Operator', + ROUTES.opsSession(SESSION_ES.id), + ); + await staff.locator('[data-signal="hands"]').click(); + await second.getByRole('button', { name: /Levantar la mano|Raise hand/i }).click(); + + const queue = staff + .getByRole('heading', { name: /Fila de manos|Hand queue/i }) + .locator('..'); + const correctedHand = queue.locator('li').filter({ hasText: 'Anahí 李' }); + await expect(correctedHand).toHaveCount(1, { + timeout: 10_000, + }); + await expect(queue.locator('li').filter({ hasText: 'Primer nombre' })).toHaveCount(0); + } finally { + await firstContext.close(); + await secondContext.close(); + await staffContext.close(); + } + }); +}); diff --git a/e2e/tests/stage-invitation.spec.ts b/e2e/tests/stage-invitation.spec.ts index dde50e6c..c67c4b2d 100644 --- a/e2e/tests/stage-invitation.spec.ts +++ b/e2e/tests/stage-invitation.spec.ts @@ -116,10 +116,32 @@ stackTest('a fresh connection stays invited until the attendee accepts the stage await expect(stageRow.getByRole('button', { name: /Take floor|Quitar la palabra/i })).toBeVisible({ timeout: 10_000, }); - await stageRow.getByRole('button', { name: /Take floor|Quitar la palabra/i }).click(); + + // The attendee owns the return transition. It revokes publishing + // without leaving either receiving room or requiring a new audio + // activation, and is deliberately distinct from session exit. + await attendee.getByRole('button', { name: /Leave the scene|Dejar la escena/i }).click(); + const leaveConfirmation = attendee.getByRole('alertdialog', { + name: /Return to the audience|volver al público/i, + }); + await expect(leaveConfirmation).toContainText(/without reconnecting|sin reconectarte/i); + await leaveConfirmation.getByRole('button', { + name: /Yes, leave the scene|Sí, dejar la escena/i, + }).click(); + await expect( attendee.getByRole('button', { name: /Turn camera off|Apagar (?:la )?cámara/i }), ).toHaveCount(0, { timeout: 10_000 }); + await expect(attendee.getByTestId('connection-state')).toHaveAttribute( + 'data-state', + 'connected', + ); + await expect(attendee.getByRole('button', { name: /Raise hand|Levantar la mano/i })).toBeVisible({ + timeout: 10_000, + }); + await expect(stageRow.getByRole('button', { name: /Take floor|Quitar la palabra/i })).toHaveCount(0, { + timeout: 10_000, + }); } finally { await attendeeContext.close(); await staffContext.close(); diff --git a/prisma/migrations/20260903150000_confirm_attendee_display_name/migration.sql b/prisma/migrations/20260903150000_confirm_attendee_display_name/migration.sql new file mode 100644 index 00000000..c1dce70c --- /dev/null +++ b/prisma/migrations/20260903150000_confirm_attendee_display_name/migration.sql @@ -0,0 +1,5 @@ +-- A room alias is explicitly confirmed before an attendee mounts LiveKit. +-- Existing sessions remain unconfirmed so their next entry repairs any +-- historical generic alias instead of silently carrying it forward. +ALTER TABLE "web_sessions" +ADD COLUMN "display_name_confirmed_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e7bd72de..f51ca0e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -331,25 +331,26 @@ model CommerceMediaOutbox { } model WebSession { - id String @id @default(uuid()) @db.Uuid - tokenDigest String @unique @map("token_digest") - displayName String? @map("display_name") - accountIssuer String? @map("account_issuer") - accountSubject String? @map("account_subject") - accountSessionId String? @map("account_session_id") - accountDisplayName String? @map("account_display_name") - accountValidatedAt DateTime? @map("account_validated_at") - staffUserId String? @map("staff_user_id") @db.Uuid - staffUser User? @relation("StaffWebSessions", fields: [staffUserId], references: [id], onDelete: Cascade) - ticketEntitlementId String? @map("ticket_entitlement_id") @db.Uuid - ticketEntitlement TicketEntitlement? @relation(fields: [ticketEntitlementId], references: [id], onDelete: Cascade) - expiresAt DateTime @map("expires_at") - lastSeenAt DateTime? @map("last_seen_at") - revokedAt DateTime? @map("revoked_at") - revokedByUserId String? @map("revoked_by_user_id") @db.Uuid - revokedBy User? @relation("WebSessionRevoker", fields: [revokedByUserId], references: [id], onDelete: SetNull) - revocationReason String? @map("revocation_reason") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) @db.Uuid + tokenDigest String @unique @map("token_digest") + displayName String? @map("display_name") + displayNameConfirmedAt DateTime? @map("display_name_confirmed_at") + accountIssuer String? @map("account_issuer") + accountSubject String? @map("account_subject") + accountSessionId String? @map("account_session_id") + accountDisplayName String? @map("account_display_name") + accountValidatedAt DateTime? @map("account_validated_at") + staffUserId String? @map("staff_user_id") @db.Uuid + staffUser User? @relation("StaffWebSessions", fields: [staffUserId], references: [id], onDelete: Cascade) + ticketEntitlementId String? @map("ticket_entitlement_id") @db.Uuid + ticketEntitlement TicketEntitlement? @relation(fields: [ticketEntitlementId], references: [id], onDelete: Cascade) + expiresAt DateTime @map("expires_at") + lastSeenAt DateTime? @map("last_seen_at") + revokedAt DateTime? @map("revoked_at") + revokedByUserId String? @map("revoked_by_user_id") @db.Uuid + revokedBy User? @relation("WebSessionRevoker", fields: [revokedByUserId], references: [id], onDelete: SetNull) + revocationReason String? @map("revocation_reason") + createdAt DateTime @default(now()) @map("created_at") @@index([staffUserId]) @@index([ticketEntitlementId]) diff --git a/src/app/api/auth/ticket/route.ts b/src/app/api/auth/ticket/route.ts index 4e6ac2f5..62848ef6 100644 --- a/src/app/api/auth/ticket/route.ts +++ b/src/app/api/auth/ticket/route.ts @@ -366,6 +366,7 @@ async function redeem( data: { tokenDigest: issued.database.tokenDigest, displayName, + displayNameConfirmedAt: now, ticketEntitlementId: entitlement.id, ...(account ? { accountIssuer: account.issuer, diff --git a/src/app/api/scheduled-sessions/[id]/entry/__tests__/route.test.ts b/src/app/api/scheduled-sessions/[id]/entry/__tests__/route.test.ts index 5d529ccb..dc357013 100644 --- a/src/app/api/scheduled-sessions/[id]/entry/__tests__/route.test.ts +++ b/src/app/api/scheduled-sessions/[id]/entry/__tests__/route.test.ts @@ -2,15 +2,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createRequest, mockParams, parseResponse } from '@/__tests__/helpers'; -const { principalFromToken, accountIdentityFromToken, attachPublicSessionAccess, findUnique } = vi.hoisted(() => ({ +const { + principalFromToken, + accountIdentityFromToken, + attachPublicSessionAccess, + findUnique, + readAttendeeDisplayName, + confirmAttendeeDisplayName, +} = vi.hoisted(() => ({ principalFromToken: vi.fn(), accountIdentityFromToken: vi.fn(), attachPublicSessionAccess: vi.fn(), findUnique: vi.fn(), + readAttendeeDisplayName: vi.fn(), + confirmAttendeeDisplayName: vi.fn(), })); vi.mock('@/lib/principal', () => ({ principalFromToken, accountIdentityFromToken })); vi.mock('@/lib/public-session-access', () => ({ attachPublicSessionAccess })); +vi.mock('@/lib/attendee-display-name', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readAttendeeDisplayName, + confirmAttendeeDisplayName, + }; +}); vi.mock('@/lib/db', () => ({ prisma: { scheduledSession: { findUnique } }, })); @@ -44,6 +61,21 @@ async function getEntry() { )); } +async function patchEntry(body: unknown) { + const { PATCH } = await import('../route'); + return parseResponse(await PATCH( + createRequest('/api/scheduled-sessions/event-1/entry', { + method: 'PATCH', + headers: { + cookie: 'hb_session=opaque', + 'content-type': 'application/json', + }, + body, + }), + mockParams({ id: 'event-1' }), + )); +} + describe('GET /api/scheduled-sessions/[id]/entry', () => { beforeEach(() => { vi.clearAllMocks(); @@ -51,6 +83,14 @@ describe('GET /api/scheduled-sessions/[id]/entry', () => { accountIdentityFromToken.mockResolvedValue(null); attachPublicSessionAccess.mockResolvedValue(false); findUnique.mockResolvedValue(session); + readAttendeeDisplayName.mockResolvedValue({ + displayName: 'Annie', + confirmed: false, + }); + confirmAttendeeDisplayName.mockResolvedValue({ + displayName: 'Annie ✿', + confirmed: true, + }); }); it('confirms a valid ticket and returns WAITING before doors open', async () => { @@ -58,6 +98,11 @@ describe('GET /api/scheduled-sessions/[id]/entry', () => { expect(status).toBe(200); expect(body).toEqual({ state: 'WAITING', + identity: { + kind: 'attendee', + displayName: 'Annie', + confirmed: false, + }, session: { id: 'event-1', title: 'The Return', @@ -67,6 +112,7 @@ describe('GET /api/scheduled-sessions/[id]/entry', () => { }, }); expect(JSON.stringify(body)).not.toMatch(/token|email|1234/i); + expect(readAttendeeDisplayName).toHaveBeenCalledWith('web-1', 'event-1', 'ticket-1'); }); it.each([ @@ -113,7 +159,11 @@ describe('GET /api/scheduled-sessions/[id]/entry', () => { userId: 'facilitator-1', role: 'FACILITATOR', }); - expect((await getEntry()).body).toMatchObject({ state: 'READY' }); + expect((await getEntry()).body).toMatchObject({ + state: 'READY', + identity: { kind: 'staff' }, + }); + expect(readAttendeeDisplayName).not.toHaveBeenCalled(); }); it('rejects an unassigned facilitator', async () => { @@ -142,4 +192,31 @@ describe('GET /api/scheduled-sessions/[id]/entry', () => { expect((await getEntry()).status).toBe(401); expect(findUnique).not.toHaveBeenCalled(); }); + + it('confirms a normalized international display name for the current attendee only', async () => { + const result = await patchEntry({ displayName: ' Anahí 李 ' }); + + expect(result).toEqual({ + status: 200, + body: { displayName: 'Annie ✿', confirmed: true }, + }); + expect(confirmAttendeeDisplayName).toHaveBeenCalledWith({ + webSessionId: 'web-1', + scheduledSessionId: 'event-1', + ticketEntitlementId: 'ticket-1', + displayName: ' Anahí 李 ', + }); + }); + + it('does not let staff mutate an attendee alias', async () => { + principalFromToken.mockResolvedValue({ + kind: 'staff', + webSessionId: 'web-staff', + userId: 'facilitator-1', + role: 'FACILITATOR', + }); + + expect((await patchEntry({ displayName: 'Someone else' })).status).toBe(403); + expect(confirmAttendeeDisplayName).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/scheduled-sessions/[id]/entry/route.ts b/src/app/api/scheduled-sessions/[id]/entry/route.ts index 13890b23..f919dcc3 100644 --- a/src/app/api/scheduled-sessions/[id]/entry/route.ts +++ b/src/app/api/scheduled-sessions/[id]/entry/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; +import { + AttendeeDisplayNameError, + confirmAttendeeDisplayName, + readAttendeeDisplayName, +} from '@/lib/attendee-display-name'; import { prisma } from '@/lib/db'; import { accountIdentityFromToken, principalFromToken } from '@/lib/principal'; import { attachPublicSessionAccess } from '@/lib/public-session-access'; @@ -8,26 +13,20 @@ import { eventStaffPolicy } from '@/lib/staff-capabilities'; export const dynamic = 'force-dynamic'; -/** - * Lightweight, non-token entry state. The room page polls this before mounting - * either LiveKit connection, so a valid ticket can wait truthfully without - * receiving stage or bed credentials. - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { +const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' }; + +function response(body: unknown, status = 200) { + return NextResponse.json(body, { status, headers: PRIVATE_NO_STORE }); +} + +async function resolveEntry(request: NextRequest, id: string) { const cookieValue = request.cookies.get(SESSION_COOKIE_NAME)?.value; let principal = await principalFromToken(cookieValue); const account = principal ? null : await accountIdentityFromToken(cookieValue); if (!principal && !account) { - return NextResponse.json( - { error: 'Authentication required' }, - { status: 401 }, - ); + return { ok: false as const, error: response({ error: 'Authentication required' }, 401) }; } - const { id } = await params; const session = await prisma.scheduledSession.findUnique({ where: { id }, select: { @@ -41,7 +40,7 @@ export async function GET( }, }); if (!session) { - return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + return { ok: false as const, error: response({ error: 'Session not found' }, 404) }; } if (!principal && account && cookieValue && session.publicAccess) { @@ -49,11 +48,11 @@ export async function GET( if (attached) principal = await principalFromToken(cookieValue); } if (!principal) { - return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + return { ok: false as const, error: response({ error: 'Not authorized' }, 403) }; } if (principal.kind === 'attendee' && principal.scheduledSessionId !== session.id) { - return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + return { ok: false as const, error: response({ error: 'Not authorized' }, 403) }; } if (principal.kind === 'staff') { const policy = eventStaffPolicy( @@ -61,18 +60,57 @@ export async function GET( session.facilitatorId === principal.userId, ); if (!policy.canOperateEvent) { - return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + return { ok: false as const, error: response({ error: 'Not authorized' }, 403) }; } } + return { ok: true as const, principal, session }; +} + +/** + * Lightweight, non-token entry state. The room page polls this before mounting + * either LiveKit connection, so a valid ticket can wait truthfully without + * receiving stage or bed credentials. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const resolved = await resolveEntry(request, id); + if (!resolved.ok) return resolved.error; + const { principal, session } = resolved; + const state = session.status === 'SCHEDULED' ? (principal.kind === 'attendee' ? 'WAITING' : 'READY') : session.status === 'LIVE' ? 'READY' : session.status; - return NextResponse.json({ + let identity: { kind: 'staff' } | { + kind: 'attendee'; + displayName: string; + confirmed: boolean; + } = { kind: 'staff' }; + if (principal.kind === 'attendee') { + try { + const name = await readAttendeeDisplayName( + principal.webSessionId, + id, + principal.entitlementId, + ); + identity = { kind: 'attendee', ...name }; + } catch (failure) { + if (failure instanceof AttendeeDisplayNameError) { + return response({ error: failure.code }, failure.status); + } + throw failure; + } + } + + return response({ state, + identity, session: { id: session.id, title: session.title, @@ -82,3 +120,42 @@ export async function GET( }, }); } + +/** Confirm or correct the caller's own room alias before LiveKit is mounted. */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const resolved = await resolveEntry(request, id); + if (!resolved.ok) return resolved.error; + if (resolved.principal.kind !== 'attendee') { + return response({ error: 'Not authorized' }, 403); + } + + let displayName = ''; + try { + const body = await request.json() as { displayName?: unknown }; + displayName = typeof body.displayName === 'string' ? body.displayName : ''; + } catch { + // The shared validator below returns the same bounded client error. + } + + try { + return response(await confirmAttendeeDisplayName({ + webSessionId: resolved.principal.webSessionId, + scheduledSessionId: id, + ticketEntitlementId: resolved.principal.entitlementId, + displayName, + })); + } catch (failure) { + if (failure instanceof AttendeeDisplayNameError) { + return response( + { error: failure.code, message: failure.message }, + failure.status, + ); + } + console.error('[entry] unexpected display-name confirmation failure'); + return response({ error: 'entry_unavailable' }, 500); + } +} diff --git a/src/app/api/scheduled-sessions/[id]/hand/__tests__/route.test.ts b/src/app/api/scheduled-sessions/[id]/hand/__tests__/route.test.ts index 4bac4acc..6d0646e3 100644 --- a/src/app/api/scheduled-sessions/[id]/hand/__tests__/route.test.ts +++ b/src/app/api/scheduled-sessions/[id]/hand/__tests__/route.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ lowerHand: vi.fn(), getHandState: vi.fn(), declineStageInvitation: vi.fn(), + leaveStage: vi.fn(), })); vi.mock('@/lib/room-entitlement', () => ({ @@ -27,6 +28,7 @@ vi.mock('@/lib/stage-control', async (importOriginal) => { return { ...original, declineStageInvitation: mocks.declineStageInvitation, + leaveStage: mocks.leaveStage, }; }); @@ -77,6 +79,13 @@ describe('/api/scheduled-sessions/[id]/hand', () => { reconcileNeeded: false, grantVersion: 2, }); + mocks.leaveStage.mockResolvedValue({ + participantId: 'participant-1', + participantIdentity: 'opaque-attendee-1', + canPublish: false, + reconcileNeeded: false, + grantVersion: 3, + }); }); it('denies an unauthenticated or unentitled caller at the entitlement gate', async () => { @@ -181,6 +190,32 @@ describe('/api/scheduled-sessions/[id]/hand', () => { }); }); + it('lets the caller leave their own stage grant and returns the converged hand state', async () => { + mocks.getHandState.mockResolvedValue(handState({ + raised: false, + raisedAt: null, + queuePosition: null, + canPublish: false, + })); + const { PATCH } = await import('../route'); + + const { status, body } = await parseResponse(await PATCH( + createRequest('/api/scheduled-sessions/event-1/hand', { + method: 'PATCH', + body: { action: 'leave_stage' }, + }), + mockParams({ id: 'event-1' }), + )); + + expect(status).toBe(200); + expect(body).toMatchObject({ raised: false, canPublish: false }); + expect(mocks.leaveStage).toHaveBeenCalledWith({ + scheduledSessionId: 'event-1', + participantIdentity: 'opaque-attendee-1', + }); + expect(mocks.declineStageInvitation).not.toHaveBeenCalled(); + }); + it('rejects an unknown invitation action without changing a grant', async () => { const { PATCH } = await import('../route'); @@ -194,6 +229,7 @@ describe('/api/scheduled-sessions/[id]/hand', () => { expect(response.status).toBe(400); expect(mocks.declineStageInvitation).not.toHaveBeenCalled(); + expect(mocks.leaveStage).not.toHaveBeenCalled(); }); it('returns the caller\u2019s own state for the polling loop, without PII', async () => { diff --git a/src/app/api/scheduled-sessions/[id]/hand/route.ts b/src/app/api/scheduled-sessions/[id]/hand/route.ts index 20961025..90049c52 100644 --- a/src/app/api/scheduled-sessions/[id]/hand/route.ts +++ b/src/app/api/scheduled-sessions/[id]/hand/route.ts @@ -10,6 +10,7 @@ import { import { resolveRoomPrincipal } from '@/lib/room-entitlement'; import { declineStageInvitation, + leaveStage, StageControlError, } from '@/lib/stage-control'; @@ -170,15 +171,18 @@ export async function PATCH( { status: 400 }, ); } - if (body.action !== 'decline_invitation') { + if (body.action !== 'decline_invitation' && body.action !== 'leave_stage') { return NextResponse.json( - { error: 'invalid_request', message: 'Action must be decline_invitation' }, + { error: 'invalid_request', message: 'Action must be decline_invitation or leave_stage' }, { status: 400 }, ); } try { - await declineStageInvitation({ + const changeStage = body.action === 'leave_stage' + ? leaveStage + : declineStageInvitation; + await changeStage({ scheduledSessionId: id, participantIdentity: principal.identity, }); diff --git a/src/app/api/test-login/route.ts b/src/app/api/test-login/route.ts index bec129b0..244ca3cd 100644 --- a/src/app/api/test-login/route.ts +++ b/src/app/api/test-login/route.ts @@ -76,6 +76,7 @@ export async function POST(request: NextRequest): Promise { let name: string; let role: DashboardRole; let landing: string | null; + let nameConfirmed: boolean; try { const body = (await request.json()) as unknown; const fields = (body ?? {}) as Record; @@ -84,6 +85,9 @@ export async function POST(request: NextRequest): Promise { ? (fields.role as DashboardRole) : 'ATTENDEE'; landing = sanitizeLanding(fields.landing); + // E2E-only hook for exercising the pre-room confirmation gate. The + // default mirrors a tester explicitly choosing the dashboard name. + nameConfirmed = fields.nameConfirmed !== false; } catch { return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); } @@ -150,6 +154,7 @@ export async function POST(request: NextRequest): Promise { data: { tokenDigest: issued.database.tokenDigest, displayName: name, + displayNameConfirmedAt: nameConfirmed ? now : null, staffUserId, ticketEntitlementId, expiresAt: webSessionExpiry(now), diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index 199ae127..a3e884dd 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -120,8 +120,12 @@ export default function LoginClient({ required autoComplete="email" spellCheck={false} + aria-describedby="display-name-hint" className="event-field" /> +

+ {messages.displayNameHint} +

} {error && ( diff --git a/src/app/session/[id]/__tests__/media-continuity.test.tsx b/src/app/session/[id]/__tests__/media-continuity.test.tsx index a6ea3d29..a24c3489 100644 --- a/src/app/session/[id]/__tests__/media-continuity.test.tsx +++ b/src/app/session/[id]/__tests__/media-continuity.test.tsx @@ -73,6 +73,7 @@ const TOKEN_RESPONSE = { const ENTRY_RESPONSE = { state: 'READY', + identity: { kind: 'attendee', displayName: 'Nico', confirmed: true }, session: { id: 'session-1', title: 'Test Session', diff --git a/src/app/session/[id]/__tests__/page.test.tsx b/src/app/session/[id]/__tests__/page.test.tsx index 3479569a..8a03b202 100644 --- a/src/app/session/[id]/__tests__/page.test.tsx +++ b/src/app/session/[id]/__tests__/page.test.tsx @@ -204,6 +204,7 @@ const TOKEN_RESPONSE = { const ENTRY_RESPONSE = { state: 'READY', + identity: { kind: 'attendee', displayName: 'Nico', confirmed: true }, session: { id: 'session-1', title: 'Test Session', @@ -277,6 +278,7 @@ describe('SessionRoomPage - event entry', () => { ok: true, json: async () => ({ state: 'WAITING', + identity: ENTRY_RESPONSE.identity, session: { ...ENTRY_RESPONSE.session, language: 'SPANISH', @@ -304,6 +306,7 @@ describe('SessionRoomPage - event entry', () => { ok: true, json: async () => ({ state: 'WAITING', + identity: ENTRY_RESPONSE.identity, session: { ...ENTRY_RESPONSE.session, language: 'SPANISH', @@ -326,6 +329,7 @@ describe('SessionRoomPage - event entry', () => { ok: true, json: async () => ({ state: 'ENDED', + identity: ENTRY_RESPONSE.identity, session: { ...ENTRY_RESPONSE.session, status: 'ENDED' }, }), } as Response); @@ -347,6 +351,7 @@ describe('SessionRoomPage - event entry', () => { json: async () => entryChecks === 1 ? { state: 'WAITING', + identity: ENTRY_RESPONSE.identity, session: { ...ENTRY_RESPONSE.session, status: 'SCHEDULED' }, } : ENTRY_RESPONSE, @@ -378,6 +383,7 @@ describe('SessionRoomPage - event entry', () => { ? ENTRY_RESPONSE : { state: 'ENDED', + identity: ENTRY_RESPONSE.identity, session: { ...ENTRY_RESPONSE.session, status: 'ENDED' }, }, } as Response); @@ -397,6 +403,46 @@ describe('SessionRoomPage - event entry', () => { expect(await screen.findByText('Session ended')).toBeInTheDocument(); await waitFor(() => expect(connectedRoom.disconnect).toHaveBeenCalledOnce()); }); + + it('does not mint a LiveKit token until the attendee confirms their visible name', async () => { + const roomsBefore = (Room as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + const unconfirmed = { + ...ENTRY_RESPONSE, + identity: { kind: 'attendee', displayName: 'Participante', confirmed: false }, + }; + vi.mocked(global.fetch).mockImplementation((url: string | URL | Request, init?: RequestInit) => { + if (String(url).includes('/entry') && init?.method === 'PATCH') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ displayName: 'Anahí 李', confirmed: true }), + } as Response); + } + if (String(url).includes('/entry')) { + return Promise.resolve({ ok: true, json: async () => unconfirmed } as Response); + } + if (String(url).includes('/token')) { + return Promise.resolve({ + ok: true, + json: async () => ({ ...TOKEN_RESPONSE, displayName: 'Anahí 李' }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }); + + renderPage('es'); + + const input = await screen.findByRole('textbox', { name: /Tu nombre visible|Your visible name/i }); + expect((Room as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(roomsBefore); + expect(vi.mocked(global.fetch).mock.calls.filter(([url]) => String(url).includes('/token'))).toHaveLength(0); + fireEvent.change(input, { target: { value: 'Anahí 李' } }); + fireEvent.click(screen.getByRole('button', { name: /Confirmar y continuar|Confirm and continue/i })); + + await waitFor(() => expect( + (Room as unknown as { mock: { calls: unknown[] } }).mock.calls, + ).toHaveLength(roomsBefore + 1)); + expect(await screen.findByTestId('viewer-identity')).toHaveTextContent('Anahí 李'); + }); }); describe('SessionRoomPage - participant identity', () => { @@ -718,6 +764,90 @@ describe('SessionRoomPage - stage invitation consent', () => { expect((Room as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(roomCount); expect(room.disconnect).not.toHaveBeenCalled(); }); + + it('leaves the stage deliberately and keeps both receiving rooms mounted', async () => { + const { room } = await receiveInvitation(); + fireEvent.click(screen.getByRole('button', { name: 'Accept and join' })); + await screen.findByRole('button', { name: 'Leave the scene' }); + const roomCount = (Room as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + const beaconStarts = audioMocks.startBeaconAudio.mock.calls.length; + const stageAudioStarts = room.startAudio.mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: 'Leave the scene' })); + const confirmation = await screen.findByRole('alertdialog', { name: 'Return to the audience?' }); + expect(confirmation).toHaveTextContent(/keep hearing the session and Beacon without reconnecting/i); + expect(screen.getByRole('button', { name: 'Stay on stage' })).toHaveFocus(); + expect(room.disconnect).not.toHaveBeenCalled(); + + const confirm = screen.getByRole('button', { name: 'Yes, leave the scene' }); + fireEvent.click(confirm); + fireEvent.click(confirm); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Leave the scene' })).not.toBeInTheDocument()); + const leaveRequests = vi.mocked(global.fetch).mock.calls.filter(([, init]) => + init?.method === 'PATCH' && init.body === JSON.stringify({ action: 'leave_stage' })); + expect(leaveRequests).toHaveLength(1); + expect((Room as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(roomCount); + expect(room.disconnect).not.toHaveBeenCalled(); + expect(audioMocks.startBeaconAudio).toHaveBeenCalledTimes(beaconStarts); + expect(room.startAudio).toHaveBeenCalledTimes(stageAudioStarts); + expect(screen.getByTestId('connection-state')).toHaveAttribute('data-state', 'connected'); + }); + + it('stays on stage with retryable feedback when the voluntary exit request fails', async () => { + const { room } = await receiveInvitation(); + fireEvent.click(screen.getByRole('button', { name: 'Accept and join' })); + await screen.findByRole('button', { name: 'Leave the scene' }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const fetchMock = vi.mocked(global.fetch); + const fallback = fetchMock.getMockImplementation(); + fetchMock.mockImplementation((url, init) => { + if ( + String(url).includes('/hand') && + init?.method === 'PATCH' && + init.body === JSON.stringify({ action: 'leave_stage' }) + ) { + return Promise.resolve({ + ok: false, + status: 503, + json: async () => ({ error: 'stage_unavailable' }), + } as Response); + } + return fallback!(url, init); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Leave the scene' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, leave the scene' })); + + expect(await screen.findByText(/could not complete your return/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Yes, leave the scene' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Turn camera off' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeInTheDocument(); + expect(room.disconnect).not.toHaveBeenCalled(); + expect(screen.getByTestId('connection-state')).toHaveAttribute('data-state', 'connected'); + }); +}); + +describe('SessionRoomPage - deliberate session exit', () => { + it('separates session exit from everyday controls and disconnects only after confirmation', async () => { + await renderConnected(); + const room = currentRoom(); + + fireEvent.click(screen.getByRole('button', { name: 'Leave session' })); + const confirmation = screen.getByRole('alertdialog', { name: 'Leave session' }); + expect(confirmation).toHaveTextContent(/disconnects this page from the session and Beacon/i); + expect(room.disconnect).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Stay in the session' })).toHaveFocus(); + + fireEvent.click(screen.getByRole('button', { name: 'Stay in the session' })); + expect(room.disconnect).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Leave session' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, leave the session' })); + + expect(room.disconnect).toHaveBeenCalledOnce(); + expect(mockPush).toHaveBeenCalledWith('/'); + }); }); describe('SessionRoomPage - server-ended disconnect', () => { @@ -917,6 +1047,7 @@ describe('SessionRoomPage - intentional disconnects are not terminal states', () await renderConnected(); fireEvent.click(screen.getByRole('button', { name: 'Leave session' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, leave the session' })); await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/')); // The real SDK would still fire Disconnected(CLIENT_INITIATED) after diff --git a/src/app/session/[id]/page.tsx b/src/app/session/[id]/page.tsx index 3c1fd697..2dbb9c2f 100644 --- a/src/app/session/[id]/page.tsx +++ b/src/app/session/[id]/page.tsx @@ -20,6 +20,7 @@ import HandRaiseButton from "@/components/session/HandRaiseButton"; import FacilitatorAudioQuality from "@/components/session/FacilitatorAudioQuality"; import SessionContributions from "@/components/session/SessionContributions"; import SessionGuidance from "@/components/session/SessionGuidance"; +import SessionIdentityGate from "@/components/session/SessionIdentityGate"; import StageLayout, { type StagePublisherView } from "@/components/session/StageLayout"; import ThumbnailSender from "@/components/session/ThumbnailSender"; import ThumbnailTapestry from "@/components/session/ThumbnailTapestry"; @@ -230,6 +231,10 @@ function SessionRoom() { const [stageInvitationAccepted, setStageInvitationAccepted] = useState(false); const [stageInvitationBusy, setStageInvitationBusy] = useState<'accept' | 'decline' | null>(null); const [stageInvitationError, setStageInvitationError] = useState(null); + const [stageExitConfirming, setStageExitConfirming] = useState(false); + const [stageExitBusy, setStageExitBusy] = useState(false); + const [stageExitError, setStageExitError] = useState(null); + const [sessionExitConfirming, setSessionExitConfirming] = useState(false); const roomRef = useRef(null); // Keep ownership by track so an unsubscribe can remove the exact DOM node @@ -249,6 +254,7 @@ function SessionRoom() { const desiredCameraRef = useRef(false); const deviceOperationRef = useRef | null>(null); const stageInvitationAcceptedRef = useRef(false); + const stageExitInFlightRef = useRef(false); const terminalViewRef = useRef(null); const stageInvitationRef = useRef(null); const participantFallbackRef = useRef(copy.session.participantFallback); @@ -494,6 +500,45 @@ function SessionRoom() { } }, [canPublish, copy.session.invitationDeclineError, id, principalKind, stageInvitationBusy]); + const leaveStage = useCallback(async () => { + if ( + principalKind !== 'ticket' || + !canPublish || + stageExitBusy || + stageExitInFlightRef.current + ) return; + + stageExitInFlightRef.current = true; + setStageExitBusy(true); + setStageExitError(null); + try { + const response = await fetch(`/api/scheduled-sessions/${id}/hand`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'leave_stage' }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + // LiveKit's permission event releases the local devices. These + // state updates make the attendee UI converge immediately while + // both receiving rooms and their audio activation stay mounted. + desiredCameraRef.current = false; + desiredMicRef.current = false; + setStageInvitationAccepted(false); + setCanPublish(false); + setIsCameraOn(false); + setIsMicOn(false); + setStageExitConfirming(false); + readStage(); + } catch (failure) { + console.error('Failed to leave the stage:', redactErrorDetail(failure)); + setStageExitError(copy.session.leaveStageFailed); + } finally { + stageExitInFlightRef.current = false; + setStageExitBusy(false); + } + }, [canPublish, copy.session.leaveStageFailed, id, principalKind, readStage, stageExitBusy]); + // Connect to LiveKit room useEffect(() => { let cancelled = false; @@ -1201,6 +1246,62 @@ function SessionRoom() { /> )} + {canControlStageDevices && principalKind === 'ticket' ? ( +
+ {!stageExitConfirming ? ( + + ) : ( +
+

+ {copy.session.leaveStageHeading} +

+

+ {copy.session.leaveStageBody} +

+
+ + +
+
+ )} + {stageExitError ? ( +

+ {stageExitError} +

+ ) : null} +
+ ) : null}
{canControlStageDevices && (
@@ -1293,24 +1394,48 @@ function SessionRoom() { {copy.session.audioOnly}
-
- - {copy.session.leave} -
{cameraSwitchError && (

{cameraSwitchError}

)} +
+ {!sessionExitConfirming ? ( + + ) : ( +
+

{copy.session.leaveSessionBody}

+
+ + +
+
+ )} +
@@ -1335,6 +1460,11 @@ type EntrySession = { type EntryResponse = { state: EntryState; + identity: { kind: 'staff' } | { + kind: 'attendee'; + displayName: string; + confirmed: boolean; + }; session: EntrySession; }; @@ -1424,6 +1554,20 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) { ); } + if (entry.identity.kind === 'attendee' && !entry.identity.confirmed) { + return ( + setEntry((current) => current ? { + ...current, + identity: { kind: 'attendee', displayName, confirmed: true }, + } : current)} + /> + ); + } + if (entry.state === 'WAITING') { const startsAt = new Intl.DateTimeFormat(locale === 'es' ? 'es-AR' : 'en-US', { dateStyle: 'full', diff --git a/src/components/session/SessionIdentityGate.tsx b/src/components/session/SessionIdentityGate.tsx new file mode 100644 index 00000000..cec21946 --- /dev/null +++ b/src/components/session/SessionIdentityGate.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { useLocale } from '@/context/LocaleContext'; + +type Props = { + sessionId: string; + sessionTitle: string; + initialDisplayName: string; + onConfirmed: (displayName: string) => void; +}; + +export default function SessionIdentityGate({ + sessionId, + sessionTitle, + initialDisplayName, + onConfirmed, +}: Props) { + const { copy } = useLocale(); + const [displayName, setDisplayName] = useState(initialDisplayName); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + setDisplayName(initialDisplayName); + setError(null); + }, [initialDisplayName, sessionId]); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (busy) return; + if (!displayName.trim()) { + setError(copy.session.nameConfirmationRequired); + inputRef.current?.focus(); + return; + } + + setBusy(true); + setError(null); + try { + const response = await fetch(`/api/scheduled-sessions/${sessionId}/entry`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName }), + }); + const body = await response.json().catch(() => ({})) as { + displayName?: unknown; + error?: unknown; + }; + if (!response.ok || typeof body.displayName !== 'string') { + if (response.status === 400) { + setError(copy.session.nameConfirmationRequired); + inputRef.current?.focus(); + } else { + setError(copy.session.nameConfirmationFailed); + } + return; + } + onConfirmed(body.displayName); + } catch { + setError(copy.session.nameConfirmationFailed); + } finally { + setBusy(false); + } + } + + return ( +
+
+
void submit(event)} + noValidate + > +
+

{copy.session.nameConfirmationEyebrow}

+

+ {copy.session.nameConfirmationHeading} +

+

+ {copy.session.nameConfirmationBody} +

+

{sessionTitle}

+
+ +
+ + setDisplayName(event.target.value)} + required + maxLength={60} + autoComplete="name" + autoFocus + aria-describedby="session-display-name-hint" + className="event-field" + /> +

+ {copy.session.nameConfirmationHint} +

+
+ + {error ? ( +

{error}

+ ) : null} + + +

+ {copy.session.nameConfirmationPrivacy} +

+
+
+
+ ); +} diff --git a/src/components/session/__tests__/SessionIdentityGate.test.tsx b/src/components/session/__tests__/SessionIdentityGate.test.tsx new file mode 100644 index 00000000..20df788e --- /dev/null +++ b/src/components/session/__tests__/SessionIdentityGate.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { LocaleProvider } from '@/context/LocaleContext'; +import SessionIdentityGate from '@/components/session/SessionIdentityGate'; + +describe('SessionIdentityGate', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ displayName: 'Anahí 李', confirmed: true }), + }) as unknown as typeof fetch; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('explains why the name is needed and confirms international characters', async () => { + const onConfirmed = vi.fn(); + render( + + + , + ); + + expect(screen.getByText('El Umbral')).toBeInTheDocument(); + expect(screen.getByText(/reconocerte cuando levantes la mano/i)).toBeInTheDocument(); + const input = screen.getByRole('textbox', { name: 'Tu nombre visible' }); + fireEvent.change(input, { target: { value: ' Anahí 李 ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Confirmar y continuar' })); + + await waitFor(() => expect(onConfirmed).toHaveBeenCalledWith('Anahí 李')); + expect(global.fetch).toHaveBeenCalledWith( + '/api/scheduled-sessions/session-1/entry', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ displayName: ' Anahí 李 ' }), + }), + ); + }); + + it('keeps the attendee outside LiveKit and focuses an empty required name', () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Confirm and continue' })); + const input = screen.getByRole('textbox', { name: 'Your visible name' }); + expect(input).toHaveFocus(); + expect(screen.getByRole('alert')).toHaveTextContent(/Enter a visible name/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('announces a server failure and allows retry without losing the draft', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ error: 'entry_unavailable' }), + } as Response); + render( + + + , + ); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Annie ✿' } }); + fireEvent.click(screen.getByRole('button', { name: 'Confirm and continue' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not save your name/i); + expect(screen.getByRole('textbox')).toHaveValue('Annie ✿'); + expect(screen.getByRole('button', { name: 'Confirm and continue' })).toBeEnabled(); + }); +}); diff --git a/src/lib/__tests__/attendee-display-name.test.ts b/src/lib/__tests__/attendee-display-name.test.ts new file mode 100644 index 00000000..4eec13fe --- /dev/null +++ b/src/lib/__tests__/attendee-display-name.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const transaction = { + $queryRaw: vi.fn(), + webSession: { + findFirst: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + sessionParticipant: { updateMany: vi.fn() }, + }; + return { + transaction, + $transaction: vi.fn(async (operation: (tx: typeof transaction) => unknown) => + operation(transaction)), + readWebSession: vi.fn(), + readParticipant: vi.fn(), + }; +}); + +vi.mock('@/lib/db', () => ({ + prisma: { + $transaction: mocks.$transaction, + webSession: { findFirst: mocks.readWebSession }, + sessionParticipant: { findFirst: mocks.readParticipant }, + }, +})); + +import { + AttendeeDisplayNameError, + confirmAttendeeDisplayName, + readAttendeeDisplayName, +} from '@/lib/attendee-display-name'; + +describe('attendee display name', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.readWebSession.mockResolvedValue({ + displayName: 'Web alias', + displayNameConfirmedAt: null, + }); + mocks.readParticipant.mockResolvedValue(null); + mocks.transaction.webSession.findFirst.mockResolvedValue({ id: 'web-1' }); + mocks.transaction.webSession.updateMany.mockResolvedValue({ count: 2 }); + mocks.transaction.webSession.update.mockResolvedValue({ id: 'web-1' }); + mocks.transaction.sessionParticipant.updateMany.mockResolvedValue({ count: 1 }); + }); + + it('reads the durable participant alias first and requires explicit confirmation', async () => { + mocks.readParticipant.mockResolvedValue({ displayName: ' Anahí 李 ' }); + + await expect(readAttendeeDisplayName('web-1', 'session-1', 'ticket-1')) + .resolves.toEqual({ displayName: 'Anahí 李', confirmed: false }); + }); + + it('does not silently invent a generic alias when no name exists', async () => { + mocks.readWebSession.mockResolvedValue({ + displayName: null, + displayNameConfirmedAt: null, + }); + + await expect(readAttendeeDisplayName('web-1', 'session-1', 'ticket-1')) + .resolves.toEqual({ displayName: '', confirmed: false }); + }); + + it('normalizes and converges active devices plus the durable participant atomically', async () => { + const now = new Date('2026-09-03T15:00:00Z'); + const result = await confirmAttendeeDisplayName({ + webSessionId: 'web-1', + scheduledSessionId: 'session-1', + ticketEntitlementId: 'ticket-1', + displayName: ' Anahí 李 ', + now, + }); + + expect(result).toEqual({ displayName: 'Anahí 李', confirmed: true }); + expect(mocks.transaction.$queryRaw).toHaveBeenCalledOnce(); + expect(mocks.transaction.webSession.updateMany).toHaveBeenCalledWith({ + where: { + ticketEntitlementId: 'ticket-1', + revokedAt: null, + expiresAt: { gt: now }, + }, + data: { displayName: 'Anahí 李' }, + }); + expect(mocks.transaction.webSession.update).toHaveBeenCalledWith({ + where: { id: 'web-1' }, + data: { displayNameConfirmedAt: now }, + }); + expect(mocks.transaction.sessionParticipant.updateMany).toHaveBeenCalledWith({ + where: { + scheduledSessionId: 'session-1', + ticketEntitlementId: 'ticket-1', + }, + data: { displayName: 'Anahí 李' }, + }); + }); + + it.each(['', ' ', 'A\u0000B', 'x'.repeat(61)])('rejects an invalid or overlong name without opening a transaction', async (displayName) => { + await expect(confirmAttendeeDisplayName({ + webSessionId: 'web-1', + scheduledSessionId: 'session-1', + ticketEntitlementId: 'ticket-1', + displayName, + })).rejects.toMatchObject({ + code: 'invalid_name', + status: 400, + } satisfies Partial); + expect(mocks.$transaction).not.toHaveBeenCalled(); + }); + + it('rejects a revoked, expired, or mismatched web session before changing an alias', async () => { + mocks.transaction.webSession.findFirst.mockResolvedValue(null); + + await expect(confirmAttendeeDisplayName({ + webSessionId: 'web-other', + scheduledSessionId: 'session-1', + ticketEntitlementId: 'ticket-1', + displayName: 'Annie', + })).rejects.toMatchObject({ code: 'not_authorized', status: 403 }); + expect(mocks.transaction.webSession.updateMany).not.toHaveBeenCalled(); + expect(mocks.transaction.sessionParticipant.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/__tests__/public-session-access.test.ts b/src/lib/__tests__/public-session-access.test.ts index d6b987e4..62b35f01 100644 --- a/src/lib/__tests__/public-session-access.test.ts +++ b/src/lib/__tests__/public-session-access.test.ts @@ -78,6 +78,7 @@ describe('attachPublicSessionAccess', () => { data: { ticketEntitlementId: 'free-entitlement-1', displayName: 'Sai', + displayNameConfirmedAt: null, lastSeenAt: now, }, }); diff --git a/src/lib/__tests__/room-entitlement.test.ts b/src/lib/__tests__/room-entitlement.test.ts index 51a6d316..3f6d8fed 100644 --- a/src/lib/__tests__/room-entitlement.test.ts +++ b/src/lib/__tests__/room-entitlement.test.ts @@ -210,6 +210,35 @@ describe('resolveRoomPrincipal', () => { })); }); + it('lets a newly confirmed device correct the durable alias without changing identity', async () => { + findWebSession.mockResolvedValue({ + ...activeTicketSession, + displayName: 'Anahí 李', + displayNameConfirmedAt: now, + }); + findParticipant.mockResolvedValue({ + id: 'participant-1', + displayName: 'Nombre anterior', + publishGrantedAt: null, + publishRevokedAt: null, + }); + + const { resolveRoomPrincipal } = await import('../room-entitlement'); + const result = await resolveRoomPrincipal(request(), 'event-1', now); + + expect(result).toMatchObject({ + ok: true, + principal: { + identity: 'opaque:event-1:ticket:ticket-1', + displayName: 'Anahí 李', + }, + }); + expect(updateParticipant).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'participant-1' }, + data: expect.objectContaining({ displayName: 'Anahí 李' }), + })); + }); + it('recovers a concurrent ticket insert only from the exact canonical winner', async () => { findParticipant .mockResolvedValueOnce(null) @@ -240,7 +269,7 @@ describe('resolveRoomPrincipal', () => { }); expect(updateParticipant).toHaveBeenCalledWith({ where: { id: 'ticket-race-winner' }, - data: { leftAt: null }, + data: { leftAt: null, displayName: 'Ana' }, select: { publishGrantedAt: true, publishRevokedAt: true, @@ -365,6 +394,7 @@ describe('resolveRoomPrincipal', () => { where: { id: 'seeded-facilitator-row' }, data: { participantIdentity: 'opaque:event-1:staff:facilitator-1', + displayName: 'Julián', leftAt: null, }, select: { diff --git a/src/lib/__tests__/stage-control.test.ts b/src/lib/__tests__/stage-control.test.ts index e2576c73..76acf43e 100644 --- a/src/lib/__tests__/stage-control.test.ts +++ b/src/lib/__tests__/stage-control.test.ts @@ -378,6 +378,83 @@ describe('stage control', () => { ); }); + it('lets a ticket-backed attendee leave the stage, clears their hand, and audits the voluntary action', async () => { + participants = [attendee('target', true, new Date('2026-08-01T15:10:00Z'))]; + const { leaveStage } = await import('../stage-control'); + + const result = await leaveStage({ + scheduledSessionId: event.id, + participantIdentity: 'opaque-target', + }); + + expect(result).toMatchObject({ canPublish: false, reconcileNeeded: false }); + expect(participants[0]).toMatchObject({ + raisedAt: null, + grantVersion: 2, + }); + expect(participants[0].publishRevokedAt).not.toBeNull(); + expect(mocks.auditCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actorUserId: null, + action: 'stage.attendee.leave', + targetId: 'target', + }), + }); + }); + + it('makes repeated voluntary exits idempotent, including a double click race', async () => { + participants = [attendee('target', true, new Date('2026-08-01T15:10:00Z'))]; + const { leaveStage } = await import('../stage-control'); + + const [first, second] = await Promise.all([ + leaveStage({ + scheduledSessionId: event.id, + participantIdentity: 'opaque-target', + }), + leaveStage({ + scheduledSessionId: event.id, + participantIdentity: 'opaque-target', + }), + ]); + + expect(first.grantVersion).toBe(2); + expect(second.grantVersion).toBe(2); + expect(participants[0]).toMatchObject({ + raisedAt: null, + grantVersion: 2, + }); + expect(mocks.auditCreate).toHaveBeenCalledTimes(1); + expect(mocks.updateParticipant).toHaveBeenCalledTimes(2); + }); + + it('converges a simultaneous voluntary exit and staff demotion on one revoked grant', async () => { + participants = [attendee('target', true, new Date('2026-08-01T15:10:00Z'))]; + const { demoteParticipant, leaveStage } = await import('../stage-control'); + + const results = await Promise.all([ + leaveStage({ + scheduledSessionId: event.id, + participantIdentity: 'opaque-target', + }), + demoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-1', + clearHand: true, + }), + ]); + + expect(results).toEqual([ + expect.objectContaining({ canPublish: false, grantVersion: 2 }), + expect.objectContaining({ canPublish: false, grantVersion: 2 }), + ]); + expect(participants[0]).toMatchObject({ + raisedAt: null, + grantVersion: 2, + }); + expect(mocks.auditCreate).toHaveBeenCalledTimes(1); + }); + it('refuses to demote the assigned facilitator reserved by the weekend contract', async () => { participants = [{ ...attendee('facilitator', true), diff --git a/src/lib/attendee-display-name.ts b/src/lib/attendee-display-name.ts new file mode 100644 index 00000000..532e8712 --- /dev/null +++ b/src/lib/attendee-display-name.ts @@ -0,0 +1,139 @@ +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; +import { isValidDisplayName, normalizeDisplayName } from '@/lib/principal'; + +export type AttendeeDisplayNameState = { + displayName: string; + confirmed: boolean; +}; + +export class AttendeeDisplayNameError extends Error { + constructor( + public readonly code: 'invalid_name' | 'not_authorized', + public readonly status: 400 | 403, + message: string, + ) { + super(message); + this.name = 'AttendeeDisplayNameError'; + } +} + +/** Read the alias the room will actually use, without materializing presence. */ +export async function readAttendeeDisplayName( + webSessionId: string, + scheduledSessionId: string, + ticketEntitlementId: string, +): Promise { + const [webSession, participant] = await Promise.all([ + prisma.webSession.findFirst({ + where: { + id: webSessionId, + ticketEntitlementId, + revokedAt: null, + }, + select: { + displayName: true, + displayNameConfirmedAt: true, + }, + }), + prisma.sessionParticipant.findFirst({ + where: { scheduledSessionId, ticketEntitlementId }, + select: { displayName: true }, + }), + ]); + if (!webSession) { + throw new AttendeeDisplayNameError( + 'not_authorized', + 403, + 'The attendee session is not authorized', + ); + } + + const confirmedWebName = webSession.displayNameConfirmedAt !== null + ? webSession.displayName?.trim() + : null; + const displayName = normalizeDisplayName( + confirmedWebName || participant?.displayName?.trim() || webSession.displayName?.trim() || '', + ); + return { + displayName, + confirmed: webSession.displayNameConfirmedAt !== null && isValidDisplayName(displayName), + }; +} + +/** + * Confirm one browser session and converge every active session plus the + * durable room participant on the same event alias. The entitlement row is + * the mutex, so simultaneous corrections from two devices have one winner. + */ +export async function confirmAttendeeDisplayName(input: { + webSessionId: string; + scheduledSessionId: string; + ticketEntitlementId: string; + displayName: string; + now?: Date; +}): Promise { + const normalizedCandidate = input.displayName.trim().replace(/\s+/g, ' '); + const displayName = normalizeDisplayName(input.displayName); + if (normalizedCandidate.length > 60 || !isValidDisplayName(displayName)) { + throw new AttendeeDisplayNameError( + 'invalid_name', + 400, + 'A visible name between 1 and 60 characters is required', + ); + } + const now = input.now ?? new Date(); + + await prisma.$transaction(async (transaction) => { + await transaction.$queryRaw( + Prisma.sql` + SELECT "id" + FROM "ticket_entitlements" + WHERE "id"::text = ${input.ticketEntitlementId} + FOR UPDATE + `, + ); + + const current = await transaction.webSession.findFirst({ + where: { + id: input.webSessionId, + ticketEntitlementId: input.ticketEntitlementId, + revokedAt: null, + expiresAt: { gt: now }, + }, + select: { id: true }, + }); + if (!current) { + throw new AttendeeDisplayNameError( + 'not_authorized', + 403, + 'The attendee session is not authorized', + ); + } + + // Other active devices inherit the corrected alias, but each device + // still confirms deliberately before its own first room connection. + await transaction.webSession.updateMany({ + where: { + ticketEntitlementId: input.ticketEntitlementId, + revokedAt: null, + expiresAt: { gt: now }, + }, + data: { displayName }, + }); + await transaction.webSession.update({ + where: { id: input.webSessionId }, + data: { displayNameConfirmedAt: now }, + }); + await transaction.sessionParticipant.updateMany({ + where: { + scheduledSessionId: input.scheduledSessionId, + ticketEntitlementId: input.ticketEntitlementId, + }, + data: { displayName }, + }); + }); + + return { displayName, confirmed: true }; +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 3f69b5e7..aa63ba62 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -61,6 +61,7 @@ export type Messages = { accountError: string; accountReconnectHint: string; displayName: string; + displayNameHint: string; ticketCode: string; ticketCodeHint: string; email: string; @@ -189,9 +190,29 @@ export type Messages = { switchToAudioOnly: string; leave: string; leaveSession: string; + leaveSessionBody: string; + leaveSessionConfirm: string; + leaveSessionCancel: string; + leaveStage: string; + leaveStageHeading: string; + leaveStageBody: string; + leaveStageConfirm: string; + leaveStageCancel: string; + leavingStage: string; + leaveStageFailed: string; preparingRoom: string; confirmingEntry: string; entryUnavailable: string; + nameConfirmationEyebrow: string; + nameConfirmationHeading: string; + nameConfirmationBody: string; + nameConfirmationLabel: string; + nameConfirmationHint: string; + nameConfirmationAction: string; + nameConfirmationSaving: string; + nameConfirmationRequired: string; + nameConfirmationFailed: string; + nameConfirmationPrivacy: string; ticketConfirmed: string; doorsClosed: string; doorsReconnecting: string; @@ -582,6 +603,7 @@ export const messages: Record = { accountError: 'No pudimos confirmar tu cuenta. Intentá de nuevo.', accountReconnectHint: 'Tu entrada admite a una persona. El mismo código funciona de nuevo si recargás o se corta la conexión.', displayName: 'Nombre visible en la sala', + displayNameHint: 'El equipo y las personas en escena usarán este nombre para reconocerte. Vas a poder confirmarlo antes de entrar.', ticketCode: 'Código de entrada', ticketCodeHint: 'Exactamente como aparece en tu entrada o invitación', email: 'Correo con el que compraste la entrada', @@ -725,9 +747,29 @@ export const messages: Record = { switchToAudioOnly: 'Cambiar a solo audio', leave: 'Salir', leaveSession: 'Salir de la sesión', + leaveSessionBody: 'Esto desconecta esta página de la sesión y del Beacon. Para volver, vas a tener que ingresar otra vez.', + leaveSessionConfirm: 'Sí, salir de la sesión', + leaveSessionCancel: 'Seguir en la sesión', + leaveStage: 'Dejar la escena', + leaveStageHeading: '¿Querés volver al público?', + leaveStageBody: 'Tu cámara y micrófono dejarán de publicarse. Vas a seguir escuchando la sesión y el Beacon sin reconectarte.', + leaveStageConfirm: 'Sí, dejar la escena', + leaveStageCancel: 'Seguir en escena', + leavingStage: 'Volviendo al público…', + leaveStageFailed: 'No pudimos completar la vuelta al público. Tu permiso se está reconciliando; intentá de nuevo.', preparingRoom: 'Preparando tu sala', confirmingEntry: 'Confirmando tu entrada y el estado del evento…', entryUnavailable: 'No se pudo comprobar el ingreso', + nameConfirmationEyebrow: 'Antes de entrar', + nameConfirmationHeading: '¿Cómo querés que te nombremos?', + nameConfirmationBody: 'Confirmá o corregí el nombre que verá el equipo para reconocerte cuando levantes la mano o entres en escena.', + nameConfirmationLabel: 'Tu nombre visible', + nameConfirmationHint: 'Hasta 60 caracteres. Puede ser tu nombre, apodo o el nombre con el que querés participar.', + nameConfirmationAction: 'Confirmar y continuar', + nameConfirmationSaving: 'Guardando nombre…', + nameConfirmationRequired: 'Escribí un nombre visible de hasta 60 caracteres.', + nameConfirmationFailed: 'No pudimos guardar tu nombre. Tu acceso sigue vigente; intentá de nuevo.', + nameConfirmationPrivacy: 'No convierte el tapiz en un directorio público. Tu nombre se muestra solo en las superficies autorizadas y, públicamente, cuando levantás la mano.', ticketConfirmed: 'Entrada confirmada', doorsClosed: 'Las puertas todavía están cerradas. Esta página te hará entrar automáticamente cuando el equipo las abra.', doorsReconnecting: 'Estamos recuperando la conexión para comprobar las puertas. Tu entrada sigue confirmada.', @@ -1173,6 +1215,7 @@ export const messages: Record = { accountError: 'We could not confirm your account. Try again.', accountReconnectHint: 'Your ticket admits one person. The same code works again after a refresh or a dropped connection.', displayName: 'Name shown in the room', + displayNameHint: 'The team and people on stage will use this name to recognize you. You can confirm it before joining.', ticketCode: 'Ticket code', ticketCodeHint: 'Exactly as it appears on your ticket or invitation', email: 'Email used to buy the ticket', @@ -1316,9 +1359,29 @@ export const messages: Record = { switchToAudioOnly: 'Switch to audio only', leave: 'Leave', leaveSession: 'Leave session', + leaveSessionBody: 'This disconnects this page from the session and Beacon. To return, you will need to join again.', + leaveSessionConfirm: 'Yes, leave the session', + leaveSessionCancel: 'Stay in the session', + leaveStage: 'Leave the scene', + leaveStageHeading: 'Return to the audience?', + leaveStageBody: 'Your camera and microphone will stop publishing. You will keep hearing the session and Beacon without reconnecting.', + leaveStageConfirm: 'Yes, leave the scene', + leaveStageCancel: 'Stay on stage', + leavingStage: 'Returning to the audience…', + leaveStageFailed: 'We could not complete your return to the audience. Your permission is being reconciled; try again.', preparingRoom: 'Preparing your room', confirmingEntry: 'Confirming your ticket and event status…', entryUnavailable: 'Entry status unavailable', + nameConfirmationEyebrow: 'Before joining', + nameConfirmationHeading: 'What should we call you?', + nameConfirmationBody: 'Confirm or correct the name the team will use to recognize you when you raise your hand or join the stage.', + nameConfirmationLabel: 'Your visible name', + nameConfirmationHint: 'Up to 60 characters. Use your name, nickname, or the name you want to participate with.', + nameConfirmationAction: 'Confirm and continue', + nameConfirmationSaving: 'Saving name…', + nameConfirmationRequired: 'Enter a visible name of up to 60 characters.', + nameConfirmationFailed: 'We could not save your name. Your access is still valid; try again.', + nameConfirmationPrivacy: 'This does not turn the tapestry into a public directory. Your name appears only on authorized surfaces and, publicly, when you raise your hand.', ticketConfirmed: 'Ticket confirmed', doorsClosed: 'The doors are not open yet. This page will bring you in automatically when the team opens them.', doorsReconnecting: 'We are reconnecting to check the doors. Your ticket remains confirmed.', diff --git a/src/lib/promo-invitation.ts b/src/lib/promo-invitation.ts index 026520f3..bad0fc4f 100644 --- a/src/lib/promo-invitation.ts +++ b/src/lib/promo-invitation.ts @@ -227,6 +227,7 @@ export async function redeemPromoInvitationByDigest( data: { tokenDigest: issued.database.tokenDigest, displayName, + displayNameConfirmedAt: now, ticketEntitlementId: entitlement.id, ...(account ? { accountIssuer: account.issuer, diff --git a/src/lib/public-session-access.ts b/src/lib/public-session-access.ts index 43744666..3876c617 100644 --- a/src/lib/public-session-access.ts +++ b/src/lib/public-session-access.ts @@ -65,6 +65,7 @@ export async function attachPublicSessionAccess( data: { ticketEntitlementId: entitlement.id, displayName: account.displayName?.trim() || 'Participante', + displayNameConfirmedAt: null, lastSeenAt: now, }, }); diff --git a/src/lib/room-entitlement.ts b/src/lib/room-entitlement.ts index aaab2095..1e1e0383 100644 --- a/src/lib/room-entitlement.ts +++ b/src/lib/room-entitlement.ts @@ -100,7 +100,7 @@ async function recoverConcurrentParticipant( return prisma.sessionParticipant.update({ where: { id: winner.id }, - data: { leftAt: null }, + data: { leftAt: null, displayName: access.displayName }, select: { publishGrantedAt: true, publishRevokedAt: true, @@ -130,6 +130,7 @@ async function resolveRoomAccess( select: { id: true, displayName: true, + displayNameConfirmedAt: true, accountIssuer: true, accountSubject: true, accountSessionId: true, @@ -318,7 +319,9 @@ async function resolveRoomAccess( startedAt: scheduledSession.startedAt, }, identity, - displayName: existingParticipant?.displayName?.trim() || displayName, + displayName: ticketEntitlementId && webSession.displayNameConfirmedAt + ? webSession.displayName?.trim() || existingParticipant?.displayName?.trim() || displayName + : existingParticipant?.displayName?.trim() || displayName, role, isAssignedFacilitator, canPublishInitially, @@ -399,7 +402,9 @@ export async function resolveRoomPrincipal( where: { id: existingParticipant.id }, data: { participantIdentity: access.identity, - // Preserve the alias already captured for this participation. + // A newly confirmed alias from another device becomes the + // durable event name without changing the stable identity. + displayName: access.displayName, leftAt: null, }, select: { diff --git a/src/lib/stage-control.ts b/src/lib/stage-control.ts index 749cf638..055f6529 100644 --- a/src/lib/stage-control.ts +++ b/src/lib/stage-control.ts @@ -85,7 +85,7 @@ type GrantInput = { type DemoteInput = Omit & { actorUserId: string | null; - auditAction?: 'stage.demote' | 'stage.invitation.decline'; + auditAction?: 'stage.demote' | 'stage.invitation.decline' | 'stage.attendee.leave'; clearHand?: boolean; }; @@ -95,6 +95,8 @@ type DeclineInvitationInput = { now?: Date; }; +type LeaveStageInput = DeclineInvitationInput; + type MuteInput = { scheduledSessionId: string; participantId: string; @@ -455,7 +457,15 @@ export async function demoteParticipant( id: input.participantId, scheduledSessionId: input.scheduledSessionId, }, - select: { id: true, participantIdentity: true, staffUserId: true }, + select: { + id: true, + participantIdentity: true, + staffUserId: true, + raisedAt: true, + publishGrantedAt: true, + publishRevokedAt: true, + grantVersion: true, + }, }); if (!target) { throw new StageControlError( @@ -471,15 +481,25 @@ export async function demoteParticipant( 'The assigned facilitator holds the reserved stage slot and cannot be demoted', ); } + const activeGrant = target.publishGrantedAt !== null && target.publishRevokedAt === null; + if (!activeGrant && (!input.clearHand || target.raisedAt === null)) { + return { + id: target.id, + participantIdentity: target.participantIdentity, + grantVersion: target.grantVersion, + roomName: scheduledSession.roomName, + }; + } + const participant = await transaction.sessionParticipant.update({ where: { id: target.id }, data: { - publishRevokedAt: now, + ...(activeGrant ? { publishRevokedAt: now } : {}), ...(input.clearHand ? { raisedAt: null } : {}), grantReconcileNeeded: false, grantChangedByUserId: input.actorUserId, grantReason: input.reason?.trim() || 'Demoted from stage', - grantVersion: { increment: 1 }, + ...(activeGrant ? { grantVersion: { increment: 1 } } : {}), }, select: { id: true, @@ -574,6 +594,41 @@ export async function declineStageInvitation( }); } +/** + * Return an attendee from the stage to the audience using only their resolved + * opaque room identity. Repeated requests and a simultaneous staff demotion + * converge on the same revoked grant without allocating a new version. + */ +export async function leaveStage( + input: LeaveStageInput, +): Promise { + const participant = await prisma.sessionParticipant.findFirst({ + where: { + scheduledSessionId: input.scheduledSessionId, + participantIdentity: input.participantIdentity, + ticketEntitlementId: { not: null }, + }, + select: { id: true }, + }); + if (!participant) { + throw new StageControlError( + 'participant_not_found', + 404, + 'Participant not found', + ); + } + + return demoteParticipant({ + scheduledSessionId: input.scheduledSessionId, + participantId: participant.id, + actorUserId: null, + reason: 'Attendee voluntarily left the stage', + auditAction: 'stage.attendee.leave', + clearHand: true, + now: input.now, + }); +} + export async function muteParticipantTrack( input: MuteInput, ): Promise { From 055d464c1191c5102d53f04317d02fdecb763338 Mon Sep 17 00:00:00 2001 From: AnnieScigliano Date: Fri, 4 Sep 2026 01:18:15 -0300 Subject: [PATCH 2/6] test(e2e): confirm deliberate session exits --- e2e/tests/media-continuity.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/e2e/tests/media-continuity.spec.ts b/e2e/tests/media-continuity.spec.ts index 07d74503..7f7cde44 100644 --- a/e2e/tests/media-continuity.spec.ts +++ b/e2e/tests/media-continuity.spec.ts @@ -68,6 +68,12 @@ async function leaveConnectedRoom( const leave = surface.getByRole('button', { name: /Leave session|Salir de la sesión/i }); if (await leave.isVisible()) { await leave.click(); + const confirmation = surface.getByRole('alertdialog', { + name: /Leave session|Salir de la sesión/i, + }); + await confirmation.getByRole('button', { + name: /Yes, leave the session|Sí, salir de la sesión/i, + }).click(); await expect(surface.getByTestId('connection-state')).toHaveCount(0); } } From aa6183619b14a72141d71f88120979fd995002b7 Mon Sep 17 00:00:00 2001 From: AnnieScigliano Date: Fri, 4 Sep 2026 01:36:35 -0300 Subject: [PATCH 3/6] test(e2e): isolate attendee hand state --- e2e/fixtures/db.ts | 33 +++++++++++ e2e/tests/attendee-identity.spec.ts | 92 +++++++++++++++-------------- 2 files changed, 80 insertions(+), 45 deletions(-) diff --git a/e2e/fixtures/db.ts b/e2e/fixtures/db.ts index ccceaecd..9e3d6aa7 100644 --- a/e2e/fixtures/db.ts +++ b/e2e/fixtures/db.ts @@ -88,6 +88,39 @@ export async function withoutContributions( } } +/** + * Clear a fixture session's raised hands before and after `run`. + * + * Hand-flow specs exercise the real persistent queue. Keeping this cleanup + * close to those specs prevents their state from leaking into later cockpit + * screenshots while still allowing the product endpoints to own every + * transition under test. + */ +export async function withoutRaisedHands( + databaseUrl: string, + sessionId: string, + run: () => Promise, +): Promise { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + await client.query( + 'update session_participants set raised_at = null where scheduled_session_id = $1', + [sessionId], + ); + try { + return await run(); + } finally { + await client.query( + 'update session_participants set raised_at = null where scheduled_session_id = $1', + [sessionId], + ); + } + } finally { + await client.end(); + } +} + /** Replace fixture event titles for a layout test, then restore them exactly. */ export async function withSessionTitles( databaseUrl: string, diff --git a/e2e/tests/attendee-identity.spec.ts b/e2e/tests/attendee-identity.spec.ts index 7f5866ea..f1ccd6f9 100644 --- a/e2e/tests/attendee-identity.spec.ts +++ b/e2e/tests/attendee-identity.spec.ts @@ -1,6 +1,6 @@ import { expect, stackTest } from '../fixtures/stack'; import { loginAttendeeWithTicket, loginViaDashboard } from '../fixtures/auth'; -import { requireDirectDb, withSessionStatus } from '../fixtures/db'; +import { requireDirectDb, withoutRaisedHands, withSessionStatus } from '../fixtures/db'; import { ROUTES, SESSION_ES, TICKETS } from '../fixtures/test-data'; stackTest('an unconfirmed attendee alias blocks LiveKit until it is corrected, then survives refresh', async ({ @@ -52,52 +52,54 @@ stackTest('a second device can correct the stable event alias used by the hand q stackTest.slow(); const db = requireDirectDb(testInfo); await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => { - const firstContext = await browser.newContext(); - const secondContext = await browser.newContext(); - const staffContext = await browser.newContext(); - const first = await firstContext.newPage(); - const second = await secondContext.newPage(); - const staff = await staffContext.newPage(); - try { - await loginAttendeeWithTicket(first, { - name: 'Primer nombre', - email: TICKETS.esBound.email, - code: TICKETS.esBound.code, - }); - await expect(first.getByTestId('viewer-identity')).toContainText('Primer nombre', { - timeout: 20_000, - }); + await withoutRaisedHands(db, SESSION_ES.id, async () => { + const firstContext = await browser.newContext(); + const secondContext = await browser.newContext(); + const staffContext = await browser.newContext(); + const first = await firstContext.newPage(); + const second = await secondContext.newPage(); + const staff = await staffContext.newPage(); + try { + await loginAttendeeWithTicket(first, { + name: 'Primer nombre', + email: TICKETS.esBound.email, + code: TICKETS.esBound.code, + }); + await expect(first.getByTestId('viewer-identity')).toContainText('Primer nombre', { + timeout: 20_000, + }); - await loginAttendeeWithTicket(second, { - name: 'Anahí 李', - email: TICKETS.esBound.email, - code: TICKETS.esBound.code, - }); - await expect(second.getByTestId('viewer-identity')).toContainText('Anahí 李', { - timeout: 20_000, - }); + await loginAttendeeWithTicket(second, { + name: 'Anahí 李', + email: TICKETS.esBound.email, + code: TICKETS.esBound.code, + }); + await expect(second.getByTestId('viewer-identity')).toContainText('Anahí 李', { + timeout: 20_000, + }); - await loginViaDashboard( - staff, - 'OPERATOR', - 'Identity Operator', - ROUTES.opsSession(SESSION_ES.id), - ); - await staff.locator('[data-signal="hands"]').click(); - await second.getByRole('button', { name: /Levantar la mano|Raise hand/i }).click(); + await loginViaDashboard( + staff, + 'OPERATOR', + 'Identity Operator', + ROUTES.opsSession(SESSION_ES.id), + ); + await staff.locator('[data-signal="hands"]').click(); + await second.getByRole('button', { name: /Levantar la mano|Raise hand/i }).click(); - const queue = staff - .getByRole('heading', { name: /Fila de manos|Hand queue/i }) - .locator('..'); - const correctedHand = queue.locator('li').filter({ hasText: 'Anahí 李' }); - await expect(correctedHand).toHaveCount(1, { - timeout: 10_000, - }); - await expect(queue.locator('li').filter({ hasText: 'Primer nombre' })).toHaveCount(0); - } finally { - await firstContext.close(); - await secondContext.close(); - await staffContext.close(); - } + const queue = staff + .getByRole('heading', { name: /Fila de manos|Hand queue/i }) + .locator('..'); + const correctedHand = queue.locator('li').filter({ hasText: 'Anahí 李' }); + await expect(correctedHand).toHaveCount(1, { + timeout: 10_000, + }); + await expect(queue.locator('li').filter({ hasText: 'Primer nombre' })).toHaveCount(0); + } finally { + await firstContext.close(); + await secondContext.close(); + await staffContext.close(); + } + }); }); }); From c433a79cf8e129f2a8dbf559fa3b0f7b39a0f69c Mon Sep 17 00:00:00 2001 From: AnnieScigliano Date: Fri, 4 Sep 2026 23:24:55 -0300 Subject: [PATCH 4/6] fix(auth): associate attendee name guidance --- src/app/login/LoginClient.tsx | 8 ++++---- src/app/login/__tests__/LoginClient.test.tsx | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index a3e884dd..b4189cdc 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -81,7 +81,11 @@ export default function LoginClient({ maxLength={60} autoComplete="name" className="event-field" + aria-describedby="display-name-hint" /> +

+ {messages.displayNameHint} +

@@ -120,12 +124,8 @@ export default function LoginClient({ required autoComplete="email" spellCheck={false} - aria-describedby="display-name-hint" className="event-field" /> -

- {messages.displayNameHint} -

} {error && ( diff --git a/src/app/login/__tests__/LoginClient.test.tsx b/src/app/login/__tests__/LoginClient.test.tsx index 62773a02..b2a12554 100644 --- a/src/app/login/__tests__/LoginClient.test.tsx +++ b/src/app/login/__tests__/LoginClient.test.tsx @@ -174,6 +174,15 @@ describe('LoginClient', () => { expect(screen.getByLabelText('Ticket code')).toHaveAttribute('maxlength', '80'); }); + it('associates the name privacy explanation with the alias instead of the email', () => { + renderLogin('en'); + + expect(screen.getByLabelText('Name shown in the room')).toHaveAccessibleDescription( + 'The team and people on stage will use this name to recognize you. You can confirm it before joining.', + ); + expect(screen.getByLabelText('Email used to buy the ticket')).not.toHaveAccessibleDescription(); + }); + it('keeps the Account profile name as an editable event alias and never asks for email', async () => { const fetchMock = mockFetch({ status: 200, @@ -188,6 +197,9 @@ describe('LoginClient', () => { const user = userEvent.setup(); const alias = screen.getByLabelText(/Name shown in the room/); expect(alias).toHaveValue('Account profile'); + expect(alias).toHaveAccessibleDescription( + 'The team and people on stage will use this name to recognize you. You can confirm it before joining.', + ); await user.clear(alias); await user.type(alias, 'Event alias'); await user.type(screen.getByLabelText(/Ticket code/), CODE); From fc112f4556eadabdc7dc82de4cc7c48f1d61635c Mon Sep 17 00:00:00 2001 From: AnnieScigliano Date: Fri, 4 Sep 2026 23:25:01 -0300 Subject: [PATCH 5/6] fix(session): ignore stale entry polls --- src/app/session/[id]/__tests__/page.test.tsx | 57 ++++++++++++++++++++ src/app/session/[id]/page.tsx | 30 ++++++++--- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/app/session/[id]/__tests__/page.test.tsx b/src/app/session/[id]/__tests__/page.test.tsx index 8a03b202..4f806311 100644 --- a/src/app/session/[id]/__tests__/page.test.tsx +++ b/src/app/session/[id]/__tests__/page.test.tsx @@ -443,6 +443,63 @@ describe('SessionRoomPage - event entry', () => { ).toHaveLength(roomsBefore + 1)); expect(await screen.findByTestId('viewer-identity')).toHaveTextContent('Anahí 李'); }); + + it('ignores an entry poll that started before the attendee confirmed their name', async () => { + const roomsBefore = (Room as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + const unconfirmed = { + ...ENTRY_RESPONSE, + identity: { kind: 'attendee', displayName: 'Participante', confirmed: false }, + }; + let entryGets = 0; + let releaseStalePoll: (value: typeof unconfirmed) => void = () => {}; + const stalePoll = new Promise((resolve) => { + releaseStalePoll = resolve; + }); + vi.mocked(global.fetch).mockImplementation((url: string | URL | Request, init?: RequestInit) => { + if (String(url).includes('/entry') && init?.method === 'PATCH') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ displayName: 'Anahí 李', confirmed: true }), + } as Response); + } + if (String(url).includes('/entry')) { + entryGets += 1; + return Promise.resolve({ + ok: true, + json: async () => entryGets === 1 ? unconfirmed : stalePoll, + } as Response); + } + if (String(url).includes('/token')) { + return Promise.resolve({ + ok: true, + json: async () => ({ ...TOKEN_RESPONSE, displayName: 'Anahí 李' }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }); + + renderPage('es'); + const input = await screen.findByRole('textbox', { name: /Tu nombre visible|Your visible name/i }); + fireEvent.focus(window); + await waitFor(() => expect(entryGets).toBe(2)); + + fireEvent.change(input, { target: { value: 'Anahí 李' } }); + fireEvent.click(screen.getByRole('button', { name: /Confirmar y continuar|Confirm and continue/i })); + await waitFor(() => expect( + (Room as unknown as { mock: { calls: unknown[] } }).mock.calls, + ).toHaveLength(roomsBefore + 1)); + const connectedRoom = currentRoom(); + + await act(async () => { + releaseStalePoll(unconfirmed); + await stalePoll; + }); + + expect(screen.queryByRole('textbox', { name: /Tu nombre visible|Your visible name/i })).toBeNull(); + expect(await screen.findByTestId('viewer-identity')).toHaveTextContent('Anahí 李'); + expect(connectedRoom.disconnect).not.toHaveBeenCalled(); + }); }); describe('SessionRoomPage - participant identity', () => { diff --git a/src/app/session/[id]/page.tsx b/src/app/session/[id]/page.tsx index 2dbb9c2f..6c0b9d2c 100644 --- a/src/app/session/[id]/page.tsx +++ b/src/app/session/[id]/page.tsx @@ -1476,11 +1476,14 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) { const [entry, setEntry] = useState(null); const [entryError, setEntryError] = useState(null); const [retryEntry, setRetryEntry] = useState(0); + const entryRequestGenerationRef = useRef(0); + const entryAbortRef = useRef(null); useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; let inFlight = false; + const effectGeneration = ++entryRequestGenerationRef.current; const checkEntry = async () => { if (cancelled || inFlight) return; @@ -1489,25 +1492,30 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) { timer = null; } inFlight = true; + const requestGeneration = entryRequestGenerationRef.current; + const controller = new AbortController(); + entryAbortRef.current = controller; try { const response = await fetch(`/api/scheduled-sessions/${sessionId}/entry`, { cache: 'no-store', + signal: controller.signal, }); const data = await response.json().catch(() => ({})) as Partial & { error?: string }; if (!response.ok || !data.state || !data.session) { throw new Error(data.error || `Entry status unavailable (HTTP ${response.status})`); } - if (!cancelled) { + if (!cancelled && requestGeneration === entryRequestGenerationRef.current) { seedLocale(localeForEventLanguage(data.session.language)); setEntry(data as EntryResponse); setEntryError(null); } } catch (failure) { - if (!cancelled) { + if (!cancelled && requestGeneration === entryRequestGenerationRef.current) { console.error('Failed to confirm event entry:', redactErrorDetail(failure)); setEntryError(copy.session.entryUnavailable); } } finally { + if (entryAbortRef.current === controller) entryAbortRef.current = null; inFlight = false; if (!cancelled) timer = setTimeout(checkEntry, ENTRY_POLL_MS); } @@ -1522,6 +1530,11 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) { document.addEventListener('visibilitychange', checkWhenVisible); return () => { cancelled = true; + if (entryRequestGenerationRef.current === effectGeneration) { + entryRequestGenerationRef.current += 1; + } + entryAbortRef.current?.abort(); + entryAbortRef.current = null; if (timer) clearTimeout(timer); window.removeEventListener('focus', checkWhenVisible); window.removeEventListener('online', checkWhenVisible); @@ -1560,10 +1573,15 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) { sessionId={sessionId} sessionTitle={entry.session.title} initialDisplayName={entry.identity.displayName} - onConfirmed={(displayName) => setEntry((current) => current ? { - ...current, - identity: { kind: 'attendee', displayName, confirmed: true }, - } : current)} + onConfirmed={(displayName) => { + entryRequestGenerationRef.current += 1; + entryAbortRef.current?.abort(); + entryAbortRef.current = null; + setEntry((current) => current ? { + ...current, + identity: { kind: 'attendee', displayName, confirmed: true }, + } : current); + }} /> ); } From b46bd1f1e59ffb5c39430a58a62733320694d89b Mon Sep 17 00:00:00 2001 From: AnnieScigliano Date: Fri, 4 Sep 2026 23:25:10 -0300 Subject: [PATCH 6/6] fix(stage): converge LiveKit grants by version --- src/lib/__tests__/stage-control.test.ts | 178 ++++++++++- src/lib/stage-control.ts | 393 +++++++++++++++++------- 2 files changed, 460 insertions(+), 111 deletions(-) diff --git a/src/lib/__tests__/stage-control.test.ts b/src/lib/__tests__/stage-control.test.ts index 76acf43e..73cb1f8d 100644 --- a/src/lib/__tests__/stage-control.test.ts +++ b/src/lib/__tests__/stage-control.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ participantFindMany: vi.fn(), participantFindFirst: vi.fn(), participantUpdate: vi.fn(), + participantUpdateMany: vi.fn(), auditCreate: vi.fn(), queryRaw: vi.fn(), updateParticipant: vi.fn(), @@ -22,6 +23,7 @@ vi.mock('@/lib/db', () => ({ sessionParticipant: { findFirst: mocks.participantFindFirst, update: mocks.participantUpdate, + updateMany: mocks.participantUpdateMany, }, auditLog: { create: mocks.auditCreate }, }, @@ -57,6 +59,16 @@ const event = { let participants: Participant[]; let transactionTail: Promise; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function attendee( id: string, active = false, @@ -118,6 +130,7 @@ describe('stage control', () => { findMany: mocks.participantFindMany, findFirst: mocks.participantFindFirst, update: mocks.participantUpdate, + updateMany: mocks.participantUpdateMany, }, auditLog: { create: mocks.auditCreate }, }; @@ -137,8 +150,7 @@ describe('stage control', () => { participants: participants.map((participant) => ({ id: participant.id, participantIdentity: participant.participantIdentity, - publishGrantedAt: participant.publishGrantedAt, - publishRevokedAt: participant.publishRevokedAt, + grantVersion: participant.grantVersion, })), }; } @@ -168,6 +180,22 @@ describe('stage control', () => { data: Record; }) => applyUpdate(where.id, data), ); + mocks.participantUpdateMany.mockImplementation( + ({ where, data }: { + where: { id: string; grantVersion?: number }; + data: Record; + }) => { + const participant = participants.find((item) => item.id === where.id); + if (!participant || ( + where.grantVersion !== undefined && + participant.grantVersion !== where.grantVersion + )) { + return { count: 0 }; + } + applyUpdate(where.id, data); + return { count: 1 }; + }, + ); mocks.auditCreate.mockResolvedValue({}); mocks.updateParticipant.mockResolvedValue({}); mocks.getParticipant.mockResolvedValue({ @@ -300,11 +328,11 @@ describe('stage control', () => { })).rejects.toMatchObject({ code: 'livekit_failed', status: 502, - details: { reconcileNeeded: true }, + details: { reconcileNeeded: false }, }); expect(participants[0]).toMatchObject({ - grantReconcileNeeded: true, + grantReconcileNeeded: false, }); expect(participants[0].publishRevokedAt).not.toBeNull(); expect(mocks.updateParticipant).toHaveBeenLastCalledWith( @@ -317,6 +345,148 @@ describe('stage control', () => { expect(mocks.mutePublishedTrack).toHaveBeenCalledTimes(2); }); + it('reapplies a newer demotion after an older promotion effect finishes last', async () => { + participants = [attendee('target')]; + const oldEffect = deferred(); + const applied: boolean[] = []; + mocks.updateParticipant + .mockImplementationOnce(async ( + _room: string, + _identity: string, + update: { permission: { canPublish: boolean } }, + ) => { + await oldEffect.promise; + applied.push(update.permission.canPublish); + }) + .mockImplementation(async ( + _room: string, + _identity: string, + update: { permission: { canPublish: boolean } }, + ) => { + applied.push(update.permission.canPublish); + }); + const { demoteParticipant, promoteParticipant } = await import('../stage-control'); + + const promotion = promoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-1', + }); + await vi.waitFor(() => expect(mocks.updateParticipant).toHaveBeenCalledTimes(1)); + expect(participants[0]).toMatchObject({ + grantVersion: 1, + grantReconcileNeeded: true, + }); + + const demotion = await demoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-1', + }); + expect(demotion).toMatchObject({ canPublish: false, grantVersion: 2 }); + expect(applied).toEqual([false]); + + oldEffect.resolve(); + const stalePromotion = await promotion; + + expect(stalePromotion).toMatchObject({ canPublish: false, grantVersion: 2 }); + expect(applied).toEqual([false, true, false]); + expect(participants[0]).toMatchObject({ + publishRevokedAt: expect.any(Date), + grantReconcileNeeded: false, + grantVersion: 2, + }); + }); + + it('reapplies a newer promotion after an older demotion effect finishes last', async () => { + participants = [attendee('target', true)]; + const oldEffect = deferred(); + const applied: boolean[] = []; + mocks.updateParticipant + .mockImplementationOnce(async ( + _room: string, + _identity: string, + update: { permission: { canPublish: boolean } }, + ) => { + await oldEffect.promise; + applied.push(update.permission.canPublish); + }) + .mockImplementation(async ( + _room: string, + _identity: string, + update: { permission: { canPublish: boolean } }, + ) => { + applied.push(update.permission.canPublish); + }); + const { demoteParticipant, promoteParticipant } = await import('../stage-control'); + + const demotion = demoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-1', + }); + await vi.waitFor(() => expect(mocks.updateParticipant).toHaveBeenCalledTimes(1)); + expect(participants[0]).toMatchObject({ + grantVersion: 2, + grantReconcileNeeded: true, + }); + + const promotion = await promoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-1', + }); + expect(promotion).toMatchObject({ canPublish: true, grantVersion: 3 }); + expect(applied).toEqual([true]); + + oldEffect.resolve(); + const staleDemotion = await demotion; + + expect(staleDemotion).toMatchObject({ canPublish: true, grantVersion: 3 }); + expect(applied).toEqual([true, false, true]); + expect(participants[0]).toMatchObject({ + publishRevokedAt: null, + grantReconcileNeeded: false, + grantVersion: 3, + }); + }); + + it('does not let an obsolete promotion failure compensate a newer promotion', async () => { + participants = [attendee('target')]; + const oldEffect = deferred(); + mocks.updateParticipant + .mockImplementationOnce(async () => oldEffect.promise) + .mockResolvedValue({}); + const { promoteParticipant } = await import('../stage-control'); + + const oldPromotion = promoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-1', + }); + await vi.waitFor(() => expect(mocks.updateParticipant).toHaveBeenCalledTimes(1)); + + const currentPromotion = await promoteParticipant({ + scheduledSessionId: event.id, + participantId: 'target', + actorUserId: 'operator-2', + }); + expect(currentPromotion).toMatchObject({ canPublish: true, grantVersion: 2 }); + + oldEffect.reject(new Error('obsolete LiveKit failure')); + const stalePromotion = await oldPromotion; + + expect(stalePromotion).toMatchObject({ canPublish: true, grantVersion: 2 }); + expect(participants[0]).toMatchObject({ + publishRevokedAt: null, + grantReconcileNeeded: false, + grantVersion: 2, + }); + expect(mocks.auditCreate).not.toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'stage.promote.livekit_failed' }), + }); + }); + it('demotes before revoking LiveKit permission and force-mutes every track', async () => { participants = [attendee('target', true)]; const { demoteParticipant } = await import('../stage-control'); diff --git a/src/lib/stage-control.ts b/src/lib/stage-control.ts index 055f6529..a5e63725 100644 --- a/src/lib/stage-control.ts +++ b/src/lib/stage-control.ts @@ -23,8 +23,6 @@ const SUBSCRIBE_PERMISSION = { canPublishSources: [] as TrackSource[], }; -type StageAction = 'promote' | 'demote' | 'mute' | 'reconcile'; - type ParticipantSnapshot = { id: string; participantIdentity: string; @@ -33,6 +31,26 @@ type ParticipantSnapshot = { raisedAt: Date | null; }; +type GrantEffectSnapshot = { + id: string; + participantIdentity: string; + roomName: string; + publishGrantedAt: Date | null; + publishRevokedAt: Date | null; + grantVersion: number; +}; + +const MAX_GRANT_SYNC_PASSES = 16; + +class GrantEffectApplyError extends Error { + constructor( + public readonly snapshot: GrantEffectSnapshot, + ) { + super('LiveKit grant effect failed for the current durable version'); + this.name = 'GrantEffectApplyError'; + } +} + export class StageControlError extends Error { constructor( public readonly code: @@ -252,43 +270,208 @@ async function enforceSubscriber( await forceMuteParticipant(roomName, participantIdentity); } -async function markReconcileNeeded( +async function readGrantEffectSnapshot( + scheduledSessionId: string, + participantId: string, +): Promise { + const participant = await prisma.sessionParticipant.findFirst({ + where: { id: participantId, scheduledSessionId }, + select: { + id: true, + participantIdentity: true, + publishGrantedAt: true, + publishRevokedAt: true, + grantVersion: true, + scheduledSession: { select: { roomName: true } }, + }, + }); + if (!participant) { + throw new StageControlError( + 'participant_not_found', + 404, + 'Participant not found', + ); + } + return { + id: participant.id, + participantIdentity: participant.participantIdentity, + roomName: participant.scheduledSession.roomName, + publishGrantedAt: participant.publishGrantedAt, + publishRevokedAt: participant.publishRevokedAt, + grantVersion: participant.grantVersion, + }; +} + +function grantResult( + snapshot: GrantEffectSnapshot, + reconcileNeeded: boolean, +): StageGrantResult { + return { + participantId: snapshot.id, + participantIdentity: snapshot.participantIdentity, + canPublish: hasActiveGrant({ ...snapshot, raisedAt: null }), + reconcileNeeded, + grantVersion: snapshot.grantVersion, + }; +} + +/** + * Apply the newest durable grant version and clear its pending marker only + * with a compare-by-version update. If a newer transition commits while an + * older LiveKit request is in flight, the older caller loops and reapplies the + * new state so an obsolete effect can never be the final effect. + */ +async function synchronizeLatestGrantEffect( scheduledSessionId: string, participantId: string, - actorUserId: string, - action: StageAction, - now: Date, ): Promise { + let lastSnapshot: GrantEffectSnapshot | null = null; + for (let pass = 0; pass < MAX_GRANT_SYNC_PASSES; pass += 1) { + const snapshot = await readGrantEffectSnapshot(scheduledSessionId, participantId); + lastSnapshot = snapshot; + const canPublish = hasActiveGrant({ ...snapshot, raisedAt: null }); + try { + if (canPublish) { + await setLiveKitPermission( + snapshot.roomName, + snapshot.participantIdentity, + true, + ); + } else { + await enforceSubscriber( + snapshot.roomName, + snapshot.participantIdentity, + ); + } + } catch { + const latest = await readGrantEffectSnapshot(scheduledSessionId, participantId); + if (latest.grantVersion !== snapshot.grantVersion) continue; + throw new GrantEffectApplyError(snapshot); + } + + const cleared = await prisma.sessionParticipant.updateMany({ + where: { + id: snapshot.id, + scheduledSessionId, + grantVersion: snapshot.grantVersion, + }, + data: { grantReconcileNeeded: false }, + }); + if (cleared.count === 1) return grantResult(snapshot, false); + } + + throw new GrantEffectApplyError(lastSnapshot ?? + await readGrantEffectSnapshot(scheduledSessionId, participantId)); +} + +async function clearDisconnectedGrantMarker( + scheduledSessionId: string, + participantId: string, +): Promise { + for (let pass = 0; pass < MAX_GRANT_SYNC_PASSES; pass += 1) { + const snapshot = await readGrantEffectSnapshot(scheduledSessionId, participantId); + const cleared = await prisma.sessionParticipant.updateMany({ + where: { + id: snapshot.id, + scheduledSessionId, + grantVersion: snapshot.grantVersion, + }, + data: { grantReconcileNeeded: false }, + }); + if (cleared.count === 1) return true; + } + return false; +} + +async function recordGrantEffectFailureIfCurrent(input: { + scheduledSessionId: string; + participantId: string; + expectedVersion: number; + actorUserId: string | null; + action: string; + reason: string; +}): Promise { return prisma.$transaction(async (transaction) => { - await lockScheduledSession(transaction, scheduledSessionId); - const participant = await transaction.sessionParticipant.update({ - where: { id: participantId }, - data: { - publishRevokedAt: now, - grantReconcileNeeded: true, - grantVersion: { increment: 1 }, + await lockScheduledSession(transaction, input.scheduledSessionId); + const participant = await transaction.sessionParticipant.findFirst({ + where: { + id: input.participantId, + scheduledSessionId: input.scheduledSessionId, + }, + select: { grantVersion: true }, + }); + if (!participant || participant.grantVersion !== input.expectedVersion) return false; + await transaction.sessionParticipant.update({ + where: { id: input.participantId }, + data: { grantReconcileNeeded: true }, + }); + await audit( + transaction, + input.actorUserId, + `${input.action}.livekit_failed`, + input.participantId, + input.reason, + { grantVersion: input.expectedVersion, reconcileNeeded: true }, + ); + return true; + }); +} + +async function compensatePromotionFailureIfCurrent(input: { + scheduledSessionId: string; + participantId: string; + expectedVersion: number; + actorUserId: string; + now: Date; +}): Promise { + return prisma.$transaction(async (transaction) => { + await lockScheduledSession(transaction, input.scheduledSessionId); + const current = await transaction.sessionParticipant.findFirst({ + where: { + id: input.participantId, + scheduledSessionId: input.scheduledSessionId, }, select: { id: true, - participantIdentity: true, + publishGrantedAt: true, + publishRevokedAt: true, grantVersion: true, }, }); + if ( + !current || + current.grantVersion !== input.expectedVersion || + !hasActiveGrant({ + ...current, + participantIdentity: '', + raisedAt: null, + }) + ) { + return false; + } + + const compensated = await transaction.sessionParticipant.update({ + where: { id: current.id }, + data: { + publishRevokedAt: input.now, + grantReconcileNeeded: true, + grantVersion: { increment: 1 }, + }, + select: { grantVersion: true }, + }); await audit( transaction, - actorUserId, - `stage.${action}.livekit_failed`, - participantId, - 'LiveKit update failed; durable grant revoked', - { reconcileNeeded: true }, + input.actorUserId, + 'stage.promote.livekit_failed', + current.id, + 'LiveKit promotion failed; durable grant revoked', + { + failedGrantVersion: input.expectedVersion, + grantVersion: compensated.grantVersion, + reconcileNeeded: true, + }, ); - return { - participantId: participant.id, - participantIdentity: participant.participantIdentity, - canPublish: false, - reconcileNeeded: true, - grantVersion: participant.grantVersion, - }; + return true; }); } @@ -371,7 +554,7 @@ export async function promoteParticipant( ? target.publishGrantedAt : now, publishRevokedAt: null, - grantReconcileNeeded: false, + grantReconcileNeeded: true, grantChangedByUserId: input.actorUserId, grantReason: input.reason?.trim() || 'Promoted to stage', grantVersion: { increment: 1 }, @@ -397,40 +580,46 @@ export async function promoteParticipant( }); try { - await setLiveKitPermission( - reservation.roomName, - reservation.participantIdentity, - true, - ); - return { - participantId: reservation.id, - participantIdentity: reservation.participantIdentity, - canPublish: true, - reconcileNeeded: false, - grantVersion: reservation.grantVersion, - }; - } catch { - const compensated = await markReconcileNeeded( + return await synchronizeLatestGrantEffect( input.scheduledSessionId, input.participantId, - input.actorUserId, - 'promote', - now, ); + } catch (failure) { + if (!(failure instanceof GrantEffectApplyError)) throw failure; + const compensated = await compensatePromotionFailureIfCurrent({ + scheduledSessionId: input.scheduledSessionId, + participantId: input.participantId, + expectedVersion: reservation.grantVersion, + actorUserId: input.actorUserId, + now, + }); + + if (!compensated) { + // A newer transition superseded the failed effect before its + // compensation could commit. Converge to that version and report + // the current truth instead of revoking it from this stale caller. + return synchronizeLatestGrantEffect( + input.scheduledSessionId, + input.participantId, + ); + } + + let reconcileNeeded = true; try { - await enforceSubscriber( - reservation.roomName, - reservation.participantIdentity, + await synchronizeLatestGrantEffect( + input.scheduledSessionId, + input.participantId, ); + reconcileNeeded = false; } catch { - // The durable state is already safe. Reconcile retries both the - // negative permission update and forced track mute. + // The compensated durable state is subscriber-only and remains + // marked until a later reconciliation can enforce it in LiveKit. } throw new StageControlError( 'livekit_failed', 502, 'LiveKit promotion failed', - { reconcileNeeded: compensated.reconcileNeeded }, + { reconcileNeeded }, ); } } @@ -496,7 +685,7 @@ export async function demoteParticipant( data: { ...(activeGrant ? { publishRevokedAt: now } : {}), ...(input.clearHand ? { raisedAt: null } : {}), - grantReconcileNeeded: false, + grantReconcileNeeded: true, grantChangedByUserId: input.actorUserId, grantReason: input.reason?.trim() || 'Demoted from stage', ...(activeGrant ? { grantVersion: { increment: 1 } } : {}), @@ -522,33 +711,26 @@ export async function demoteParticipant( }); try { - await enforceSubscriber( - revocation.roomName, - revocation.participantIdentity, + return await synchronizeLatestGrantEffect( + input.scheduledSessionId, + input.participantId, ); - return { - participantId: revocation.id, - participantIdentity: revocation.participantIdentity, - canPublish: false, - reconcileNeeded: false, - grantVersion: revocation.grantVersion, - }; - } catch { - await prisma.$transaction(async (transaction) => { - await lockScheduledSession(transaction, input.scheduledSessionId); - await transaction.sessionParticipant.update({ - where: { id: input.participantId }, - data: { grantReconcileNeeded: true }, - }); - await audit( - transaction, - input.actorUserId, - `${input.auditAction ?? 'stage.demote'}.livekit_failed`, + } catch (failure) { + if (!(failure instanceof GrantEffectApplyError)) throw failure; + const failureIsCurrent = await recordGrantEffectFailureIfCurrent({ + scheduledSessionId: input.scheduledSessionId, + participantId: input.participantId, + expectedVersion: failure.snapshot.grantVersion, + actorUserId: input.actorUserId, + action: input.auditAction ?? 'stage.demote', + reason: 'LiveKit demotion or forced mute failed', + }); + if (!failureIsCurrent) { + return synchronizeLatestGrantEffect( + input.scheduledSessionId, input.participantId, - 'LiveKit demotion or forced mute failed', - { reconcileNeeded: true }, ); - }); + } throw new StageControlError( 'livekit_failed', 502, @@ -711,8 +893,7 @@ export async function reconcileParticipants(input: { select: { id: true, participantIdentity: true, - publishGrantedAt: true, - publishRevokedAt: true, + grantVersion: true, }, }, }, @@ -748,8 +929,12 @@ export async function reconcileParticipants(input: { } for (const participant of scheduledSession.participants) { if (failed.includes(participant.id)) { - await prisma.sessionParticipant.update({ - where: { id: participant.id }, + await prisma.sessionParticipant.updateMany({ + where: { + id: participant.id, + scheduledSessionId: input.scheduledSessionId, + grantVersion: participant.grantVersion, + }, data: { grantReconcileNeeded: true }, }); continue; @@ -757,38 +942,32 @@ export async function reconcileParticipants(input: { // A disconnected identity has no live permission or tracks to disagree // with the database. Its next token is minted from the durable grant. if (!connectedIdentities.has(participant.participantIdentity)) { - await prisma.sessionParticipant.update({ - where: { id: participant.id }, - data: { grantReconcileNeeded: false }, - }); - reconciled.push(participant.id); + if (await clearDisconnectedGrantMarker( + input.scheduledSessionId, + participant.id, + )) { + reconciled.push(participant.id); + } else { + failed.push(participant.id); + } continue; } - const canPublish = hasActiveGrant({ - ...participant, - raisedAt: null, - }); try { - if (canPublish) { - await setLiveKitPermission( - scheduledSession.roomName, - participant.participantIdentity, - true, - ); - } else { - await enforceSubscriber( - scheduledSession.roomName, - participant.participantIdentity, - ); - } - await prisma.sessionParticipant.update({ - where: { id: participant.id }, - data: { grantReconcileNeeded: false }, - }); + await synchronizeLatestGrantEffect( + input.scheduledSessionId, + participant.id, + ); reconciled.push(participant.id); - } catch { - await prisma.sessionParticipant.update({ - where: { id: participant.id }, + } catch (failure) { + const snapshot = failure instanceof GrantEffectApplyError + ? failure.snapshot + : await readGrantEffectSnapshot(input.scheduledSessionId, participant.id); + await prisma.sessionParticipant.updateMany({ + where: { + id: participant.id, + scheduledSessionId: input.scheduledSessionId, + grantVersion: snapshot.grantVersion, + }, data: { grantReconcileNeeded: true }, }); failed.push(participant.id);