diff --git a/changelog.d/151.fixed.md b/changelog.d/151.fixed.md new file mode 100644 index 0000000..37f9512 --- /dev/null +++ b/changelog.d/151.fixed.md @@ -0,0 +1 @@ +Presenter BroadcastChannel traffic is now scoped to an ephemeral workbench session, and prompter popout URLs carry that scope so independent same-origin presentations do not drive each other. diff --git a/src/main.ts b/src/main.ts index 6344af9..650c8a0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,6 +61,7 @@ import { createKeyboardPresenterSource, createPracticeRenderer, createPresenterBridge, + getPresenterSessionId, } from './system/presenter'; import { defaultTransitions } from './system/transitions'; import { WORKBENCH_COMPOSITIONS, WORKBENCH_SCENES } from './workbench-graph'; @@ -372,13 +373,17 @@ const presenterKeyboard = createKeyboardPresenterSource({ }, }); -// Cross-window bridge: same-origin pulsar windows (present + popped- -// out prompter) share `BroadcastChannel('pulsar-presenter')` so a -// keystroke in either window drives the same controller. Every local -// keyboard command is broadcast outbound; inbound commands fan into -// the loader alongside the local keyboard source via +// Cross-window bridge: same-origin pulsar windows in this workbench +// session (present + popped-out prompter) share a scoped presenter +// BroadcastChannel so a keystroke in either window drives the same +// controller without leaking to another local presentation. Every +// local keyboard command is broadcast outbound; inbound commands fan +// into the loader alongside the local keyboard source via // `combinePresenterSources`. -const presenterBridge: PresenterBridgeHandle = createPresenterBridge(); +const presenterSessionId = getPresenterSessionId(); +const presenterBridge: PresenterBridgeHandle = createPresenterBridge({ + sessionId: presenterSessionId, +}); presenterKeyboard.source.subscribe((cmd) => presenterBridge.send(cmd)); const combinedPresenterSource = combinePresenterSources( presenterKeyboard.source, diff --git a/src/system/presenter/bridge.ts b/src/system/presenter/bridge.ts index 78d2eb4..977d856 100644 --- a/src/system/presenter/bridge.ts +++ b/src/system/presenter/bridge.ts @@ -1,11 +1,11 @@ // Pulsar L2 presenter — cross-window command bridge. // -// Same-origin windows (the present window + a popped-out prompter -// window) share a `BroadcastChannel('pulsar-presenter')` so a keystroke -// in either drives the same `PresenterController`. The bridge is -// symmetric: each window subscribes for inbound commands AND emits -// local keyboard events outbound, but a window does NOT re-broadcast -// commands it received over the channel (no echo loop). +// Same-origin windows in one workbench session (the present window + a +// popped-out prompter window) share a scoped presenter BroadcastChannel +// so a keystroke in either drives the same `PresenterController`. The +// bridge is symmetric: each window subscribes for inbound commands AND +// emits local keyboard events outbound, but a window does NOT re- +// broadcast commands it received over the channel (no echo loop). // // Use `combinePresenterSources` to merge multiple sources (keyboard + // bridge.source) into one source the loader can subscribe to. @@ -16,12 +16,55 @@ import { isPresenterCommand, } from '../../runtime/presenter'; -/** Default channel name used by every pulsar workbench. */ +/** Base channel prefix; runtime bridges append a per-session scope. */ export const DEFAULT_PRESENTER_CHANNEL = 'pulsar-presenter'; +export const PRESENTER_SESSION_QUERY_PARAM = 'pulsar-presenter-session'; + +const PRESENTER_SESSION_ID_FORM = /^[A-Za-z0-9_-]{8,128}$/; +let resolvedPresenterSessionId: string | null = null; + +export const isPresenterSessionId = (value: string): boolean => + PRESENTER_SESSION_ID_FORM.test(value); + +export const presenterChannelName = (sessionId: string): string => { + if (!isPresenterSessionId(sessionId)) { + throw new Error('presenter session id must be 8-128 URL-safe characters'); + } + return `${DEFAULT_PRESENTER_CHANNEL}:${sessionId}`; +}; + +const presenterSessionIdFromLocation = (location: Pick): string | null => { + let url: URL; + try { + url = new URL(location.href); + } catch { + return null; + } + const value = url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM); + if (value === null || !isPresenterSessionId(value)) return null; + return value; +}; + +const createPresenterSessionId = (): string => { + const crypto = globalThis.crypto; + if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID(); + throw new Error('secure random presenter session id source is unavailable'); +}; + +export const getPresenterSessionId = ( + location: Pick | undefined = globalThis.window?.location, +): string => { + if (resolvedPresenterSessionId !== null) return resolvedPresenterSessionId; + const fromLocation = location === undefined ? null : presenterSessionIdFromLocation(location); + resolvedPresenterSessionId = fromLocation ?? createPresenterSessionId(); + return resolvedPresenterSessionId; +}; export interface PresenterBridgeOptions { - /** Channel name. Defaults to {@link DEFAULT_PRESENTER_CHANNEL}. */ + /** Exact channel name override. Supplying this bypasses session scoping. */ readonly channelName?: string; + /** Per-workbench session id used to scope the default presenter channel. */ + readonly sessionId?: string; /** Optional sink for non-fatal errors (bad message, channel close). */ readonly onError?: (err: unknown) => void; } @@ -48,12 +91,16 @@ export interface PresenterBridgeHandle { export const createPresenterBridge = ( options: PresenterBridgeOptions = {}, ): PresenterBridgeHandle => { - const channelName = options.channelName ?? DEFAULT_PRESENTER_CHANNEL; const handlers = new Set<(cmd: PresenterCommand) => void>(); let disposed = false; // Guard for older runtimes / SSR / Node tests. const BC = (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel; - const ch = typeof BC === 'function' ? new BC(channelName) : null; + const ch = + typeof BC === 'function' + ? new BC( + options.channelName ?? presenterChannelName(options.sessionId ?? getPresenterSessionId()), + ) + : null; if (ch !== null) { ch.onmessage = (event) => { const data = (event as MessageEvent).data; diff --git a/src/system/presenter/index.ts b/src/system/presenter/index.ts index f3306c8..a75a7d4 100644 --- a/src/system/presenter/index.ts +++ b/src/system/presenter/index.ts @@ -14,10 +14,15 @@ export { createPracticeRenderer, splitCaptionText } from './practice-renderer'; export type { PracticeRendererHandle, PracticeRendererHost } from './practice-renderer'; export { createChromePrompterRenderer, openPrompterWindow } from './prompter-window'; +export type { PrompterWindowOptions } from './prompter-window'; export { combinePresenterSources, createPresenterBridge, DEFAULT_PRESENTER_CHANNEL, + getPresenterSessionId, + isPresenterSessionId, + PRESENTER_SESSION_QUERY_PARAM, + presenterChannelName, } from './bridge'; export type { PresenterBridgeHandle, PresenterBridgeOptions } from './bridge'; diff --git a/src/system/presenter/prompter-window.ts b/src/system/presenter/prompter-window.ts index 0e89714..ceba0fe 100644 --- a/src/system/presenter/prompter-window.ts +++ b/src/system/presenter/prompter-window.ts @@ -11,13 +11,23 @@ // prompter window just needs to open the same URL with mode=prompter. import type { PrompterRenderer, PrompterScript } from '../../runtime/prompter'; +import { + PRESENTER_SESSION_QUERY_PARAM, + getPresenterSessionId, + isPresenterSessionId, +} from './bridge'; const DEFAULT_PROMPTER_WINDOW_FEATURES = 'width=900,height=700,menubar=no,toolbar=no'; const POPUP_ISOLATION_FEATURES = ['noopener', 'noreferrer'] as const; +export interface PrompterWindowOptions { + readonly presenterSessionId?: string; +} + const buildPrompterUrl = ( baseUrl: string, location: Pick, + presenterSessionId: string, ): string | null => { let url: URL; try { @@ -28,6 +38,7 @@ const buildPrompterUrl = ( if (url.origin !== location.origin) return null; url.searchParams.set('mode', 'prompter'); + url.searchParams.set(PRESENTER_SESSION_QUERY_PARAM, presenterSessionId); return url.href; }; @@ -62,11 +73,15 @@ const isolateOpenedWindow = (opened: Window | null): Window | null => { export const openPrompterWindow = ( baseUrl: string, features = DEFAULT_PROMPTER_WINDOW_FEATURES, + options: PrompterWindowOptions = {}, ): Window | null => { const win = globalThis.window; if (win === undefined) return null; - const url = buildPrompterUrl(baseUrl, win.location); + const presenterSessionId = options.presenterSessionId ?? getPresenterSessionId(); + if (!isPresenterSessionId(presenterSessionId)) return null; + + const url = buildPrompterUrl(baseUrl, win.location, presenterSessionId); if (url === null) return null; const opened = win.open(url, '_blank', withPopupIsolationFeatures(features)); diff --git a/tests/system/presenter-bridge.test.ts b/tests/system/presenter-bridge.test.ts index 7c23618..b103b5e 100644 --- a/tests/system/presenter-bridge.test.ts +++ b/tests/system/presenter-bridge.test.ts @@ -11,12 +11,17 @@ import { DEFAULT_PRESENTER_CHANNEL, combinePresenterSources, createPresenterBridge, + presenterChannelName, } from '../../src/system/presenter/bridge'; // BroadcastChannel delivers messages on a macrotask; wait one out. const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 10)); const advance: PresenterCommand = { kind: 'advance' }; +const importFreshBridge = async (): Promise => { + vi.resetModules(); + return import('../../src/system/presenter/bridge'); +}; describe('createPresenterBridge', () => { const bridges: { dispose(): void }[] = []; @@ -27,12 +32,53 @@ describe('createPresenterBridge', () => { afterEach(() => { for (const b of bridges) b.dispose(); bridges.length = 0; + vi.unstubAllGlobals(); }); it('exposes the default channel name', () => { expect(DEFAULT_PRESENTER_CHANNEL).toBe('pulsar-presenter'); }); + it('derives scoped channel names from the presenter session id', () => { + expect(presenterChannelName('session-a-123456')).toBe('pulsar-presenter:session-a-123456'); + }); + + it('rejects invalid presenter session ids before deriving channel names', () => { + expect(() => presenterChannelName('short')).toThrow( + 'presenter session id must be 8-128 URL-safe characters', + ); + }); + + it('generates one ephemeral session id when no URL scope exists', async () => { + vi.stubGlobal('crypto', { randomUUID: () => 'generated-session-123456' }); + const { getPresenterSessionId } = await importFreshBridge(); + expect(getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toBe( + 'generated-session-123456', + ); + expect(getPresenterSessionId({ href: 'https://pulsar.test/?composition=other' })).toBe( + 'generated-session-123456', + ); + }); + + it('falls back to a generated session id when the bootstrap URL is unreadable', async () => { + vi.stubGlobal('crypto', { randomUUID: () => 'generated-session-abcdef' }); + const { getPresenterSessionId } = await importFreshBridge(); + const location = { + get href(): string { + throw new Error('bad href'); + }, + }; + expect(getPresenterSessionId(location)).toBe('generated-session-abcdef'); + }); + + it('reports missing secure random support when no URL scope exists', async () => { + vi.stubGlobal('crypto', {}); + const { getPresenterSessionId } = await importFreshBridge(); + expect(() => getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toThrow( + 'secure random presenter session id source is unavailable', + ); + }); + it('delivers a command sent from one window to another', async () => { const channelName = `pulsar-test-${Math.random()}`; const sender = track(createPresenterBridge({ channelName })); @@ -44,6 +90,33 @@ describe('createPresenterBridge', () => { expect(received).toEqual([advance]); }); + it('delivers commands within a session scope but not across different scopes', async () => { + const inSessionSender = track(createPresenterBridge({ sessionId: 'session-a-123456' })); + const inSessionReceiver = track(createPresenterBridge({ sessionId: 'session-a-123456' })); + const otherSessionReceiver = track(createPresenterBridge({ sessionId: 'session-b-123456' })); + const inSessionReceived: PresenterCommand[] = []; + const otherSessionReceived: PresenterCommand[] = []; + inSessionReceiver.source.subscribe((cmd) => inSessionReceived.push(cmd)); + otherSessionReceiver.source.subscribe((cmd) => otherSessionReceived.push(cmd)); + inSessionSender.send(advance); + await flush(); + expect(inSessionReceived).toEqual([advance]); + expect(otherSessionReceived).toEqual([]); + }); + + it('keeps presenter-command validation on scoped channels', async () => { + const sessionId = 'session-a-123456'; + const scopedChannel = presenterChannelName(sessionId); + const receiver = track(createPresenterBridge({ sessionId })); + const received: PresenterCommand[] = []; + receiver.source.subscribe((cmd) => received.push(cmd)); + new BroadcastChannel(scopedChannel).postMessage({ kind: 'not-a-command' }); + new BroadcastChannel(scopedChannel).postMessage({ sessionId, command: advance }); + new BroadcastChannel(scopedChannel).postMessage(advance); + await flush(); + expect(received).toEqual([advance]); + }); + it('does not echo a command back to the sending window', async () => { const channelName = `pulsar-test-${Math.random()}`; const sender = track(createPresenterBridge({ channelName })); diff --git a/tests/system/prompter-window-url.test.ts b/tests/system/prompter-window-url.test.ts index 2d03646..bbd2221 100644 --- a/tests/system/prompter-window-url.test.ts +++ b/tests/system/prompter-window-url.test.ts @@ -1,14 +1,24 @@ // Pulsar L2 — openPrompterWindow URL builder + window.open dispatch. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { openPrompterWindow } from '../../src/system/presenter'; +import { PRESENTER_SESSION_QUERY_PARAM, openPrompterWindow } from '../../src/system/presenter'; describe('openPrompterWindow', () => { const realOpen = globalThis.window?.open ?? null; const originalWindow = globalThis.window; + const presenterSessionId = 'session-a-123456'; let opened: Array<{ url: string; target: string; features: string }> = []; let openedWindow: Window & { opener: unknown }; + const openScopedPrompterWindow = (baseUrl: string, features?: string): Window | null => + openPrompterWindow(baseUrl, features, { presenterSessionId }); + + const expectPrompterUrl = (expected: string): void => { + const url = new URL(opened[0]?.url ?? ''); + expect(url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM)).toBe(presenterSessionId); + expect(url.href).toBe(expected); + }; + beforeEach(() => { opened = []; openedWindow = { closed: false, opener: { source: 'presenter' } } as Window & { @@ -18,7 +28,9 @@ describe('openPrompterWindow', () => { // attach one minimally for this test. Object.defineProperty(globalThis, 'window', { value: { - location: new URL('https://pulsar.test/workbench/current?composition=current'), + location: new URL( + 'https://pulsar.test/workbench/current?composition=current&pulsar-presenter-session=opener-session-123456', + ), open: (url: string, target: string, features: string) => { opened.push({ url, target, features }); return openedWindow; @@ -41,62 +53,104 @@ describe('openPrompterWindow', () => { }); it('replaces an existing mode= param', () => { - openPrompterWindow('/?composition=demo&mode=present'); - expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); + openScopedPrompterWindow('/?composition=demo&mode=present'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); - it('appends mode=prompter to a URL with other params', () => { + it('uses the current workbench presenter session scope by default', () => { openPrompterWindow('/?composition=demo'); - expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); + const url = new URL(opened[0]?.url ?? ''); + expect(url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM)).toBe('opener-session-123456'); + expect(url.href).toBe( + 'https://pulsar.test/?composition=demo&mode=prompter&pulsar-presenter-session=opener-session-123456', + ); + }); + + it('appends mode=prompter to a URL with other params', () => { + openScopedPrompterWindow('/?composition=demo'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); it('uses ?mode=prompter for a URL with no query', () => { - openPrompterWindow('/'); - expect(opened[0]?.url).toBe('https://pulsar.test/?mode=prompter'); + openScopedPrompterWindow('/'); + expectPrompterUrl( + 'https://pulsar.test/?mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); it('does not rewrite query values containing mode=', () => { - openPrompterWindow('/?composition=demo¬e=has-mode=inside'); - expect(opened[0]?.url).toBe( - 'https://pulsar.test/?composition=demo¬e=has-mode%3Dinside&mode=prompter', + openScopedPrompterWindow('/?composition=demo¬e=has-mode=inside'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo¬e=has-mode%3Dinside&mode=prompter&pulsar-presenter-session=session-a-123456', ); }); it('preserves hashes after the prompter mode query parameter', () => { - openPrompterWindow('/deck?composition=demo#speaker-notes'); - expect(opened[0]?.url).toBe( - 'https://pulsar.test/deck?composition=demo&mode=prompter#speaker-notes', + openScopedPrompterWindow('/deck?composition=demo#speaker-notes'); + expectPrompterUrl( + 'https://pulsar.test/deck?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456#speaker-notes', ); }); it('resolves relative URLs against the current window location', () => { - openPrompterWindow('./notes?scene=intro'); - expect(opened[0]?.url).toBe('https://pulsar.test/workbench/notes?scene=intro&mode=prompter'); + openScopedPrompterWindow('./notes?scene=intro'); + expectPrompterUrl( + 'https://pulsar.test/workbench/notes?scene=intro&mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); it('accepts absolute same-origin URLs', () => { - openPrompterWindow('https://pulsar.test/presenter?composition=demo&mode=loop#notes'); - expect(opened[0]?.url).toBe( - 'https://pulsar.test/presenter?composition=demo&mode=prompter#notes', + openScopedPrompterWindow('https://pulsar.test/presenter?composition=demo&mode=loop#notes'); + expectPrompterUrl( + 'https://pulsar.test/presenter?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456#notes', + ); + }); + + it('overwrites stale presenter session scope in the popout URL', () => { + openScopedPrompterWindow('/?composition=demo&pulsar-presenter-session=session-stale-123456'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo&pulsar-presenter-session=session-a-123456&mode=prompter', ); }); it('does not open cross-origin URLs', () => { - const result = openPrompterWindow('https://external.example/?composition=demo'); + const result = openScopedPrompterWindow('https://external.example/?composition=demo'); + expect(result).toBeNull(); + expect(opened).toEqual([]); + }); + + it('does not open malformed URLs', () => { + const result = openScopedPrompterWindow('http://[bad'); expect(result).toBeNull(); expect(opened).toEqual([]); }); it('forwards custom features arg', () => { - openPrompterWindow('/', 'width=400'); + openScopedPrompterWindow('/', 'width=400'); const features = opened[0]?.features.split(',').map((feature) => feature.trim()); expect(features).toEqual(expect.arrayContaining(['width=400', 'noopener', 'noreferrer'])); }); it('isolates the opened window from the opener', () => { - const result = openPrompterWindow('/'); + const result = openScopedPrompterWindow('/'); expect(result).toBe(openedWindow); expect(opened[0]?.target).toBe('_blank'); expect(openedWindow.opener).toBeNull(); }); + + it('still returns the opened window when opener mutation is blocked', () => { + Object.defineProperty(openedWindow, 'opener', { + configurable: true, + get: () => ({ source: 'presenter' }), + set: () => { + throw new Error('blocked opener mutation'); + }, + }); + const result = openScopedPrompterWindow('/'); + expect(result).toBe(openedWindow); + }); });