diff --git a/src/room-routes.ts b/src/room-routes.ts index 07cd7dd0..ba479e03 100644 --- a/src/room-routes.ts +++ b/src/room-routes.ts @@ -17,10 +17,16 @@ * REFLECTT_HOST_HEARTBEAT_TOKEN is set, requests must present it via * `Authorization: Bearer`, `x-heartbeat-token` header, or `?token=` query. * If unset, the route is open (matches existing host-cred behavior). + * + * `GET /room/participants` is broader by contract. Per ROOM_MODEL_V0 it is + * a normal room-output read, so the cloud/browser path must be able to use a + * scoped user JWT instead of the host heartbeat secret. We therefore accept + * either the heartbeat token or a valid Supabase-backed bearer token here. */ import { existsSync, readFileSync, unlinkSync } from 'node:fs' import type { FastifyInstance, FastifyRequest } from 'fastify' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' import { eventBus } from './events.js' import { listRoomParticipants, getRoomPresenceStatus } from './room-presence-store.js' import { getRecentTranscript, getRoomTranscriptStatus } from './room-transcript-store.js' @@ -42,25 +48,73 @@ const IMAGE_ARTIFACT_RETENTION_MAX = 20 const IMAGE_ARTIFACT_KINDS = ['snapshot', 'camera-snapshot'] as const const ALLOWED_KINDS_V0 = new Set(IMAGE_ARTIFACT_KINDS) -function verifyAuth(request: FastifyRequest): { ok: boolean; error?: string } { - const expectedToken = process.env.REFLECTT_HOST_HEARTBEAT_TOKEN - if (!expectedToken) return { ok: true } +let roomReadAuthClient: SupabaseClient | null | undefined +function getBearerToken(request: FastifyRequest): string | null { const headers = request.headers as Record const authHeader = (headers.authorization || headers.Authorization) as string | undefined if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { - const provided = authHeader.slice('Bearer '.length).trim() - if (provided === expectedToken) return { ok: true } + const token = authHeader.slice('Bearer '.length).trim() + return token.length > 0 ? token : null } - const headerToken = headers['x-heartbeat-token'] - if (typeof headerToken === 'string' && headerToken === expectedToken) return { ok: true } + return null +} + +function verifyAuth(request: FastifyRequest): { ok: boolean; error?: string } { + const heartbeatToken = process.env.REFLECTT_HOST_HEARTBEAT_TOKEN + if (!heartbeatToken) return { ok: true } + const bearer = getBearerToken(request) + if (bearer === heartbeatToken) return { ok: true } + + const headers = request.headers as Record + const headerToken = headers['x-heartbeat-token'] + if (typeof headerToken === 'string' && headerToken === heartbeatToken) return { ok: true } const query = request.query as Record - if (typeof query?.token === 'string' && query.token === expectedToken) return { ok: true } + if (typeof query?.token === 'string' && query.token === heartbeatToken) return { ok: true } return { ok: false, error: 'Unauthorized: REFLECTT_HOST_HEARTBEAT_TOKEN required' } } +function getRoomReadAuthClient(): SupabaseClient | null { + if (roomReadAuthClient !== undefined) return roomReadAuthClient + + const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL + const key = process.env.SUPABASE_SERVICE_ROLE_KEY + || process.env.SUPABASE_ACCESS_TOKEN + || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + + if (!url || !key) { + roomReadAuthClient = null + return null + } + + roomReadAuthClient = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }) + return roomReadAuthClient +} + +async function verifyParticipantsReadAuth(request: FastifyRequest): Promise<{ ok: boolean; error?: string }> { + const heartbeat = verifyAuth(request) + if (heartbeat.ok) return heartbeat + + const bearer = getBearerToken(request) + if (!bearer) return { ok: false, error: 'Unauthorized: valid room-read JWT or REFLECTT_HOST_HEARTBEAT_TOKEN required' } + + const client = getRoomReadAuthClient() + if (!client) return { ok: false, error: 'Unauthorized: valid room-read JWT or REFLECTT_HOST_HEARTBEAT_TOKEN required' } + + try { + const { data, error } = await client.auth.getUser(bearer) + if (!error && data.user) return { ok: true } + } catch { + // fall through + } + + return { ok: false, error: 'Unauthorized: valid room-read JWT or REFLECTT_HOST_HEARTBEAT_TOKEN required' } +} + function resolveHostId(): string { return process.env.REFLECTT_HOST_ID || process.env.HOSTNAME || 'unknown' } @@ -98,7 +152,7 @@ function projectArtifact(art: Artifact): Record { export async function roomRoutes(app: FastifyInstance) { app.get('/room/participants', async (request, reply) => { - const auth = verifyAuth(request) + const auth = await verifyParticipantsReadAuth(request) if (!auth.ok) { reply.status(401) return { error: auth.error }