Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions src/room-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string>(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<string, string | string[] | undefined>
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<string, string | string[] | undefined>
const headerToken = headers['x-heartbeat-token']
if (typeof headerToken === 'string' && headerToken === heartbeatToken) return { ok: true }
const query = request.query as Record<string, unknown>
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'
}
Expand Down Expand Up @@ -98,7 +152,7 @@ function projectArtifact(art: Artifact): Record<string, unknown> {

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 }
Expand Down
Loading