Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions server/services/fableLoom/hostedSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,11 @@ export async function checkHostedSessionReadiness({ loomId, episodeId, loom: cus

// 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 isHttps = netStatus.scheme === 'https';
const httpsUrl = isHttps
? `https://${joinHost}${joinPort === 443 ? '' : `:${joinPort}`}`
: `http://${joinHost}${joinPort === 80 ? '' : `:${joinPort}`}`;
Expand Down
44 changes: 42 additions & 2 deletions server/services/fableLoom/hostedSession.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,27 @@ describe('fableLoom hostedSession', () => {
}],
};

// Hosted-session preflight gates on the live network posture. Every test
// that isn't specifically exercising the HTTPS gate runs against this
// TLS-provisioned snapshot so the rest of the readiness checks are what
// the assertion is about.
const httpsExposure = () => ({
scheme: 'https',
httpsEnabled: true,
bind: { host: '0.0.0.0', port: 5555, audience: 'all-interfaces' },
cert: { mode: 'tailscale', tailscaleHost: 'host-example.example-tailnet.ts.net' },
});

const httpExposure = () => ({
...httpsExposure(),
scheme: 'http',
httpsEnabled: false,
});

beforeEach(() => {
_resetHostedSessions();
vi.restoreAllMocks();
vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockImplementation(httpsExposure);
vi.spyOn(records, 'getLoom').mockResolvedValue(mockLoom);
vi.spyOn(tts, 'synthesize').mockResolvedValue({
wav: Buffer.from('RIFFmockwavdata'),
Expand Down Expand Up @@ -97,13 +115,27 @@ describe('fableLoom hostedSession', () => {
});

describe('checkHostedSessionReadiness', () => {
it('passes readiness when loom, episode, and start scene are configured', async () => {
it('passes readiness when loom, episode, and start scene are configured over HTTPS', async () => {
const result = await checkHostedSessionReadiness({ loomId: 'loom-1', episodeId: 'ep-1' });
expect(result.ready).toBe(true);
expect(result.https.url).toMatch(/^https?:\/\//);
expect(result.https.enabled).toBe(true);
expect(result.https.url).toMatch(/^https:\/\//);
expect(result.checks.https.ok).toBe(true);
expect(result.checks.host.ok).toBe(true);
});

it('flags error when the install is serving plain HTTP', async () => {
vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockImplementation(httpExposure);
const result = await checkHostedSessionReadiness({ loomId: 'loom-1', episodeId: 'ep-1' });
expect(result.ready).toBe(false);
expect(result.https.enabled).toBe(false);
expect(result.checks.https.ok).toBe(false);
expect(result.https.url).toMatch(/^http:\/\//);
expect(result.errors).toContain(
'HTTPS is required for mobile device QR microphone join (run npm run setup:cert to enable TLS).',
);
});

it('flags error if start scene is missing', async () => {
const badLoom = {
...mockLoom,
Expand Down Expand Up @@ -142,6 +174,14 @@ describe('fableLoom hostedSession', () => {
expect(verifyHostedToken(result.session.id, 'wrong-token')).toBe(false);
expect(verifyHostedToken('missing-session', result.token)).toBe(false);
});

it('refuses to start a session on an HTTP-only install with a 412 preflight failure', async () => {
vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockImplementation(httpExposure);
await expect(createHostedSession('loom-1', 'ep-1', { audioTarget: 'host' })).rejects.toMatchObject({
status: 412,
code: 'HOSTED_SESSION_PREFLIGHT_FAILED',
});
});
});

describe('revalidateLiveConversationGate', () => {
Expand Down
9 changes: 9 additions & 0 deletions server/sockets/fableLoomHosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getHostedSession,
} from '../services/fableLoom/hostedSession.js';
import * as records from '../services/fableLoom/records.js';
import * as networkExposure from '../lib/networkExposure.js';
import * as tts from '../services/voice/tts.js';
import * as stt from '../services/voice/stt.js';

Expand Down Expand Up @@ -45,6 +46,14 @@ describe('fableLoomHosted Socket.IO namespace', () => {
beforeEach(() => {
_resetHostedSessions();
vi.restoreAllMocks();
// createHostedSession runs the readiness preflight, which refuses to start
// a session unless the install is serving HTTPS.
vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockReturnValue({
scheme: 'https',
httpsEnabled: true,
bind: { host: '0.0.0.0', port: 5555, audience: 'all-interfaces' },
cert: { mode: 'tailscale', tailscaleHost: 'host-example.example-tailnet.ts.net' },
});
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 });
Expand Down