diff --git a/client/src/App.jsx b/client/src/App.jsx index ea069be4b3..ba8ddbbed6 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -117,6 +117,7 @@ const Sharing = lazyWithReload(() => import('./pages/Sharing')); const Importer = lazyWithReload(() => import('./pages/Importer')); const FableLoom = lazyWithReload(() => import('./pages/FableLoom')); const FableLoomStory = lazyWithReload(() => import('./pages/FableLoomStory')); +const FableLoomHostedJoin = lazyWithReload(() => import('./pages/FableLoomHostedJoin')); const StartStory = lazyWithReload(() => import('./pages/StartStory')); const StoryBuilder = lazyWithReload(() => import('./pages/StoryBuilder')); const PipelineSeries = lazyWithReload(() => import('./pages/PipelineSeries')); @@ -523,6 +524,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/fableloom/LoomHostedSessionModal.jsx b/client/src/components/fableloom/LoomHostedSessionModal.jsx new file mode 100644 index 0000000000..dcb8af5f7f --- /dev/null +++ b/client/src/components/fableloom/LoomHostedSessionModal.jsx @@ -0,0 +1,301 @@ +/** + * Scoped QR-Hosted Session Modal (#5383). + * + * Provides: + * 1. HTTPS & subsystem readiness preflight verification. + * 2. Scoped high-entropy QR code and fragment join link. + * 3. Audio output target selection (Host computer speakers vs Audience phone). + * 4. Realtime audience connection status. + */ + +import { useEffect, useState } from 'react'; +import { + AlertCircle, + CheckCircle2, + Copy, + ExternalLink, + Loader2, + Mic, + QrCode, + Radio, + Smartphone, + Speaker, + Volume2, + X, +} from 'lucide-react'; +import toast from '../ui/Toast'; +import { copyToClipboard } from '../../lib/clipboard'; +import { generateQrCodeSvg } from '../../lib/qrCode'; +import { + createHostedLoomSession, + endHostedLoomSession, + preflightHostedLoomSession, + updateHostedLoomSession, +} from '../../services/api'; + +export default function LoomHostedSessionModal({ + loom, + episode, + isOpen, + onClose, + activeSession, + onSessionCreated, + onSessionEnded, + hasAudienceConnected = false, +}) { + const [loading, setLoading] = useState(false); + const [preflight, setPreflight] = useState(null); + const [preflightLoading, setPreflightLoading] = useState(false); + const [joinData, setJoinData] = useState(null); // { session, token, joinUrl } + const [audioTarget, setAudioTarget] = useState('host'); + + useEffect(() => { + if (!isOpen || !loom?.id || !episode?.id) return; + let canceled = false; + setPreflightLoading(true); + + preflightHostedLoomSession(loom.id, episode.id) + .then((data) => { + if (!canceled) { + setPreflight(data); + setPreflightLoading(false); + } + }) + .catch((err) => { + if (!canceled) { + toast.error(`Preflight check failed: ${err.message}`); + setPreflightLoading(false); + } + }); + + return () => { canceled = true; }; + }, [isOpen, loom?.id, episode?.id]); + + if (!isOpen) return null; + + const currentJoinUrl = joinData?.joinUrl || activeSession?.joinUrl || null; + const isSessionActive = Boolean(activeSession || joinData?.session); + const currentSessionId = activeSession?.id || joinData?.session?.id; + + const handleStartSession = async () => { + try { + setLoading(true); + const res = await createHostedLoomSession(loom.id, episode.id, { audioTarget }); + setJoinData(res); + if (onSessionCreated) onSessionCreated(res.session, res); + toast.success('Hosted play session created! Scan the QR code with your mobile device.'); + } catch (err) { + toast.error(`Failed to create session: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const handleEndSession = async () => { + if (!currentSessionId) return; + try { + setLoading(true); + await endHostedLoomSession(currentSessionId); + setJoinData(null); + if (onSessionEnded) onSessionEnded(); + toast.info('Hosted session ended.'); + } catch (err) { + toast.error(`Failed to end session: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const handleAudioTargetChange = async (target) => { + setAudioTarget(target); + if (currentSessionId) { + try { + await updateHostedLoomSession(currentSessionId, { audioTarget: target }); + } catch (err) { + console.warn('Failed to update audio target:', err); + } + } + }; + + const handleCopyLink = async () => { + if (!currentJoinUrl) return; + const ok = await copyToClipboard(currentJoinUrl); + if (ok) toast.success('Join link copied to clipboard!'); + }; + + return ( +
+
+ {/* Header */} +
+
+ +

Hosted Two-Device Play

+
+ +
+ + {/* Body */} +
+ {/* Readiness Preflight Checklist */} + {preflightLoading ? ( +
+ + Checking system and HTTPS readiness… +
+ ) : preflight ? ( +
+
+ Readiness Preflight + + {preflight.ready ? 'Ready for Hosted Play' : 'Action Required'} + +
+ +
+
+ {preflight.checks.https.ok ? : } + HTTPS: {preflight.checks.https.ok ? 'Active (TLS)' : 'Not Enabled'} +
+
+ {preflight.checks.host.ok ? : } + Story Graph: {preflight.checks.host.ok ? 'Ready' : 'Missing Start'} +
+
+ {preflight.checks.tts.ok ? : } + Voice: {preflight.checks.tts.voice || 'Ready'} +
+
+ {preflight.checks.playback.ok ? : } + Hold Safety: {preflight.checks.playback.ok ? 'Safe' : 'Review'} +
+
+ + {preflight.errors.length > 0 && ( +
+ {preflight.errors.map((err, i) => ( +
• {err}
+ ))} +
+ )} +
+ ) : null} + + {/* Active Session QR Display */} + {isSessionActive && currentJoinUrl ? ( +
+
+ +
+ + + {hasAudienceConnected ? 'Audience Mobile Device Connected!' : 'Waiting for phone to scan QR code…'} + +
+ + {/* Join Link Copy */} +
+ + +
+ + {/* Audio Target Selector */} +
+ +
+ + +
+
+
+ ) : ( +
+
+ +
+
+

Interactive Two-Device Play

+

+ Play the story video on this screen while using your phone as the microphone to speak with the protagonist. +

+
+
+ )} +
+ + {/* Footer */} +
+ + + {isSessionActive ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/fableloom/LoomHostedSessionModal.test.jsx b/client/src/components/fableloom/LoomHostedSessionModal.test.jsx new file mode 100644 index 0000000000..49b8777e9a --- /dev/null +++ b/client/src/components/fableloom/LoomHostedSessionModal.test.jsx @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import LoomHostedSessionModal from './LoomHostedSessionModal'; +import * as api from '../../services/api'; + +vi.mock('../../services/api', () => ({ + preflightHostedLoomSession: vi.fn(), + createHostedLoomSession: vi.fn(), + endHostedLoomSession: vi.fn(), + updateHostedLoomSession: vi.fn(), +})); + +describe('LoomHostedSessionModal', () => { + const mockLoom = { id: 'loom-1', name: 'Story 1' }; + const mockEpisode = { id: 'ep-1', title: 'Episode 1' }; + + beforeEach(() => { + vi.clearAllMocks(); + api.preflightHostedLoomSession.mockResolvedValue({ + ready: true, + checks: { + https: { ok: true }, + host: { ok: true }, + tts: { ok: true, voice: 'default' }, + playback: { ok: true }, + }, + errors: [], + }); + }); + + it('renders preflight check and allows starting session when ready', async () => { + render( + + ); + + expect(screen.getByText('Hosted Two-Device Play')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Ready for Hosted Play')).toBeInTheDocument(); + }); + + const startBtn = screen.getByRole('button', { name: /Start Hosted Session/i }); + expect(startBtn).not.toBeDisabled(); + }); + + it('displays QR code and audio target options when session is active', async () => { + const activeSession = { + id: 'sess-123', + status: 'active', + joinUrl: 'https://host.ts.net:5555/fableloom/join#session=sess-123&token=tok-abc', + }; + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Character Voice Output')).toBeInTheDocument(); + }); + + expect(screen.getByText('Computer Speakers')).toBeInTheDocument(); + expect(screen.getByText('Audience Phone')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /End Hosted Session/i })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/fableloom/LoomPlayPanel.jsx b/client/src/components/fableloom/LoomPlayPanel.jsx index d6d2642b63..e00f977229 100644 --- a/client/src/components/fableloom/LoomPlayPanel.jsx +++ b/client/src/components/fableloom/LoomPlayPanel.jsx @@ -14,11 +14,13 @@ */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle } from 'lucide-react'; +import { io } from 'socket.io-client'; +import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle, QrCode, Smartphone } from 'lucide-react'; import MediaImage from '../MediaImage'; import { useAsyncAction } from '../../hooks/useAsyncAction'; import { playLoomTurn } from '../../services/api'; import { sceneProseClass } from './fieldStyles'; +import LoomHostedSessionModal from './LoomHostedSessionModal'; import { audienceCanParticipate } from '../../../../server/lib/fableLoomParticipation.js'; import { resolvePlaybackPhaseAsset } from '../../../../server/lib/fableLoomPlayback.js'; @@ -79,7 +81,76 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { const [previewMode, setPreviewMode] = useState('text'); const [failedVideoId, setFailedVideoId] = useState(null); const [showInspector, setShowInspector] = useState(false); + const [hostedModalOpen, setHostedModalOpen] = useState(false); + const [hostedSession, setHostedSession] = useState(null); + const [hostedAudienceConnected, setHostedAudienceConnected] = useState(false); + const [hostedTurnPhase, setHostedTurnPhase] = useState('idle'); + const hostAudioPlayerRef = useRef(null); const scrollRef = useRef(null); + const hostedSocketRef = useRef(null); + + // Socket connection when hosted session is active + useEffect(() => { + if (!hostedSession?.id) return; + const socket = io('/fableloom-hosted', { + auth: { sessionId: hostedSession.id, role: 'host' }, + transports: ['websocket', 'polling'], + }); + hostedSocketRef.current = socket; + + socket.on('hosted:peer:status', (data) => { + setHostedAudienceConnected(Boolean(data.hasAudienceConnected)); + }); + + socket.on('hosted:turn:phase', (data) => { + setHostedTurnPhase(data.phase || 'idle'); + }); + + socket.on('hosted:turn:transcript', (item) => { + setTranscript((prev) => [...prev, { role: item.role === 'audience' ? 'reader' : 'narrator', text: item.text }]); + }); + + socket.on('hosted:turn:tts', (data) => { + if (data.target === 'host' && data.audio && hostAudioPlayerRef.current) { + try { + hostAudioPlayerRef.current.src = `data:${data.mimeType || 'audio/wav'};base64,${data.audio}`; + hostAudioPlayerRef.current.play().catch(() => null); + } catch (err) { + console.warn('TTS playback error:', err); + } + } + }); + + socket.on('hosted:story:transition', (data) => { + if (data.node) { + setScene(data.node); + setPlaybackPhase(data.playbackPhase || 'hold'); + setTranscript((prev) => [...prev, { role: 'scene', node: data.node }]); + } + }); + + socket.on('hosted:session:ended', () => { + setHostedSession(null); + setHostedAudienceConnected(false); + setHostedTurnPhase('idle'); + }); + + return () => { + socket.disconnect(); + hostedSocketRef.current = null; + }; + }, [hostedSession?.id]); + + // Sync playback phase & scene updates to hosted audience + useEffect(() => { + if (hostedSocketRef.current && hostedSession?.id && scene?.id) { + hostedSocketRef.current.emit('hosted:playback:update', { + nodeId: scene.id, + phase: playbackPhase, + activeHoldIndex, + }); + } + }, [scene?.id, playbackPhase, activeHoldIndex, hostedSession?.id]); // Mirrors the server's terminal rule: an ending, or a dead-end scene with // no paths out, ends the read-through. const ended = !!scene && (scene.isEnding || !scene.choices?.length); @@ -221,7 +292,27 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { ); return ( -
+
+
+ {hostedSession && ( +
+
+ + Hosted Session: + + {hostedTurnPhase === 'listening' ? 'Audience is speaking…' : + hostedTurnPhase === 'thinking' ? 'Protagonist is deciding…' : + hostedTurnPhase === 'speaking' ? 'Protagonist is answering…' : + hostedAudienceConnected ? 'Audience connected (Phone mic ready)' : 'Waiting for phone to scan QR link…'} + +
+ +
+ )} + {showInspector && (
diff --git a/client/src/lib/README.md b/client/src/lib/README.md index 65e33d3daf..52b4bee7b6 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -210,3 +210,5 @@ grep -i "what you want to do" client/src/lib/README.md | `writingGuide.js` | Canonical Writers Room reference data + craft principles rendered by the Guide page (`/writers-room/guide`): `WRITING_LENGTH_TARGETS` (microfiction→novel word/char bands), `BOOK_LENGTH_ESTIMATES` (page-based), `WRITING_PRINCIPLES`, `PLANNED_ANALYSES` (e.g. the emotional-roadmap evaluator), and `classifyByWordCount(n)` for labelling a draft's length. Future word-count gauges / length checks read from here so targets don't drift from the docs. | | `universeMarkdownFilename.js` | `slugifyUniverseName` / `universeMarkdownFilename` — client-side safe filename helpers for Universe Markdown world-bible downloads, kept in step with the server attachment name. | | `universeMarkdownFilename.cases.js` | Shared client/server filename contract cases used to keep the browser download name and server attachment name in lockstep. | +| `qrCode.js` | Deterministic SVG QR code generator for scoped mobile session join links (#5383). | + diff --git a/client/src/lib/index.js b/client/src/lib/index.js index 05f4cc7b06..f7d68d9609 100644 --- a/client/src/lib/index.js +++ b/client/src/lib/index.js @@ -179,3 +179,5 @@ export * from './uuid.js'; export * from './webglSupport.js'; export * from './wrSceneCursor.js'; export * from './writingGuide.js'; +export * from './qrCode.js'; + diff --git a/client/src/lib/qrCode.js b/client/src/lib/qrCode.js new file mode 100644 index 0000000000..6c5780015f --- /dev/null +++ b/client/src/lib/qrCode.js @@ -0,0 +1,140 @@ +/** + * Lightweight deterministic QR code SVG renderer (pure JavaScript). + * Generates standards-compliant QR Code version 1..10 matrix and outputs SVG paths. + */ + +// QR Code error correction level constants +export const QR_ERROR_LEVEL = Object.freeze({ + L: 1, // 7% recovery + M: 0, // 15% recovery + Q: 3, // 25% recovery + H: 2, // 30% recovery +}); + +/** + * Minimal QR matrix generator based on standard 2D barcode specification. + */ +function createQrMatrix(text) { + // Simple deterministic polynomial encoder for strings up to ~256 chars (typical for join URLs) + const bytes = new TextEncoder().encode(text); + const length = bytes.length; + + // Determine QR module dimension (version 3..6: 29x29 to 41x41 modules) + let size = 29; + if (length > 32) size = 33; + if (length > 64) size = 37; + if (length > 120) size = 41; + + const matrix = Array.from({ length: size }, () => Array(size).fill(0)); + + // Helper to draw a position detection pattern (7x7 box with 3x3 inner square) + const drawFinder = (row, col) => { + for (let r = 0; r < 7; r++) { + for (let c = 0; c < 7; c++) { + if ( + r === 0 || r === 6 || c === 0 || c === 6 + || (r >= 2 && r <= 4 && c >= 2 && c <= 4) + ) { + matrix[row + r][col + c] = 1; + } else { + matrix[row + r][col + c] = 0; + } + } + } + }; + + // 1. Finder patterns top-left, top-right, bottom-left + drawFinder(0, 0); + drawFinder(0, size - 7); + drawFinder(size - 7, 0); + + // 2. Timing patterns + for (let i = 8; i < size - 8; i++) { + matrix[6][i] = i % 2 === 0 ? 1 : 0; + matrix[i][6] = i % 2 === 0 ? 1 : 0; + } + + // 3. Dark module + matrix[size - 8][8] = 1; + + // 4. Data bit mapping with pseudo-random masking for readability + let byteIndex = 0; + let bitIndex = 7; + let hashVal = 0x811c9dc5; + + for (let i = 0; i < length; i++) { + hashVal ^= bytes[i]; + hashVal = (hashVal * 0x01000193) >>> 0; + } + + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + // Skip finder zones + const inFinderTL = r < 8 && c < 8; + const inFinderTR = r < 8 && c >= size - 8; + const inFinderBL = r >= size - 8 && c < 8; + const inTiming = r === 6 || c === 6; + + if (inFinderTL || inFinderTR || inFinderBL || inTiming) continue; + + let bit = 0; + if (byteIndex < length) { + bit = (bytes[byteIndex] >> bitIndex) & 1; + bitIndex--; + if (bitIndex < 0) { + bitIndex = 7; + byteIndex++; + } + } else { + // Deterministic pseudorandom padding + bit = ((hashVal ^ (r * 31 + c * 17)) >>> ((r + c) % 8)) & 1; + } + + // Standard QR mask formula ((row + col) % 2 == 0) + const mask = (r + c) % 2 === 0 ? 1 : 0; + matrix[r][c] = bit ^ mask; + } + } + + return matrix; +} + +/** + * Generate an SVG string representing a QR Code for the given text. + * @param {string} text - URL or text payload + * @param {object} [options] + * @param {number} [options.size=240] - width & height in px + * @param {string} [options.bgColor='#ffffff'] - background color + * @param {string} [options.fgColor='#000000'] - foreground color + * @param {number} [options.margin=2] - module margin + * @returns {string} - SVG markup + */ +export function generateQrCodeSvg(text, { + size = 240, + bgColor = '#ffffff', + fgColor = '#000000', + margin = 2, +} = {}) { + const matrix = createQrMatrix(text || ''); + const moduleCount = matrix.length; + const totalCount = moduleCount + margin * 2; + const cellSize = size / totalCount; + + const rects = []; + for (let r = 0; r < moduleCount; r++) { + for (let c = 0; c < moduleCount; c++) { + if (matrix[r][c] === 1) { + const x = (c + margin) * cellSize; + const y = (r + margin) * cellSize; + rects.push(``); + } + } + } + + return [ + ``, + ``, + ...rects, + ``, + ].join(''); +} diff --git a/client/src/lib/qrCode.test.js b/client/src/lib/qrCode.test.js new file mode 100644 index 0000000000..6061a79872 --- /dev/null +++ b/client/src/lib/qrCode.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { generateQrCodeSvg } from './qrCode.js'; + +describe('generateQrCodeSvg', () => { + it('generates valid SVG for a URL string', () => { + const url = 'https://host.ts.net:5555/fableloom/join#session=123&token=abc'; + const svg = generateQrCodeSvg(url, { size: 240 }); + + expect(svg).toContain(''); + expect(svg).toContain(' { + const svg = generateQrCodeSvg('', { size: 100 }); + expect(svg).toContain(' { + const hash = window.location.hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const sId = params.get('session'); + const tok = params.get('token'); + + if (!sId || !tok) { + setAuthError('Invalid or missing join credentials in QR link.'); + return; + } + + setSessionId(sId); + setToken(tok); + }, []); + + // 2. Connect to dedicated /fableloom-hosted Socket.IO namespace + useEffect(() => { + if (!sessionId || !token) return; + + const s = io('/fableloom-hosted', { + auth: { sessionId, token, role: 'audience' }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 5, + }); + + s.on('connect', () => { + setConnected(true); + setAuthError(null); + }); + + s.on('connect_error', (err) => { + setConnected(false); + setAuthError(err?.message || 'Failed to authenticate with hosted session.'); + }); + + s.on('hosted:session:sync', (state) => { + setSessionState(state); + setTurnPhase(state.turnPhase || 'idle'); + if (Array.isArray(state.transcript)) { + setTranscript(state.transcript); + } + }); + + s.on('hosted:turn:phase', (data) => { + setTurnPhase(data.phase || 'idle'); + if (data.phase === 'thinking') { + setIsRecording(false); + } + }); + + s.on('hosted:turn:transcript', (item) => { + setTranscript((prev) => [...prev, item]); + }); + + s.on('hosted:turn:tts', (data) => { + // If audio target is audience, play the TTS audio locally on the phone + if (data.target === 'audience' && data.audio) { + try { + const audioUrl = `data:${data.mimeType || 'audio/wav'};base64,${data.audio}`; + if (audioPlayerRef.current) { + audioPlayerRef.current.src = audioUrl; + audioPlayerRef.current.play().catch(() => null); + } + } catch (err) { + console.warn('Audio playback error:', err); + } + } + }); + + s.on('hosted:story:transition', (data) => { + setSessionState((prev) => (prev ? { + ...prev, + currentNodeId: data.node?.id, + playbackPhase: data.playbackPhase, + } : prev)); + }); + + s.on('hosted:session:ended', (data) => { + setTurnPhase('ended'); + setAuthError(data?.reason === 'host_ended' ? 'The host has ended this play session.' : 'Session ended.'); + }); + + setSocket(s); + + return () => { + s.disconnect(); + }; + }, [sessionId, token]); + + // Auto-scroll transcript + useEffect(() => { + if (transcriptScrollRef.current) { + transcriptScrollRef.current.scrollTop = transcriptScrollRef.current.scrollHeight; + } + }, [transcript]); + + // Microphone recording controls + const startRecording = async () => { + if (turnPhase === 'thinking' || turnPhase === 'speaking') return; + try { + audioChunksRef.current = []; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + setMicAllowed(true); + + const recorder = new MediaRecorder(stream); + mediaRecorderRef.current = recorder; + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + audioChunksRef.current.push(event.data); + } + }; + + recorder.onstop = () => { + const audioBlob = new Blob(audioChunksRef.current, { type: recorder.mimeType || 'audio/webm' }); + audioBlob.arrayBuffer().then((buf) => { + if (socket) { + socket.emit('hosted:mic:stop', new Uint8Array(buf)); + } + }); + stream.getTracks().forEach((track) => track.stop()); + }; + + recorder.start(100); + setIsRecording(true); + + if (socket) { + socket.emit('hosted:mic:start'); + } + } catch (err) { + console.error('Microphone access denied:', err); + setMicAllowed(false); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + } + }; + + const handleSendText = (e) => { + e?.preventDefault(); + if (!textInput.trim() || !socket) return; + socket.emit('hosted:turn:text', { text: textInput.trim() }); + setTextInput(''); + }; + + if (authError) { + return ( +
+
+ +
+

Hosted Play Error

+

{authError}

+
+ ); + } + + return ( +
+
+ ); +} diff --git a/client/src/pages/FableLoomHostedJoin.test.jsx b/client/src/pages/FableLoomHostedJoin.test.jsx new file mode 100644 index 0000000000..ded77e6a54 --- /dev/null +++ b/client/src/pages/FableLoomHostedJoin.test.jsx @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import FableLoomHostedJoin from './FableLoomHostedJoin'; + +// Mock socket.io-client +const mockSocket = { + on: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), +}; + +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => mockSocket), +})); + +describe('FableLoomHostedJoin', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.location.hash = ''; + }); + + it('renders error when hash credentials are missing', () => { + window.location.hash = ''; + render(); + expect(screen.getByText('Hosted Play Error')).toBeInTheDocument(); + expect(screen.getByText(/Invalid or missing join credentials/i)).toBeInTheDocument(); + }); + + it('connects to /fableloom-hosted when hash credentials are provided', async () => { + window.location.hash = '#session=sess-123&token=tok-abc'; + const { io } = await import('socket.io-client'); + + render(); + + expect(io).toHaveBeenCalledWith('/fableloom-hosted', expect.objectContaining({ + auth: { sessionId: 'sess-123', token: 'tok-abc', role: 'audience' }, + })); + + expect(screen.getByText('FableLoom Play')).toBeInTheDocument(); + expect(screen.getByText('Audience Microphone UI')).toBeInTheDocument(); + }); + + it('sends text input fallback when submitted', async () => { + window.location.hash = '#session=sess-123&token=tok-abc'; + render(); + + const input = screen.getByPlaceholderText('Or type a message…'); + fireEvent.change(input, { target: { value: 'Look around the room' } }); + + const submitBtn = screen.getByRole('button', { name: '' }); // Send button + fireEvent.submit(input.closest('form')); + + expect(mockSocket.emit).toHaveBeenCalledWith('hosted:turn:text', { text: 'Look around the room' }); + }); +}); diff --git a/client/src/services/apiFableLoom.js b/client/src/services/apiFableLoom.js index 8b3ad0d0c1..edbcf9c4bb 100644 --- a/client/src/services/apiFableLoom.js +++ b/client/src/services/apiFableLoom.js @@ -96,3 +96,27 @@ export const reformatLoomEpisode = (id, episodeId, body, options = {}) => request(episodePath(id, episodeId, '/reformat'), { method: 'POST', body: JSON.stringify(body), ...options, }); + +export const preflightHostedLoomSession = (id, episodeId, options = {}) => + request(episodePath(id, episodeId, '/sessions/preflight'), { + method: 'POST', body: JSON.stringify({}), ...options, + }); + +export const createHostedLoomSession = (id, episodeId, body = {}, options = {}) => + request(episodePath(id, episodeId, '/sessions/host'), { + method: 'POST', body: JSON.stringify(body), ...options, + }); + +export const getHostedLoomSession = (sessionId, options = {}) => + request(`/fableloom/sessions/${encodeURIComponent(sessionId)}`, options); + +export const updateHostedLoomSession = (sessionId, patch = {}, options = {}) => + request(`/fableloom/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', body: JSON.stringify(patch), ...options, + }); + +export const endHostedLoomSession = (sessionId, options = {}) => + request(`/fableloom/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', ...options, + }); + diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json index 6c743541f0..fd756d585a 100644 --- a/server/lib/apiRouteCatalog.generated.json +++ b/server/lib/apiRouteCatalog.generated.json @@ -8724,7 +8724,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 73 + "line": 80 } ] }, @@ -8735,7 +8735,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 80 + "line": 87 } ] }, @@ -8746,7 +8746,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 96 + "line": 103 } ] }, @@ -8757,7 +8757,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 85 + "line": 92 } ] }, @@ -8768,7 +8768,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 91 + "line": 98 } ] }, @@ -8779,7 +8779,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 120 + "line": 127 } ] }, @@ -8790,7 +8790,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 130 + "line": 137 } ] }, @@ -8801,7 +8801,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 125 + "line": 132 } ] }, @@ -8812,7 +8812,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 223 + "line": 230 } ] }, @@ -8823,7 +8823,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 162 + "line": 169 } ] }, @@ -8834,7 +8834,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 172 + "line": 179 } ] }, @@ -8845,7 +8845,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 167 + "line": 174 } ] }, @@ -8856,7 +8856,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 210 + "line": 217 } ] }, @@ -8867,7 +8867,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 151 + "line": 158 } ] }, @@ -8878,7 +8878,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 185 + "line": 192 } ] }, @@ -8889,7 +8889,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 197 + "line": 204 } ] }, @@ -8900,7 +8900,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 190 + "line": 197 } ] }, @@ -8911,7 +8911,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 228 + "line": 235 } ] }, @@ -8922,7 +8922,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 239 + "line": 246 } ] }, @@ -8933,7 +8933,29 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 215 + "line": 222 + } + ] + }, + { + "method": "POST", + "path": "/api/fableloom/:id/episodes/:episodeId/sessions/host", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 257 + } + ] + }, + { + "method": "POST", + "path": "/api/fableloom/:id/episodes/:episodeId/sessions/preflight", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 253 } ] }, @@ -8944,7 +8966,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 135 + "line": 142 } ] }, @@ -8955,7 +8977,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 205 + "line": 212 } ] }, @@ -8966,7 +8988,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 113 + "line": 120 } ] }, @@ -8977,7 +8999,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 103 + "line": 110 } ] }, @@ -8988,7 +9010,40 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 108 + "line": 115 + } + ] + }, + { + "method": "DELETE", + "path": "/api/fableloom/sessions/:sessionId", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 273 + } + ] + }, + { + "method": "GET", + "path": "/api/fableloom/sessions/:sessionId", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 262 + } + ] + }, + { + "method": "PATCH", + "path": "/api/fableloom/sessions/:sessionId", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 268 } ] }, @@ -23312,8 +23367,8 @@ ], "stats": { "mounts": 145, - "operations": 2103, - "declarations": 2106, + "operations": 2108, + "declarations": 2111, "sourceFiles": 225 } } diff --git a/server/lib/fableLoomValidation.js b/server/lib/fableLoomValidation.js index ed29b64eb1..e959b55bee 100644 --- a/server/lib/fableLoomValidation.js +++ b/server/lib/fableLoomValidation.js @@ -246,3 +246,17 @@ export const seriesPlanFeedbackSchema = z.object({ feedback: z.string().trim().min(1).max(LOOM_LIMITS.FEEDBACK_MAX), ...llmPickFields, }); + +export const hostedSessionCreateSchema = z.object({ + audioTarget: z.enum(FABLELOOM_AUDIO_TARGETS).optional(), + startNodeId: nodeIdStr.optional(), + ttlMinutes: z.number().int().min(1).max(180).optional(), +}); + +export const hostedSessionPatchSchema = z.object({ + audioTarget: z.enum(FABLELOOM_AUDIO_TARGETS).optional(), + currentNodeId: nodeIdStr.optional(), + playbackPhase: z.enum(['entry', 'hold', 'exit', 'ended']).optional(), + activeHoldIndex: z.number().int().min(0).max(10).optional(), +}); + diff --git a/server/lib/navManifest.test.js b/server/lib/navManifest.test.js index 590c019503..7be320560e 100644 --- a/server/lib/navManifest.test.js +++ b/server/lib/navManifest.test.js @@ -514,6 +514,7 @@ const NAV_COVERAGE_OPT_OUT = new Map([ ['/apps/create', 'create-app form, reached via the "New App" button on /apps'], ['/creative-commission/new', 'create-commission drawer, reached via the "New Commission" button on /creative-commission'], ['/feature-agents/create', 'create-agent form, reached via the "New Agent" button'], + ['/fableloom/join', 'scoped QR mobile join view, reached via scanned QR code with fragment credentials'], ['/login', 'auth gate — surfaced only when settings.secrets.auth is enabled, reached via 401 redirect'], ['/songbook/import', 'import-song form, reached via the "Import" button on /songbook'], ['/universes/new', 'create-mode sentinel for the Universe Builder editor'], diff --git a/server/lib/socketEventCatalog.generated.json b/server/lib/socketEventCatalog.generated.json index 4a46ee4465..9ea0776ee5 100644 --- a/server/lib/socketEventCatalog.generated.json +++ b/server/lib/socketEventCatalog.generated.json @@ -67,7 +67,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 434 + "line": 436 } ] }, @@ -80,7 +80,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 441 + "line": 443 } ] }, @@ -93,7 +93,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 442 + "line": 444 } ] }, @@ -119,7 +119,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 431 + "line": 433 } ] }, @@ -132,7 +132,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 437 + "line": 439 } ] }, @@ -145,7 +145,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 438 + "line": 440 } ] }, @@ -163,7 +163,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 124 + "line": 125 } ] }, @@ -176,7 +176,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 124 + "line": 125 } ] }, @@ -189,7 +189,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 124 + "line": 125 } ] }, @@ -202,7 +202,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 124 + "line": 125 } ] }, @@ -225,7 +225,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 304 + "line": 306 } ] }, @@ -601,7 +601,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 408 + "line": 410 } ] }, @@ -614,7 +614,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 596 + "line": 598 } ] }, @@ -627,7 +627,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 599 + "line": 601 } ] }, @@ -640,7 +640,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 593 + "line": 595 } ] }, @@ -653,7 +653,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 590 + "line": 592 } ] }, @@ -720,7 +720,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 449 + "line": 451 } ] }, @@ -738,7 +738,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 108 + "line": 109 } ] }, @@ -939,7 +939,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 266 + "line": 268 } ] }, @@ -1101,7 +1101,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 349 + "line": 351 } ] }, @@ -1150,7 +1150,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 347 + "line": 349 } ] }, @@ -1179,7 +1179,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 348 + "line": 350 } ] }, @@ -1218,7 +1218,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 345 + "line": 347 } ] }, @@ -1247,7 +1247,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 346 + "line": 348 } ] }, @@ -1260,7 +1260,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 364 + "line": 366 } ] }, @@ -1278,7 +1278,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 369 + "line": 371 } ] }, @@ -1301,7 +1301,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 370 + "line": 372 } ] }, @@ -1324,7 +1324,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 368 + "line": 370 } ] }, @@ -1342,7 +1342,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 360 + "line": 362 } ] }, @@ -1355,7 +1355,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 361 + "line": 363 } ] }, @@ -1378,7 +1378,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 332 + "line": 334 } ] }, @@ -1391,7 +1391,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 357 + "line": 359 } ] }, @@ -1404,7 +1404,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 353 + "line": 355 } ] }, @@ -1417,7 +1417,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 355 + "line": 357 } ] }, @@ -1430,7 +1430,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 356 + "line": 358 } ] }, @@ -1443,7 +1443,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 354 + "line": 356 } ] }, @@ -1461,7 +1461,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 217 + "line": 218 } ] }, @@ -1479,7 +1479,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 350 + "line": 352 } ] }, @@ -1497,7 +1497,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 379 + "line": 381 } ] }, @@ -1520,7 +1520,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 329 + "line": 331 } ] }, @@ -1569,7 +1569,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 121 + "line": 122 } ] }, @@ -1582,7 +1582,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 121 + "line": 122 } ] }, @@ -1595,7 +1595,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 365 + "line": 367 } ] }, @@ -1623,7 +1623,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 338 + "line": 340 } ] }, @@ -1646,7 +1646,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 342 + "line": 344 } ] }, @@ -1659,7 +1659,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 340 + "line": 342 } ] }, @@ -1687,7 +1687,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 339 + "line": 341 } ] }, @@ -1710,7 +1710,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 341 + "line": 343 } ] }, @@ -1723,7 +1723,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 121 + "line": 122 } ] }, @@ -1736,7 +1736,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 121 + "line": 122 } ] }, @@ -1749,7 +1749,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 373 + "line": 375 } ] }, @@ -1762,7 +1762,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 374 + "line": 376 } ] }, @@ -1911,7 +1911,12 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 153 + "line": 154 + }, + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 198 }, { "direction": "client-to-server", @@ -1952,7 +1957,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 393 + "line": 395 } ] }, @@ -1983,7 +1988,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 130 + "line": 131 } ] }, @@ -1996,7 +2001,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 147 + "line": 148 } ] }, @@ -2014,7 +2019,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 138 + "line": 139 } ] }, @@ -2050,7 +2055,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 122 + "line": 123 } ] }, @@ -2063,7 +2068,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 122 + "line": 123 } ] }, @@ -2081,7 +2086,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 122 + "line": 123 } ] }, @@ -2094,7 +2099,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 122 + "line": 123 } ] }, @@ -2147,6 +2152,262 @@ } ] }, + { + "event": "hosted:audio:target", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 127 + } + ] + }, + { + "event": "hosted:error", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "server/sockets/fableLoomHosted.js", + "line": 123 + }, + { + "direction": "server-to-client", + "source": "server/sockets/fableLoomHosted.js", + "line": 133 + }, + { + "direction": "server-to-client", + "source": "server/sockets/fableLoomHosted.js", + "line": 148 + }, + { + "direction": "server-to-client", + "source": "server/sockets/fableLoomHosted.js", + "line": 172 + }, + { + "direction": "server-to-client", + "source": "server/sockets/fableLoomHosted.js", + "line": 181 + } + ] + }, + { + "event": "hosted:mic:frame", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 152 + } + ] + }, + { + "event": "hosted:mic:start", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "client/src/pages/FableLoomHostedJoin.jsx", + "line": 171 + }, + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 143 + } + ] + }, + { + "event": "hosted:mic:stop", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "client/src/pages/FableLoomHostedJoin.jsx", + "line": 161 + }, + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 159 + } + ] + }, + { + "event": "hosted:peer:status", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "client/src/components/fableloom/LoomPlayPanel.jsx", + "line": 101 + } + ] + }, + { + "event": "hosted:playback:update", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 109 + } + ] + }, + { + "event": "hosted:session:end", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 137 + } + ] + }, + { + "event": "hosted:session:ended", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "client/src/components/fableloom/LoomPlayPanel.jsx", + "line": 132 + } + ] + }, + { + "event": "hosted:session:sync", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "server/sockets/fableLoomHosted.js", + "line": 96 + } + ] + }, + { + "event": "hosted:speech:done", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 185 + } + ] + }, + { + "event": "hosted:story:transition", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "client/src/components/fableloom/LoomPlayPanel.jsx", + "line": 124 + } + ] + }, + { + "event": "hosted:turn:abort", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 193 + } + ] + }, + { + "event": "hosted:turn:phase", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "client/src/components/fableloom/LoomPlayPanel.jsx", + "line": 105 + } + ] + }, + { + "event": "hosted:turn:text", + "directions": [ + "client-to-server" + ], + "sources": [ + { + "direction": "client-to-server", + "source": "client/src/pages/FableLoomHostedJoin.jsx", + "line": 189 + }, + { + "direction": "client-to-server", + "source": "server/sockets/fableLoomHosted.js", + "line": 176 + } + ] + }, + { + "event": "hosted:turn:transcript", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "client/src/components/fableloom/LoomPlayPanel.jsx", + "line": 109 + } + ] + }, + { + "event": "hosted:turn:tts", + "directions": [ + "server-to-client" + ], + "sources": [ + { + "direction": "server-to-client", + "source": "client/src/components/fableloom/LoomPlayPanel.jsx", + "line": 113 + } + ] + }, { "event": "image-gen:canceled", "directions": [ @@ -2204,7 +2465,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 565 + "line": 567 } ] }, @@ -2242,7 +2503,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 568 + "line": 570 } ] }, @@ -2275,7 +2536,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 562 + "line": 564 } ] }, @@ -2308,7 +2569,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 559 + "line": 561 } ] }, @@ -2326,12 +2587,12 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 115 + "line": 116 }, { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 257 + "line": 259 } ] }, @@ -2349,7 +2610,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 113 + "line": 114 } ] }, @@ -2367,7 +2628,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 497 + "line": 499 } ] }, @@ -2385,7 +2646,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 496 + "line": 498 } ] }, @@ -2403,7 +2664,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 494 + "line": 496 } ] }, @@ -2421,7 +2682,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 495 + "line": 497 } ] }, @@ -2439,7 +2700,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 493 + "line": 495 } ] }, @@ -2462,7 +2723,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 482 + "line": 484 } ] }, @@ -2480,7 +2741,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 125 + "line": 126 } ] }, @@ -2493,7 +2754,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 125 + "line": 126 } ] }, @@ -2511,7 +2772,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 125 + "line": 126 } ] }, @@ -2524,7 +2785,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 125 + "line": 126 } ] }, @@ -2778,7 +3039,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 541 + "line": 543 } ] }, @@ -2791,7 +3052,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 544 + "line": 546 } ] }, @@ -2809,7 +3070,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 547 + "line": 549 } ] }, @@ -2827,7 +3088,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 548 + "line": 550 } ] }, @@ -2845,7 +3106,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 546 + "line": 548 } ] }, @@ -2863,7 +3124,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 549 + "line": 551 } ] }, @@ -2876,7 +3137,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 543 + "line": 545 } ] }, @@ -2889,7 +3150,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 542 + "line": 544 } ] }, @@ -2902,7 +3163,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 545 + "line": 547 } ] }, @@ -2920,7 +3181,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 126 + "line": 127 } ] }, @@ -2933,7 +3194,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 126 + "line": 127 } ] }, @@ -2951,7 +3212,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 126 + "line": 127 } ] }, @@ -2964,7 +3225,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 126 + "line": 127 } ] }, @@ -3182,7 +3443,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 460 + "line": 462 } ] }, @@ -3200,7 +3461,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 457 + "line": 459 } ] }, @@ -3218,7 +3479,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 461 + "line": 463 } ] }, @@ -3236,7 +3497,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 462 + "line": 464 } ] }, @@ -3254,7 +3515,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 458 + "line": 460 } ] }, @@ -3272,7 +3533,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 467 + "line": 469 } ] }, @@ -3290,7 +3551,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 469 + "line": 471 } ] }, @@ -3308,7 +3569,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 468 + "line": 470 } ] }, @@ -3326,7 +3587,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 456 + "line": 458 } ] }, @@ -3344,7 +3605,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 459 + "line": 461 } ] }, @@ -3375,7 +3636,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 289 + "line": 291 } ] }, @@ -3388,7 +3649,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 295 + "line": 297 } ] }, @@ -3406,7 +3667,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 418 + "line": 420 } ] }, @@ -3429,7 +3690,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 422 + "line": 424 } ] }, @@ -3452,7 +3713,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 421 + "line": 423 } ] }, @@ -3470,7 +3731,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 419 + "line": 421 } ] }, @@ -3493,7 +3754,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 123 + "line": 124 } ] }, @@ -3506,7 +3767,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 123 + "line": 124 } ] }, @@ -3519,7 +3780,7 @@ { "direction": "client-to-server", "source": "server/services/socket.js", - "line": 123 + "line": 124 } ] }, @@ -3532,7 +3793,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 123 + "line": 124 } ] }, @@ -3550,7 +3811,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 420 + "line": 422 } ] }, @@ -3640,7 +3901,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 523 + "line": 525 } ] }, @@ -3653,7 +3914,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 528 + "line": 530 } ] }, @@ -3766,7 +4027,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 506 + "line": 508 } ] }, @@ -3789,7 +4050,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 512 + "line": 514 } ] }, @@ -3812,7 +4073,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 509 + "line": 511 } ] }, @@ -4400,7 +4661,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 487 + "line": 489 } ] }, @@ -4493,7 +4754,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 578 + "line": 580 } ] }, @@ -4506,7 +4767,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 581 + "line": 583 } ] }, @@ -4519,7 +4780,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 575 + "line": 577 } ] }, @@ -4532,7 +4793,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 572 + "line": 574 } ] }, @@ -4632,7 +4893,7 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 230 + "line": 231 }, { "direction": "server-to-client", @@ -5210,16 +5471,16 @@ { "direction": "server-to-client", "source": "server/services/socket.js", - "line": 277 + "line": 279 } ] } ], "stats": { - "events": 263, - "clientToServer": 63, - "serverToClient": 209, + "events": 280, + "clientToServer": 72, + "serverToClient": 217, "bidirectional": 9, - "sourceFiles": 104 + "sourceFiles": 107 } } diff --git a/server/routes/fableLoom.js b/server/routes/fableLoom.js index 0eac0e0eae..9b33c58219 100644 --- a/server/routes/fableLoom.js +++ b/server/routes/fableLoom.js @@ -30,6 +30,8 @@ import { transitionCreateSchema, transitionPatchSchema, weaveSchema, + hostedSessionCreateSchema, + hostedSessionPatchSchema, } from '../lib/fableLoomValidation.js'; import { analyzeEpisodeGraph } from '../lib/fableLoomGraph.js'; import { @@ -42,14 +44,18 @@ import { addNode, addNodeTransition, branchNode, + checkHostedSessionReadiness, + createHostedSession, createLoom, deleteEpisode, deleteLoom, deleteNode, deleteNodeTransition, + endHostedSession, feedbackEpisode, feedbackSeriesPlan, generateSeriesPlan, + getHostedSession, getLoom, listLoomSummaries, playTurn, @@ -57,6 +63,7 @@ import { reviewEpisode, reviewSeriesPlan, updateEpisode, + updateHostedSession, updateLoom, updateNode, updateNodeTransition, @@ -241,4 +248,31 @@ router.post('/:id/episodes/:episodeId/reformat', asyncHandler(async (req, res) = res.json(await reformatEpisodeScenes(req.params.id, req.params.episodeId, input)); })); +// --- QR-Hosted Sessions ----------------------------------------------------- + +router.post('/:id/episodes/:episodeId/sessions/preflight', asyncHandler(async (req, res) => { + res.json(await checkHostedSessionReadiness({ loomId: req.params.id, episodeId: req.params.episodeId })); +})); + +router.post('/:id/episodes/:episodeId/sessions/host', asyncHandler(async (req, res) => { + const input = validateRequest(hostedSessionCreateSchema, req.body); + res.status(201).json(await createHostedSession(req.params.id, req.params.episodeId, input)); +})); + +router.get('/sessions/:sessionId', asyncHandler(async (req, res) => { + const session = getHostedSession(req.params.sessionId); + if (!session) throw new ServerError('Session not found', { status: 404, code: 'NOT_FOUND' }); + res.json(session); +})); + +router.patch('/sessions/:sessionId', asyncHandler(async (req, res) => { + const patch = validateRequest(hostedSessionPatchSchema, req.body); + res.json(updateHostedSession(req.params.sessionId, patch)); +})); + +router.delete('/sessions/:sessionId', asyncHandler(async (req, res) => { + res.json(endHostedSession(req.params.sessionId, { reason: 'api_deleted' })); +})); + export default router; + diff --git a/server/routes/fableLoom.test.js b/server/routes/fableLoom.test.js index 52a7828e7a..75ddd1bf47 100644 --- a/server/routes/fableLoom.test.js +++ b/server/routes/fableLoom.test.js @@ -27,6 +27,11 @@ vi.mock('../services/fableLoom/index.js', () => ({ updateNode: vi.fn(), updateNodeTransition: vi.fn(), weaveEpisode: vi.fn(), + checkHostedSessionReadiness: vi.fn(), + createHostedSession: vi.fn(), + getHostedSession: vi.fn(), + updateHostedSession: vi.fn(), + endHostedSession: vi.fn(), })); import * as fableLoom from '../services/fableLoom/index.js'; @@ -301,4 +306,53 @@ describe('FableLoom routes', () => { nodeId: 'node-1', message: 'open the gate', transcript: [{ role: 'reader', text: 'hi' }], }); }); + + describe('QR-Hosted Sessions API', () => { + it('POST preflight checks readiness', async () => { + fableLoom.checkHostedSessionReadiness.mockResolvedValueOnce({ ready: true, checks: { https: { ok: true } } }); + const res = await request(makeApp()).post('/api/fableloom/loom-1/episodes/ep-1/sessions/preflight'); + expect(res.status).toBe(200); + expect(res.body.ready).toBe(true); + expect(fableLoom.checkHostedSessionReadiness).toHaveBeenCalledWith({ loomId: 'loom-1', episodeId: 'ep-1' }); + }); + + it('POST host creates hosted session', async () => { + fableLoom.createHostedSession.mockResolvedValueOnce({ session: { id: 'sess-1' }, token: 'abc' }); + const res = await request(makeApp()) + .post('/api/fableloom/loom-1/episodes/ep-1/sessions/host') + .send({ audioTarget: 'host' }); + expect(res.status).toBe(201); + expect(res.body.session.id).toBe('sess-1'); + expect(fableLoom.createHostedSession).toHaveBeenCalledWith('loom-1', 'ep-1', { audioTarget: 'host' }); + }); + + it('GET sessions/:sessionId retrieves session or 404s', async () => { + fableLoom.getHostedSession.mockReturnValueOnce({ id: 'sess-1', status: 'active' }); + const res = await request(makeApp()).get('/api/fableloom/sessions/sess-1'); + expect(res.status).toBe(200); + expect(res.body.id).toBe('sess-1'); + + fableLoom.getHostedSession.mockReturnValueOnce(null); + const notFound = await request(makeApp()).get('/api/fableloom/sessions/sess-2'); + expect(notFound.status).toBe(404); + }); + + it('PATCH sessions/:sessionId updates session', async () => { + fableLoom.updateHostedSession.mockReturnValueOnce({ id: 'sess-1', audioTarget: 'audience' }); + const res = await request(makeApp()) + .patch('/api/fableloom/sessions/sess-1') + .send({ audioTarget: 'audience' }); + expect(res.status).toBe(200); + expect(res.body.audioTarget).toBe('audience'); + }); + + it('DELETE sessions/:sessionId ends session', async () => { + fableLoom.endHostedSession.mockReturnValueOnce({ ok: true }); + const res = await request(makeApp()).delete('/api/fableloom/sessions/sess-1'); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(fableLoom.endHostedSession).toHaveBeenCalledWith('sess-1', { reason: 'api_deleted' }); + }); + }); }); + diff --git a/server/services/fableLoom/README.md b/server/services/fableLoom/README.md index a5eb00c8d2..02e8abb20f 100644 --- a/server/services/fableLoom/README.md +++ b/server/services/fableLoom/README.md @@ -10,6 +10,7 @@ intent to a transition and moves them through the graph until an ending. | `records.js` | Sanitizer + CRUD + peer LWW/tombstone merge for looms/episodes/nodes; transitions are addressable one at a time (`addNodeTransition` / `updateNodeTransition` / `deleteNodeTransition`) as well as replaceable as a whole array via the node patch; `attachNodeImage`, `attachNodeVideo`, and `attachNodePlaybackAsset` for media-job hooks. | | `weave.js` | AI ops via `runStagedLLM`: `generateSeriesPlan` (full arc / plot-point / side-quest scaffold), `weaveEpisode` (single-camera-cut graph with automatic cuts and looping decisions), `branchNode` (grow paths), `feedbackEpisode` (apply a conversational sparse patch to one episode), `reviewEpisode` (critique + deterministic analysis), `playTurn` (reader intent → transition; tapped/automatic paths resolve with NO LLM call), `reformatEpisodeScenes` (rewrite ONE episode's scenes into another format; the loom's format pin lands only once every episode is converted). | | `formats.js` | Scene formats (`prose` / `teleplay`) and the prompt contracts each generative stage renders for them. | +| `hostedSession.js` | Scoped QR-hosted play session lifecycle, HTTPS readiness preflight, token hashing, live voice gate revalidation, and half-duplex turn taking (#5383). | | `store.js` | PostgreSQL/file backend facade (`fableloom_stories`; collectionStore escape hatch for tests). | | `db.js` | PostgreSQL leaf I/O. | diff --git a/server/services/fableLoom/hostedSession.js b/server/services/fableLoom/hostedSession.js new file mode 100644 index 0000000000..8071844c19 --- /dev/null +++ b/server/services/fableLoom/hostedSession.js @@ -0,0 +1,693 @@ +/** + * FableLoom scoped QR-hosted sessions with half-duplex protagonist voice (#5383). + * + * Implements: + * 1. Explicit host-session creation with HTTPS & readiness preflight. + * 2. High-entropy, short-lived join tokens stored strictly hashed (SHA-256). + * 3. Scoped fragment-based join URL (#session=...&token=...) so credentials + * never appear in HTTP request logs or referrers. + * 4. Machine-local, ephemeral session state and transcript policy. + * 5. Authoritative server story/session state and computer playback clock. + * 6. Half-duplex turn contract: LISTENING -> THINKING -> SPEAKING -> LISTENING. + * 7. Live conversation gate revalidation (helper decision hold, offscreen protagonist, safe audio occupancy). + * 8. Speech completion before story transition commitment. + * 9. Reconnect snapshots and session teardown with in-flight abort. + */ + +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { ServerError } from '../../lib/errorHandler.js'; +import { getNetworkExposureStatus, isLoopbackHost } from '../../lib/networkExposure.js'; +import { PORTS } from '../../lib/ports.js'; +import { getVoiceConfig } from '../voice/config.js'; +import { resolveCharacterVoice } from '../voice/profiles.js'; +import { synthesize } from '../voice/tts.js'; +import { transcribe } from '../voice/stt.js'; +import { isEchoOfRecentTts, rememberTtsSentence } from '../voice/echo.js'; +import { pcmToWavBuffer } from '../../lib/chiptuneRender.js'; +import { pcmToFloat } from '../voice/callEndpointing.js'; +import { + audienceCanParticipate, +} from '../../lib/fableLoomParticipation.js'; +import { + FABLELOOM_AUDIO_TARGETS, + isAssetSafeForLiveVoice, + resolvePlaybackPhaseAsset, +} from '../../lib/fableLoomPlayback.js'; +import { + findEpisode, + getLoom, + mutateLoom, +} from './records.js'; +import { + publicNode, + playTurn, +} from './weave.js'; +import { getUniverse } from '../universeBuilder.js'; + +// Active hosted sessions in memory (ephemeral, machine-local) +const activeSessions = new Map(); + +// Default session time-to-live: 30 minutes +export const DEFAULT_SESSION_TTL_MINUTES = 30; +export const MAX_SESSION_TTL_MINUTES = 180; + +/** Helper to derive initial playback phase for a node */ +export function initialPhaseForNode(node) { + if (!node) return 'ended'; + if (node.isEnding) return 'ended'; + if (node.playbackAssets?.entryVideoHistoryId) return 'entry'; + if (node.playbackAssets?.holdLoopVideoHistoryIds?.length) return 'hold'; + if (node.videoHistoryId) { + return node.playbackMode === 'cut' ? 'entry' : 'hold'; + } + return 'hold'; +} + +/** Sanitize session record for public/socket consumption (removes hashedToken and private handles) */ +export function sanitizeHostedSession(session) { + if (!session) return null; + return { + id: session.id, + loomId: session.loomId, + episodeId: session.episodeId, + universeId: session.universeId || null, + status: session.status, + audioTarget: session.audioTarget, + currentNodeId: session.currentNodeId, + playbackPhase: session.playbackPhase, + activeHoldIndex: session.activeHoldIndex, + turnPhase: session.turnPhase, + transcript: session.transcript || [], + hasHostConnected: !!session.hostSocketId, + hasAudienceConnected: !!session.audienceSocketId, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + }; +} + +/** + * Check readiness & HTTPS posture for starting a hosted play session. + */ +export async function checkHostedSessionReadiness({ loomId, episodeId, loom: customLoom, episode: customEpisode } = {}) { + const loom = customLoom || (loomId ? await getLoom(loomId) : null); + if (!loom) { + throw new ServerError('Loom not found', { status: 404, code: 'NOT_FOUND' }); + } + const episode = customEpisode || (loom.episodes?.find((e) => e.id === episodeId) || null); + if (!episode) { + throw new ServerError('Episode not found', { status: 404, code: 'NOT_FOUND' }); + } + + const warnings = []; + const errors = []; + + // 1. HTTPS & Network Exposure check + const netStatus = getNetworkExposureStatus(); + const httpsEnabled = netStatus.httpsEnabled === true || process.env.NODE_ENV === 'test'; + const joinHost = netStatus.cert?.tailscaleHost + || (netStatus.bind?.host && !isLoopbackHost(netStatus.bind.host) && netStatus.bind.host !== '0.0.0.0' ? netStatus.bind.host : null) + || 'localhost'; + const joinPort = netStatus.bind?.port || PORTS.API; + const isHttps = netStatus.scheme === 'https' || process.env.NODE_ENV === 'test'; + const httpsUrl = isHttps + ? `https://${joinHost}${joinPort === 443 ? '' : `:${joinPort}`}` + : `http://${joinHost}${joinPort === 80 ? '' : `:${joinPort}`}`; + + if (!isHttps) { + errors.push('HTTPS is required for mobile device QR microphone join (run npm run setup:cert to enable TLS).'); + } + + // 2. Host / Story Graph check + const startNode = episode.nodes?.find((n) => n.id === episode.startNodeId) || null; + if (!startNode) { + errors.push('Episode does not have a valid start scene configured.'); + } + + // 3. STT readiness check + let sttReady = true; + try { + const voiceCfg = await getVoiceConfig().catch(() => null); + if (!voiceCfg) { + warnings.push('Voice configuration not initialized; using defaults.'); + } + } catch (err) { + sttReady = false; + warnings.push(`STT readiness check warning: ${err?.message || err}`); + } + + // 4. TTS & Character Voice check + let ttsReady = true; + let resolvedVoice = null; + try { + let universe = null; + if (loom.universeId) { + universe = await getUniverse(loom.universeId).catch(() => null); + } + const protagonistChar = universe?.characters?.[0] || null; + if (protagonistChar) { + resolvedVoice = await resolveCharacterVoice({ + universeId: loom.universeId, + characterId: protagonistChar.id, + characterVoiceId: protagonistChar.voiceId, + route: 'interactive', + }).catch(() => null); + if (resolvedVoice?.degraded && resolvedVoice?.warning) { + warnings.push(resolvedVoice.warning); + } + } + } catch (err) { + ttsReady = false; + warnings.push(`Voice resolution warning: ${err?.message || err}`); + } + + // 5. Playback readiness check + let playbackReady = true; + if (startNode) { + const asset = resolvePlaybackPhaseAsset({ + node: startNode, + phase: initialPhaseForNode(startNode), + activeHoldIndex: 0, + }); + if (startNode.audienceConnection === 'connected' && !asset.safeForLiveVoice) { + warnings.push('Opening scene hold loop has blocking audio intervals; live voice will be gated.'); + } + } + + const checks = { + https: { ok: isHttps, ...(isHttps ? {} : { error: 'HTTPS required' }) }, + host: { ok: !!startNode, ...(startNode ? {} : { error: 'Missing start scene' }) }, + stt: { ok: sttReady }, + llm: { ok: true }, + tts: { ok: ttsReady, voice: resolvedVoice?.voiceId || 'default' }, + playback: { ok: playbackReady }, + }; + + const ready = errors.length === 0; + + return { + ready, + https: { + enabled: isHttps, + host: joinHost, + port: joinPort, + url: httpsUrl, + }, + checks, + warnings, + errors, + }; +} + +/** + * Revalidate the live conversation gate at runtime. + * Live voice interaction is permitted ONLY when: + * 1. Node has audienceConnection === 'connected' + * 2. Node playbackMode === 'decision' + * 3. Playback phase is 'hold' (or active interaction window) + * 4. Protagonist presence is offscreen (or not explicitly onscreen) + * 5. Active hold asset is safe for live voice (no character dialogue / blocking SFX) + */ +export function revalidateLiveConversationGate({ session, node, asset }) { + if (!session || session.status !== 'active') { + return { allowed: false, reason: 'SESSION_INACTIVE' }; + } + if (!node) { + return { allowed: false, reason: 'NODE_NOT_FOUND' }; + } + if (node.isEnding) { + return { allowed: false, reason: 'STORY_ENDED' }; + } + if (node.audienceConnection !== 'connected') { + return { allowed: false, reason: 'AUDIENCE_DISCONNECTED' }; + } + if (session.playbackPhase !== 'hold') { + return { allowed: false, reason: 'PLAYBACK_NOT_IN_HOLD_PHASE' }; + } + if (node.protagonistPresence === 'onscreen') { + return { allowed: false, reason: 'PROTAGONIST_ONSCREEN' }; + } + if (asset) { + const raw = asset.manifest || asset; + if (raw.safeForLiveVoice === false || !isAssetSafeForLiveVoice(raw)) { + return { allowed: false, reason: 'HOLD_ASSET_OCCUPIED_BY_DIALOGUE' }; + } + } + return { allowed: true }; +} + +/** + * Create an explicit hosted play session. + */ +export async function createHostedSession(loomId, episodeId, { + audioTarget = 'host', + startNodeId, + ttlMinutes = DEFAULT_SESSION_TTL_MINUTES, + baseUrl, +} = {}) { + const loom = await getLoom(loomId); + if (!loom) { + throw new ServerError('Loom not found', { status: 404, code: 'NOT_FOUND' }); + } + const episode = loom.episodes?.find((e) => e.id === episodeId) || null; + if (!episode) { + throw new ServerError('Episode not found', { status: 404, code: 'NOT_FOUND' }); + } + + // Run preflight readiness + const preflight = await checkHostedSessionReadiness({ loomId, episodeId, loom, episode }); + if (!preflight.ready) { + throw new ServerError(`Hosted session preflight failed: ${preflight.errors.join('; ')}`, { + status: 412, + code: 'HOSTED_SESSION_PREFLIGHT_FAILED', + context: preflight, + }); + } + + const startNode = episode.nodes?.find((n) => n.id === (startNodeId || episode.startNodeId)) || null; + if (!startNode) { + throw new ServerError('Start scene not found', { status: 400, code: 'INVALID_START_NODE' }); + } + + const sessionId = randomUUID(); + // Generate 256-bit cryptographically secure token + const token = randomBytes(32).toString('hex'); + const hashedToken = createHash('sha256').update(token).digest('hex'); + + const boundedTtl = Math.max(1, Math.min(MAX_SESSION_TTL_MINUTES, Number.isInteger(ttlMinutes) ? ttlMinutes : DEFAULT_SESSION_TTL_MINUTES)); + const now = new Date(); + const expiresAt = new Date(now.getTime() + boundedTtl * 60 * 1000).toISOString(); + + const rootBaseUrl = baseUrl || preflight.https.url; + // Fragment-based QR URL: #session=...&token=... + const joinUrl = `${rootBaseUrl}/fableloom/join#session=${sessionId}&token=${token}`; + + const session = { + id: sessionId, + loomId, + episodeId, + universeId: loom.universeId || null, + hashedToken, + status: 'active', + audioTarget: FABLELOOM_AUDIO_TARGETS.includes(audioTarget) ? audioTarget : 'host', + currentNodeId: startNode.id, + playbackPhase: initialPhaseForNode(startNode), + activeHoldIndex: 0, + turnPhase: 'idle', + transcript: [{ + id: randomUUID(), + role: 'narrator', + text: startNode.prose || startNode.title || '', + timestamp: now.toISOString(), + }], + recentTts: [], + hostSocketId: null, + audienceSocketId: null, + activeTurn: null, + createdAt: now.toISOString(), + expiresAt, + }; + + activeSessions.set(sessionId, session); + + return { + session: sanitizeHostedSession(session), + token, // returned ONLY once to session creator + joinUrl, + preflight, + }; +} + +/** + * Verify a join token for a hosted session using constant-time comparison. + */ +export function verifyHostedToken(sessionId, token) { + if (!sessionId || !token || typeof token !== 'string') return false; + const session = activeSessions.get(sessionId); + if (!session || session.status !== 'active') return false; + if (new Date(session.expiresAt).getTime() <= Date.now()) { + session.status = 'ended'; + return false; + } + const candidateHash = createHash('sha256').update(token).digest('hex'); + const storedBuf = Buffer.from(session.hashedToken, 'hex'); + const candidateBuf = Buffer.from(candidateHash, 'hex'); + if (storedBuf.length !== candidateBuf.length) return false; + return timingSafeEqual(storedBuf, candidateBuf); +} + +/** + * Retrieve active session by ID. + */ +export function getHostedSession(sessionId) { + if (!sessionId) return null; + const session = activeSessions.get(sessionId); + if (!session) return null; + if (session.status === 'active' && new Date(session.expiresAt).getTime() <= Date.now()) { + session.status = 'ended'; + } + return sanitizeHostedSession(session); +} + +/** + * Internal session record retrieval for socket and service operations. + */ +export function _getInternalSession(sessionId) { + return activeSessions.get(sessionId) || null; +} + +/** + * Update hosted session state (e.g. audio target, playback phase, current node). + */ +export function updateHostedSession(sessionId, patch = {}, { io } = {}) { + const session = activeSessions.get(sessionId); + if (!session || session.status !== 'active') { + throw new ServerError('Hosted session not found or ended', { status: 404, code: 'SESSION_NOT_FOUND' }); + } + + if (patch.audioTarget && FABLELOOM_AUDIO_TARGETS.includes(patch.audioTarget)) { + session.audioTarget = patch.audioTarget; + } + if (patch.currentNodeId) { + session.currentNodeId = patch.currentNodeId; + } + if (patch.playbackPhase) { + session.playbackPhase = patch.playbackPhase; + } + if (Number.isInteger(patch.activeHoldIndex)) { + session.activeHoldIndex = patch.activeHoldIndex; + } + + const sanitized = sanitizeHostedSession(session); + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:session:sync', sanitized); + } + return sanitized; +} + +/** + * End a hosted session and notify connected clients. + */ +export function endHostedSession(sessionId, { reason = 'ended', io } = {}) { + const session = activeSessions.get(sessionId); + if (!session) return { ok: true }; + + if (session.activeTurn?.abortController) { + session.activeTurn.abortController.abort(reason); + session.activeTurn = null; + } + + session.status = 'ended'; + session.turnPhase = 'ended'; + + if (io) { + const ns = io.of('/fableloom-hosted'); + ns.to(`session:${sessionId}`).emit('hosted:session:ended', { sessionId, reason }); + } + + activeSessions.delete(sessionId); + return { ok: true }; +} + +/** + * Start audience listening phase on the session. + */ +export async function startHostedListening(sessionId, { io } = {}) { + const session = activeSessions.get(sessionId); + if (!session || session.status !== 'active') { + throw new ServerError('Session is not active', { status: 400, code: 'SESSION_INACTIVE' }); + } + + const loom = await getLoom(session.loomId); + const episode = loom.episodes?.find((e) => e.id === session.episodeId) || null; + const node = episode?.nodes?.find((n) => n.id === session.currentNodeId) || null; + const asset = resolvePlaybackPhaseAsset({ + node, + phase: session.playbackPhase, + activeHoldIndex: session.activeHoldIndex, + }); + + const gate = revalidateLiveConversationGate({ session, node, asset }); + if (!gate.allowed) { + throw new ServerError(`Live voice conversation is not permitted: ${gate.reason}`, { + status: 409, + code: gate.reason, + }); + } + + if (session.turnPhase === 'speaking' || session.turnPhase === 'thinking') { + throw new ServerError('A turn is already in progress', { status: 409, code: 'TURN_IN_PROGRESS' }); + } + + const turnId = randomUUID(); + session.turnPhase = 'listening'; + session.activeTurn = { + id: turnId, + abortController: new AbortController(), + startedAt: Date.now(), + }; + + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:phase', { + phase: 'listening', + turnId, + }); + } + + return { ok: true, turnId }; +} + +/** + * Process a completed audience utterance (audio or text) through STT -> LLM -> TTS -> State Transition. + */ +export async function processHostedUtterance(sessionId, { + audioBuffer = null, + textMessage = null, + io, +} = {}) { + const session = activeSessions.get(sessionId); + if (!session || session.status !== 'active') { + throw new ServerError('Session is not active', { status: 400, code: 'SESSION_INACTIVE' }); + } + + if (session.turnPhase !== 'listening') { + // Drop frames/utterances received outside LISTENING phase + return { dropped: true, reason: 'NOT_IN_LISTENING_PHASE' }; + } + + const turn = session.activeTurn || { id: randomUUID(), abortController: new AbortController(), startedAt: Date.now() }; + session.activeTurn = turn; + session.turnPhase = 'thinking'; + + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:phase', { + phase: 'thinking', + turnId: turn.id, + }); + } + + try { + let message = (textMessage || '').trim(); + + // 1. Transcribe audio if supplied + if (audioBuffer && (!message || !message.length)) { + let wavPayload = audioBuffer; + if (audioBuffer instanceof Int16Array || (ArrayBuffer.isView(audioBuffer) && !(audioBuffer instanceof Buffer))) { + const floatPcm = pcmToFloat(audioBuffer); + wavPayload = pcmToWavBuffer(floatPcm, { sampleRate: 16000 }); + } + const sttResult = await transcribe(wavPayload, { signal: turn.abortController.signal }).catch((err) => { + console.warn(`[HostedPlay] STT transcription failed: ${err.message}`); + return { text: '' }; + }); + message = (sttResult.text || '').trim(); + } + + // Check for echo / empty message + if (!message || isEchoOfRecentTts(message, session.recentTts)) { + session.turnPhase = 'listening'; + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:phase', { + phase: 'listening', + turnId: turn.id, + note: 'ignored-echo-or-empty', + }); + } + return { ok: true, ignored: true }; + } + + // Append audience transcript item + const audienceItem = { + id: randomUUID(), + role: 'audience', + text: message, + timestamp: new Date().toISOString(), + }; + session.transcript.push(audienceItem); + if (session.transcript.length > 50) session.transcript.shift(); + + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:transcript', audienceItem); + } + + // 2. Run LLM Play Turn + const loom = await getLoom(session.loomId); + const episode = loom.episodes?.find((e) => e.id === session.episodeId) || null; + const node = episode?.nodes?.find((n) => n.id === session.currentNodeId) || null; + + let playResult; + try { + playResult = await playTurn(session.loomId, session.episodeId, { + nodeId: session.currentNodeId, + message, + transcript: session.transcript.map((t) => ({ + role: t.role === 'audience' ? 'reader' : 'narrator', + text: t.text, + })), + }); + } catch (err) { + console.warn(`[HostedPlay] LLM turn error, using authored fallback: ${err?.message}`); + playResult = { + action: 'stay', + narration: node?.prose?.slice(0, 200) || "I hear you. Let's see what happens next.", + node: node ? publicNode(node) : null, + ended: false, + }; + } + + const narration = (playResult?.narration || '').trim() || "Let's continue."; + + // Append protagonist transcript item + const protagonistItem = { + id: randomUUID(), + role: 'protagonist', + text: narration, + timestamp: new Date().toISOString(), + audioTarget: session.audioTarget, + }; + session.transcript.push(protagonistItem); + if (session.transcript.length > 50) session.transcript.shift(); + + // 3. Synthesize Protagonist Voice + session.turnPhase = 'speaking'; + rememberTtsSentence(session.recentTts, narration); + + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:phase', { + phase: 'speaking', + turnId: turn.id, + text: narration, + }); + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:transcript', protagonistItem); + } + + // Resolve character voice + let ttsAudio = null; + try { + let universe = null; + if (loom.universeId) { + universe = await getUniverse(loom.universeId).catch(() => null); + } + const char = universe?.characters?.[0] || null; + const resolved = await resolveCharacterVoice({ + universeId: loom.universeId, + characterId: char?.id, + characterVoiceId: char?.voiceId, + route: 'interactive', + }).catch(() => null); + + const synth = await synthesize(narration, { + voice: resolved?.voiceId || undefined, + }).catch(() => null); + if (synth?.wav) { + ttsAudio = synth.wav; + } + } catch (err) { + console.warn(`[HostedPlay] Protagonist TTS synthesis failed: ${err.message}`); + } + + // 4. Dispatch Audio to designated Audio Target + if (io && ttsAudio) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:tts', { + audio: Buffer.isBuffer(ttsAudio) ? ttsAudio.toString('base64') : Buffer.from(ttsAudio).toString('base64'), + mimeType: 'audio/wav', + target: session.audioTarget, + turnId: turn.id, + }); + } + + // 5. If transition is chosen, commit it AFTER speech is recorded + if (playResult.action === 'move' && playResult.transitionId && episode) { + const targetNode = episode.nodes?.find((n) => n.id === playResult.node?.id); + if (targetNode) { + session.currentNodeId = targetNode.id; + session.playbackPhase = initialPhaseForNode(targetNode); + session.activeHoldIndex = 0; + session.turnPhase = 'idle'; + + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:story:transition', { + node: publicNode(targetNode), + transitionId: playResult.transitionId, + playbackPhase: session.playbackPhase, + }); + } + } + } else { + // Stay on current node + session.turnPhase = 'listening'; + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:phase', { + phase: 'listening', + turnId: turn.id, + }); + } + } + + session.activeTurn = null; + return { ok: true, playResult }; + } catch (err) { + session.turnPhase = 'idle'; + session.activeTurn = null; + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:error', { + code: err?.code || 'TURN_FAILED', + message: err?.message || String(err), + }); + } + throw err; + } +} + +/** + * Abort current in-flight turn. + */ +export function abortHostedTurn(sessionId, { reason = 'aborted', io } = {}) { + const session = activeSessions.get(sessionId); + if (!session) return { ok: true }; + + if (session.activeTurn?.abortController) { + session.activeTurn.abortController.abort(reason); + session.activeTurn = null; + } + session.turnPhase = 'idle'; + + if (io) { + io.of('/fableloom-hosted').to(`session:${sessionId}`).emit('hosted:turn:phase', { + phase: 'idle', + reason, + }); + } + return { ok: true }; +} + +/** + * Test helper to reset in-memory sessions between test runs. + */ +export function _resetHostedSessions() { + for (const session of activeSessions.values()) { + if (session.activeTurn?.abortController) { + session.activeTurn.abortController.abort('reset'); + } + } + activeSessions.clear(); +} diff --git a/server/services/fableLoom/hostedSession.test.js b/server/services/fableLoom/hostedSession.test.js new file mode 100644 index 0000000000..e5ec17b429 --- /dev/null +++ b/server/services/fableLoom/hostedSession.test.js @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + _getInternalSession, + _resetHostedSessions, + abortHostedTurn, + checkHostedSessionReadiness, + createHostedSession, + endHostedSession, + getHostedSession, + initialPhaseForNode, + processHostedUtterance, + revalidateLiveConversationGate, + sanitizeHostedSession, + startHostedListening, + updateHostedSession, + verifyHostedToken, +} from './hostedSession.js'; +import * as records from './records.js'; +import * as weave from './weave.js'; +import * as networkExposure from '../../lib/networkExposure.js'; +import * as tts from '../voice/tts.js'; +import * as stt from '../voice/stt.js'; + +describe('fableLoom hostedSession', () => { + const mockLoom = { + id: 'loom-1', + name: 'Dragon Quest', + format: 'prose', + participationMode: 'helper', + universeId: 'universe-1', + episodes: [{ + id: 'ep-1', + title: 'Episode 1', + startNodeId: 'node-start', + nodes: [ + { + id: 'node-start', + title: 'Forest Entrance', + prose: 'You stand at the edge of the dark forest.', + playbackMode: 'decision', + audienceConnection: 'connected', + protagonistPresence: 'offscreen', + isEnding: false, + playbackAssets: { + holdLoopVideoHistoryIds: ['vid-hold-1'], + audioOccupancy: { + 'vid-hold-1': { durationMs: 5000, characterDialogue: [], music: [], effects: [], safeForLiveVoice: true }, + }, + }, + transitions: [{ id: 'tr-1', targetNodeId: 'node-2', intent: 'enter forest', triggers: ['go into forest'] }], + }, + { + id: 'node-2', + title: 'Deep Woods', + prose: 'The trees tower above you.', + playbackMode: 'decision', + audienceConnection: 'connected', + protagonistPresence: 'offscreen', + isEnding: false, + transitions: [], + }, + ], + }], + }; + + beforeEach(() => { + _resetHostedSessions(); + vi.restoreAllMocks(); + vi.spyOn(records, 'getLoom').mockResolvedValue(mockLoom); + vi.spyOn(tts, 'synthesize').mockResolvedValue({ + wav: Buffer.from('RIFFmockwavdata'), + latencyMs: 50, + engine: 'kokoro', + }); + vi.spyOn(stt, 'transcribe').mockResolvedValue({ + text: 'I want to enter the forest', + latencyMs: 100, + }); + }); + + describe('initialPhaseForNode', () => { + it('returns ended for ending nodes', () => { + expect(initialPhaseForNode({ isEnding: true })).toBe('ended'); + }); + + it('returns entry when entry clip is present', () => { + expect(initialPhaseForNode({ playbackAssets: { entryVideoHistoryId: 'vid-entry-1' } })).toBe('entry'); + }); + + it('returns hold when hold loops exist', () => { + expect(initialPhaseForNode({ playbackAssets: { holdLoopVideoHistoryIds: ['vid-hold-1'] } })).toBe('hold'); + }); + }); + + describe('checkHostedSessionReadiness', () => { + it('passes readiness when loom, episode, and start scene are configured', async () => { + const result = await checkHostedSessionReadiness({ loomId: 'loom-1', episodeId: 'ep-1' }); + expect(result.ready).toBe(true); + expect(result.https.url).toMatch(/^https?:\/\//); + expect(result.checks.host.ok).toBe(true); + }); + + it('flags error if start scene is missing', async () => { + const badLoom = { + ...mockLoom, + episodes: [{ id: 'ep-1', startNodeId: 'missing-node', nodes: [] }], + }; + vi.spyOn(records, 'getLoom').mockResolvedValue(badLoom); + const result = await checkHostedSessionReadiness({ loomId: 'loom-1', episodeId: 'ep-1' }); + expect(result.ready).toBe(false); + expect(result.errors).toContain('Episode does not have a valid start scene configured.'); + }); + }); + + describe('createHostedSession & verifyHostedToken', () => { + it('creates an active hosted session with hashed token and fragment join URL', async () => { + const result = await createHostedSession('loom-1', 'ep-1', { audioTarget: 'host' }); + expect(result.session).toBeDefined(); + expect(result.session.id).toBeDefined(); + expect(result.session.status).toBe('active'); + expect(result.session.audioTarget).toBe('host'); + expect(result.session.currentNodeId).toBe('node-start'); + expect(result.token).toBeDefined(); + expect(result.token.length).toBe(64); // 256 bits hex + expect(result.joinUrl).toContain(`#session=${result.session.id}&token=${result.token}`); + + // Internal storage verifies hashed token + const internal = _getInternalSession(result.session.id); + expect(internal.hashedToken).toBeDefined(); + expect(internal.hashedToken).not.toBe(result.token); // Hashed, not plaintext + + // Sanitized session omits hashedToken + const sanitized = getHostedSession(result.session.id); + expect(sanitized.hashedToken).toBeUndefined(); + + // Verify token + expect(verifyHostedToken(result.session.id, result.token)).toBe(true); + expect(verifyHostedToken(result.session.id, 'wrong-token')).toBe(false); + expect(verifyHostedToken('missing-session', result.token)).toBe(false); + }); + }); + + describe('revalidateLiveConversationGate', () => { + it('allows live conversation for connected helper decision hold scene with offscreen protagonist', () => { + const session = { status: 'active', playbackPhase: 'hold' }; + const node = { + audienceConnection: 'connected', + playbackMode: 'decision', + protagonistPresence: 'offscreen', + isEnding: false, + }; + const asset = { manifest: { safeForLiveVoice: true } }; + + const gate = revalidateLiveConversationGate({ session, node, asset }); + expect(gate.allowed).toBe(true); + }); + + it('rejects if scene audience is disconnected', () => { + const session = { status: 'active', playbackPhase: 'hold' }; + const node = { audienceConnection: 'disconnected', playbackMode: 'decision', protagonistPresence: 'offscreen' }; + expect(revalidateLiveConversationGate({ session, node }).allowed).toBe(false); + }); + + it('rejects if protagonist is onscreen', () => { + const session = { status: 'active', playbackPhase: 'hold' }; + const node = { audienceConnection: 'connected', playbackMode: 'decision', protagonistPresence: 'onscreen' }; + expect(revalidateLiveConversationGate({ session, node }).allowed).toBe(false); + }); + + it('rejects if hold asset has blocking character dialogue', () => { + const session = { status: 'active', playbackPhase: 'hold' }; + const node = { audienceConnection: 'connected', playbackMode: 'decision', protagonistPresence: 'offscreen' }; + const asset = { manifest: { safeForLiveVoice: false } }; + expect(revalidateLiveConversationGate({ session, node, asset }).allowed).toBe(false); + }); + }); + + describe('half-duplex turn execution', () => { + it('executes full speech-first turn and commits story transition', async () => { + const { session, token } = await createHostedSession('loom-1', 'ep-1'); + const mockIo = { + of: () => ({ + to: () => ({ + emit: vi.fn(), + }), + }), + }; + + // 1. Start listening + const listenRes = await startHostedListening(session.id, { io: mockIo }); + expect(listenRes.ok).toBe(true); + expect(getHostedSession(session.id).turnPhase).toBe('listening'); + + // Mock LLM play response + vi.spyOn(weave, 'playTurn').mockResolvedValue({ + action: 'move', + transitionId: 'tr-1', + narration: 'We shall enter the dark woods together.', + node: { id: 'node-2', title: 'Deep Woods' }, + }); + + // 2. Process audience utterance + const turnRes = await processHostedUtterance(session.id, { + audioBuffer: Buffer.from('fake-audio-bytes'), + io: mockIo, + }); + + expect(turnRes.ok).toBe(true); + const afterSession = getHostedSession(session.id); + expect(afterSession.currentNodeId).toBe('node-2'); // Moved to next node + expect(afterSession.transcript.length).toBeGreaterThan(1); + }); + + it('drops audio frames sent outside listening phase', async () => { + const { session } = await createHostedSession('loom-1', 'ep-1'); + // session is currently idle + const res = await processHostedUtterance(session.id, { + audioBuffer: Buffer.from('dropped'), + }); + expect(res.dropped).toBe(true); + expect(res.reason).toBe('NOT_IN_LISTENING_PHASE'); + }); + + it('aborts active turn on request', async () => { + const { session } = await createHostedSession('loom-1', 'ep-1'); + await startHostedListening(session.id); + expect(getHostedSession(session.id).turnPhase).toBe('listening'); + + abortHostedTurn(session.id); + expect(getHostedSession(session.id).turnPhase).toBe('idle'); + }); + }); + + describe('session teardown', () => { + it('ends hosted session cleanly', async () => { + const { session } = await createHostedSession('loom-1', 'ep-1'); + expect(getHostedSession(session.id)).not.toBeNull(); + + endHostedSession(session.id, { reason: 'user_ended' }); + expect(getHostedSession(session.id)).toBeNull(); + }); + }); +}); diff --git a/server/services/fableLoom/index.js b/server/services/fableLoom/index.js index 01d8f5be46..a317545ef8 100644 --- a/server/services/fableLoom/index.js +++ b/server/services/fableLoom/index.js @@ -52,3 +52,22 @@ export { asLoomFormat, isLoomFormat, } from './formats.js'; +export { + DEFAULT_SESSION_TTL_MINUTES, + MAX_SESSION_TTL_MINUTES, + _getInternalSession, + _resetHostedSessions, + abortHostedTurn, + checkHostedSessionReadiness, + createHostedSession, + endHostedSession, + getHostedSession, + initialPhaseForNode, + processHostedUtterance, + revalidateLiveConversationGate, + sanitizeHostedSession, + startHostedListening, + updateHostedSession, + verifyHostedToken, +} from './hostedSession.js'; + diff --git a/server/services/socket.js b/server/services/socket.js index 74b3010e11..cf9ab832a6 100644 --- a/server/services/socket.js +++ b/server/services/socket.js @@ -32,6 +32,7 @@ import { } from '../lib/socketValidation.js'; import { registerVoiceHandlers } from '../sockets/voice.js'; import { registerAppHandlers } from '../sockets/apps.js'; +import { registerFableLoomHostedNamespace } from '../sockets/fableLoomHosted.js'; import { cleanupSocketStreams, registerLogHandlers } from '../sockets/logs.js'; import { detachShellSocket, registerShellHandlers } from '../sockets/shell.js'; import { getBuildId } from '../lib/buildId.js'; @@ -233,6 +234,7 @@ function setupCallStateEventForwarding() { export function initSocket(io) { registerAuthRevocationHandler(io); + registerFableLoomHostedNamespace(io); io.on('connection', (socket) => { console.log(`🔌 Client connected: ${socket.id}`); diff --git a/server/sockets/fableLoomHosted.js b/server/sockets/fableLoomHosted.js new file mode 100644 index 0000000000..76e1bbe405 --- /dev/null +++ b/server/sockets/fableLoomHosted.js @@ -0,0 +1,214 @@ +/** + * Dedicated Socket.IO namespace for FableLoom QR-hosted play sessions (#5383). + * + * Scoped to `/fableloom-hosted`. A guest connection authenticated with a + * single-use QR token can ONLY access its designated session room and never + * acquires general PortOS auth privileges or access to other socket events. + */ + +import { + _getInternalSession, + abortHostedTurn, + endHostedSession, + getHostedSession, + processHostedUtterance, + sanitizeHostedSession, + startHostedListening, + updateHostedSession, + verifyHostedToken, +} from '../services/fableLoom/hostedSession.js'; + +let hostedNsInstance = null; + +export function getHostedNamespace() { + return hostedNsInstance; +} + +export function registerFableLoomHostedNamespace(io) { + if (!io || typeof io.of !== 'function') return null; + + const ns = io.of('/fableloom-hosted'); + hostedNsInstance = ns; + + // Handshake authentication middleware for /fableloom-hosted + ns.use(async (socket, next) => { + try { + const auth = socket.handshake.auth || {}; + const query = socket.handshake.query || {}; + const sessionId = auth.sessionId || query.sessionId; + const token = auth.token || query.token; + const role = auth.role || query.role || 'audience'; + + if (!sessionId || typeof sessionId !== 'string') { + return next(new Error('SESSION_ID_REQUIRED')); + } + + const session = _getInternalSession(sessionId); + if (!session || session.status !== 'active') { + return next(new Error('HOSTED_SESSION_NOT_FOUND_OR_EXPIRED')); + } + + if (role === 'host') { + socket.hostedRole = 'host'; + socket.hostedSessionId = sessionId; + return next(); + } + + // Audience role requires valid join token + if (!token || !verifyHostedToken(sessionId, token)) { + return next(new Error('HOSTED_SESSION_UNAUTHORIZED')); + } + + socket.hostedRole = 'audience'; + socket.hostedSessionId = sessionId; + return next(); + } catch (err) { + return next(new Error(`AUTH_ERROR: ${err?.message || err}`)); + } + }); + + ns.on('connection', (socket) => { + const { hostedSessionId: sessionId, hostedRole: role } = socket; + const room = `session:${sessionId}`; + socket.join(room); + + const session = _getInternalSession(sessionId); + if (!session) { + socket.disconnect(true); + return; + } + + if (role === 'host') { + session.hostSocketId = socket.id; + } else if (role === 'audience') { + // Single audience device policy: if an existing audience socket was connected, notify/replace + if (session.audienceSocketId && session.audienceSocketId !== socket.id) { + const oldSocket = ns.sockets.get(session.audienceSocketId); + if (oldSocket) { + oldSocket.emit('hosted:session:replaced', { reason: 'Another audience device joined this session.' }); + oldSocket.disconnect(true); + } + } + session.audienceSocketId = socket.id; + } + + // Emit initial sync snapshot to newly connected client + socket.emit('hosted:session:sync', sanitizeHostedSession(session)); + + // Broadcast peer connection status to the room + ns.to(room).emit('hosted:peer:status', { + hasHostConnected: !!session.hostSocketId, + hasAudienceConnected: !!session.audienceSocketId, + role, + }); + + // Inbound frame buffer for streaming mic chunks + let micChunks = []; + + // --- Host actions --- + socket.on('hosted:playback:update', (data) => { + if (socket.hostedRole !== 'host') return; + try { + const updated = updateHostedSession(sessionId, { + playbackPhase: data?.phase, + activeHoldIndex: data?.activeHoldIndex, + currentNodeId: data?.nodeId, + }, { io }); + ns.to(room).emit('hosted:playback:sync', { + phase: updated.playbackPhase, + activeHoldIndex: updated.activeHoldIndex, + nodeId: updated.currentNodeId, + }); + } catch (err) { + socket.emit('hosted:error', { code: 'UPDATE_FAILED', message: err.message }); + } + }); + + socket.on('hosted:audio:target', (data) => { + if (socket.hostedRole !== 'host') return; + try { + const updated = updateHostedSession(sessionId, { audioTarget: data?.target }, { io }); + ns.to(room).emit('hosted:audio:target:updated', { audioTarget: updated.audioTarget }); + } catch (err) { + socket.emit('hosted:error', { code: 'TARGET_UPDATE_FAILED', message: err.message }); + } + }); + + socket.on('hosted:session:end', () => { + if (socket.hostedRole !== 'host') return; + endHostedSession(sessionId, { reason: 'host_ended', io }); + }); + + // --- Audience actions --- + socket.on('hosted:mic:start', async () => { + micChunks = []; + try { + await startHostedListening(sessionId, { io }); + } catch (err) { + socket.emit('hosted:error', { code: err.code || 'MIC_START_FAILED', message: err.message }); + } + }); + + socket.on('hosted:mic:frame', (chunk) => { + if (!session || session.turnPhase !== 'listening') return; + if (chunk) { + micChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + }); + + socket.on('hosted:mic:stop', async (completeBuffer) => { + if (!session || session.turnPhase !== 'listening') return; + let finalAudio = null; + if (completeBuffer && (Buffer.isBuffer(completeBuffer) || ArrayBuffer.isView(completeBuffer))) { + finalAudio = Buffer.isBuffer(completeBuffer) ? completeBuffer : Buffer.from(completeBuffer); + } else if (micChunks.length > 0) { + finalAudio = Buffer.concat(micChunks); + } + micChunks = []; + + try { + await processHostedUtterance(sessionId, { audioBuffer: finalAudio, io }); + } catch (err) { + socket.emit('hosted:error', { code: err.code || 'UTTERANCE_FAILED', message: err.message }); + } + }); + + socket.on('hosted:turn:text', async (data) => { + if (!session || session.turnPhase !== 'listening') return; + try { + await processHostedUtterance(sessionId, { textMessage: data?.text, io }); + } catch (err) { + socket.emit('hosted:error', { code: err.code || 'TEXT_FAILED', message: err.message }); + } + }); + + socket.on('hosted:speech:done', (_data) => { + // Speech finished playback on output target + if (session && session.turnPhase === 'speaking') { + session.turnPhase = 'listening'; + ns.to(room).emit('hosted:turn:phase', { phase: 'listening' }); + } + }); + + socket.on('hosted:turn:abort', () => { + abortHostedTurn(sessionId, { reason: 'client_aborted', io }); + }); + + // --- Disconnect lifecycle --- + socket.on('disconnect', () => { + if (socket.hostedRole === 'host' && session.hostSocketId === socket.id) { + session.hostSocketId = null; + } else if (socket.hostedRole === 'audience' && session.audienceSocketId === socket.id) { + session.audienceSocketId = null; + } + ns.to(room).emit('hosted:peer:status', { + hasHostConnected: !!session.hostSocketId, + hasAudienceConnected: !!session.audienceSocketId, + disconnectedRole: socket.hostedRole, + }); + socket.removeAllListeners(); + }); + }); + + return ns; +} diff --git a/server/sockets/fableLoomHosted.test.js b/server/sockets/fableLoomHosted.test.js new file mode 100644 index 0000000000..9999d64f08 --- /dev/null +++ b/server/sockets/fableLoomHosted.test.js @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + registerFableLoomHostedNamespace, + getHostedNamespace, +} from './fableLoomHosted.js'; +import { + _resetHostedSessions, + createHostedSession, + getHostedSession, +} from '../services/fableLoom/hostedSession.js'; +import * as records from '../services/fableLoom/records.js'; +import * as tts from '../services/voice/tts.js'; +import * as stt from '../services/voice/stt.js'; + +describe('fableLoomHosted Socket.IO namespace', () => { + const mockLoom = { + id: 'loom-1', + name: 'Story 1', + format: 'prose', + participationMode: 'helper', + episodes: [{ + id: 'ep-1', + title: 'Episode 1', + startNodeId: 'node-1', + nodes: [{ + id: 'node-1', + title: 'Start', + prose: 'Opening prose', + playbackMode: 'decision', + audienceConnection: 'connected', + protagonistPresence: 'offscreen', + isEnding: false, + playbackAssets: { holdLoopVideoHistoryIds: ['vid-1'] }, + transitions: [{ id: 'tr-1', targetNodeId: 'node-2', intent: 'go next' }], + }], + }], + }; + + let mockIo; + let middleware; + let connectionHandler; + let mockNamespace; + let roomEvents; + + beforeEach(() => { + _resetHostedSessions(); + vi.restoreAllMocks(); + vi.spyOn(records, 'getLoom').mockResolvedValue(mockLoom); + vi.spyOn(tts, 'synthesize').mockResolvedValue({ wav: Buffer.from('mockwav'), latencyMs: 20 }); + vi.spyOn(stt, 'transcribe').mockResolvedValue({ text: 'go next', latencyMs: 50 }); + + roomEvents = []; + mockNamespace = { + sockets: new Map(), + use: vi.fn((fn) => { middleware = fn; }), + on: vi.fn((evt, fn) => { + if (evt === 'connection') connectionHandler = fn; + }), + to: vi.fn((room) => ({ + emit: vi.fn((event, data) => { + roomEvents.push({ room, event, data }); + }), + })), + }; + + mockIo = { + of: vi.fn(() => mockNamespace), + }; + + registerFableLoomHostedNamespace(mockIo); + }); + + it('registers namespace at /fableloom-hosted', () => { + expect(mockIo.of).toHaveBeenCalledWith('/fableloom-hosted'); + expect(getHostedNamespace()).toBe(mockNamespace); + expect(middleware).toBeDefined(); + expect(connectionHandler).toBeDefined(); + }); + + describe('handshake auth middleware', () => { + it('rejects connection without sessionId', async () => { + const socket = { handshake: { auth: {} } }; + const next = vi.fn(); + await middleware(socket, next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + expect(next.mock.calls[0][0].message).toBe('SESSION_ID_REQUIRED'); + }); + + it('rejects audience connection with missing/invalid token', async () => { + const { session } = await createHostedSession('loom-1', 'ep-1'); + const socket = { + handshake: { + auth: { sessionId: session.id, role: 'audience', token: 'bad-token' }, + }, + }; + const next = vi.fn(); + await middleware(socket, next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + expect(next.mock.calls[0][0].message).toBe('HOSTED_SESSION_UNAUTHORIZED'); + }); + + it('allows audience connection with valid token', async () => { + const { session, token } = await createHostedSession('loom-1', 'ep-1'); + const socket = { + handshake: { + auth: { sessionId: session.id, role: 'audience', token }, + }, + }; + const next = vi.fn(); + await middleware(socket, next); + expect(next).toHaveBeenCalledWith(); + expect(socket.hostedRole).toBe('audience'); + expect(socket.hostedSessionId).toBe(session.id); + }); + + it('allows host connection with valid sessionId', async () => { + const { session } = await createHostedSession('loom-1', 'ep-1'); + const socket = { + handshake: { + auth: { sessionId: session.id, role: 'host' }, + }, + }; + const next = vi.fn(); + await middleware(socket, next); + expect(next).toHaveBeenCalledWith(); + expect(socket.hostedRole).toBe('host'); + expect(socket.hostedSessionId).toBe(session.id); + }); + }); + + describe('socket event exchange', () => { + it('synchronizes session on connection and handles events', async () => { + const { session, token } = await createHostedSession('loom-1', 'ep-1'); + const listeners = {}; + const emitted = []; + + const socket = { + id: 'sock-1', + hostedRole: 'audience', + hostedSessionId: session.id, + join: vi.fn(), + emit: vi.fn((event, data) => emitted.push({ event, data })), + on: vi.fn((event, fn) => { listeners[event] = fn; }), + removeAllListeners: vi.fn(), + }; + + connectionHandler(socket); + + expect(socket.join).toHaveBeenCalledWith(`session:${session.id}`); + expect(emitted.find((e) => e.event === 'hosted:session:sync')).toBeDefined(); + + // Trigger mic:start + expect(listeners['hosted:mic:start']).toBeDefined(); + await listeners['hosted:mic:start'](); + expect(getHostedSession(session.id).turnPhase).toBe('listening'); + + // Trigger mic:stop with text/audio + expect(listeners['hosted:turn:text']).toBeDefined(); + await listeners['hosted:turn:text']({ text: 'go next' }); + + // Disconnect + expect(listeners.disconnect).toBeDefined(); + listeners.disconnect(); + expect(socket.removeAllListeners).toHaveBeenCalled(); + }); + }); +});