diff --git a/changelog.d/148.fixed.md b/changelog.d/148.fixed.md new file mode 100644 index 0000000..bf8fd92 --- /dev/null +++ b/changelog.d/148.fixed.md @@ -0,0 +1 @@ +Apply configured asset URL policy to composition audio-bed validation and playback. diff --git a/src/runtime/asset-preloader.ts b/src/runtime/asset-preloader.ts index feca2d0..bc8457e 100644 --- a/src/runtime/asset-preloader.ts +++ b/src/runtime/asset-preloader.ts @@ -74,27 +74,11 @@ export const DEFAULT_ALLOWED_SCHEMES: readonly string[] = Object.freeze([ ]); /** - * Options for {@link createAssetPreloader}. All fields are optional; - * the defaults pull from `globalThis.fetch`, no extra request init, - * no base URL, and the {@link DEFAULT_ALLOWED_SCHEMES} allowlist. + * URL policy shared by validation, preloading, and runtime audio + * source checks. All fields are optional; omitted values mean no + * base URL and the {@link DEFAULT_ALLOWED_SCHEMES} allowlist. */ -export interface AssetPreloaderOptions { - /** - * Override the fetch implementation. Production callers default to - * `globalThis.fetch`; tests inject a fake to record calls and shape - * responses. Injection (rather than module-level global mutation) - * keeps tests isolated and matches the orchestrator-with-adapters - * pattern PUL-F004 uses. - */ - readonly fetch?: typeof globalThis.fetch; - /** - * `RequestInit` forwarded to every fetch call (e.g. headers, signal, - * cache mode). Forwarded by reference so callers can attach a single - * `AbortSignal` to all per-scene fetches. NB: credentials in - * `init.headers` flow to every asset URL — see ADR-012's - * cross-origin caveat before attaching `Authorization` or `Cookie`. - */ - readonly init?: RequestInit; +export interface AssetUrlPolicy { /** * Base URL used to resolve relative asset paths. Required when the * runtime is preloading on Node — Node's `fetch` rejects relative @@ -130,6 +114,30 @@ export interface AssetPreloaderOptions { readonly allowedSchemes?: readonly string[]; } +/** + * Options for {@link createAssetPreloader}. All fields are optional; + * the defaults pull from `globalThis.fetch`, no extra request init, + * no base URL, and the {@link DEFAULT_ALLOWED_SCHEMES} allowlist. + */ +export interface AssetPreloaderOptions extends AssetUrlPolicy { + /** + * Override the fetch implementation. Production callers default to + * `globalThis.fetch`; tests inject a fake to record calls and shape + * responses. Injection (rather than module-level global mutation) + * keeps tests isolated and matches the orchestrator-with-adapters + * pattern PUL-F004 uses. + */ + readonly fetch?: typeof globalThis.fetch; + /** + * `RequestInit` forwarded to every fetch call (e.g. headers, signal, + * cache mode). Forwarded by reference so callers can attach a single + * `AbortSignal` to all per-scene fetches. NB: credentials in + * `init.headers` flow to every asset URL — see ADR-012's + * cross-origin caveat before attaching `Authorization` or `Cookie`. + */ + readonly init?: RequestInit; +} + /** * Build a per-scene asset preloader. * diff --git a/src/runtime/audio.ts b/src/runtime/audio.ts index fcd4c46..2493187 100644 --- a/src/runtime/audio.ts +++ b/src/runtime/audio.ts @@ -57,7 +57,7 @@ // module reuses; declared audio sources are warmed before mount. import { Howl, Howler, type SoundSpriteDefinitions } from 'howler'; -import { DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; +import { type AssetUrlPolicy, DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; import { describeError } from './error'; import { KEBAB_IDENTIFIER_FORM, isKebabIdentifier } from './identifier'; import { deepFreeze, isPlainRecord } from './object'; @@ -651,6 +651,13 @@ export interface AudioServiceOptions { * test callers); scheme validation still applies. */ readonly allowedSources?: Iterable; + /** + * Asset URL policy shared with validation and the preloader. When + * supplied, every scene sound source and composition bed source is + * resolved with the same `baseUrl` / `allowedSchemes` rules before + * reaching the engine. Omit to keep the default authoring policy. + */ + readonly assetPolicy?: AssetUrlPolicy; /** Sink for non-fatal async audio errors (load / play failure). Defaults to a no-op. */ readonly onError?: (err: unknown) => void; /** @@ -957,14 +964,12 @@ const assertSpriteMap = (soundId: string, sprite: unknown): void => { * * 1. The URL is a non-empty string — scene modules can be plain JS, * so the static type does not hold. - * 2. The URL resolves under {@link DEFAULT_ALLOWED_SCHEMES}. - * Defense-in-depth: even when an allowlist is supplied, the audio - * service runs the same default scheme allowlist the preloader - * uses by default — so a no-op or weak preloader cannot let - * `file:` / `//host` URLs through. Threading the preloader's exact - * `baseUrl` / `allowedSchemes` policy into the audio service is a - * documented follow-up; for now the service is at least as - * restrictive as `DEFAULT_ALLOWED_SCHEMES`. + * 2. The URL resolves under the supplied asset policy, or under + * {@link DEFAULT_ALLOWED_SCHEMES} when no policy was supplied. + * This is the same `baseUrl` / `allowedSchemes` rule the + * validation pass and asset preloader use, so hardened + * deployments do not get a looser audio-bed URL path when + * validation is skipped. * 3. When `allowedForSource` is non-null, the URL is a member of it. * PUL-F030 / ADR-029: `scene.audio` is the audio-source allowlist * for scene sounds; for the composition bed it is the bed's own @@ -978,16 +983,22 @@ const assertSpriteMap = (soundId: string, sprite: unknown): void => { * `normalizeSources` stays within Sonar's / Biome's cognitive- * complexity budget (the per-URL branching is the bulk of it). */ -function assertValidAudioUrl( +function normalizeAudioUrl( soundId: string, url: unknown, allowedForSource: ReadonlySet | null, -): asserts url is string { + assetPolicy: AssetUrlPolicy | undefined, +): string { if (typeof url !== 'string' || url === '') { throw new AudioSourceError(`audio sound "${soundId}" source must be a non-empty URL string`); } + let resolved: string; try { - resolveAssetUrl(url, undefined, DEFAULT_ALLOWED_SCHEMES); + resolved = resolveAssetUrl( + url, + assetPolicy?.baseUrl, + assetPolicy?.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES, + ); } catch (cause) { throw new AudioSourceError( `audio sound "${soundId}" source "${url}" is invalid: ${describeError(cause)}`, @@ -999,6 +1010,7 @@ function assertValidAudioUrl( `audio sound "${soundId}" source "${url}" is not a declared audio source — list it in scene.audio (and ensure it is also in scene.assets so the preloader warms it) per PUL-F030 / ADR-029`, ); } + return resolved; } /** Build the per-navigation {@link AudioService} over `engine`. */ @@ -1119,6 +1131,7 @@ export function createAudioService( const muted = outputPolicy !== 'audible'; const onError = options.onError ?? ((): void => undefined); const onCue = options.onCue; + const assetPolicy = options.assetPolicy; const allowed = options.allowedSources === undefined ? null : new Set(options.allowedSources); // PUL-F017 / ADR-020: the dynamic cue-eligibility gate. Absent for // every navigation except `mode=scrub`; an absent gate means cues @@ -1249,10 +1262,7 @@ export function createAudioService( `audio sound "${soundId}" has no source — provide at least one URL`, ); } - for (const url of list) { - assertValidAudioUrl(soundId, url, allowedForSource); - } - return list; + return list.map((url) => normalizeAudioUrl(soundId, url, allowedForSource, assetPolicy)); }; const unknownSpriteMessage = ( diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index 292f838..bc1b14e 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -29,6 +29,7 @@ // - ADR-007 — runtime parses URL parameters at startup AND popstate. // - ADR-013 — URL navigation grammar boundary; F007 owns parsing. +import type { AssetUrlPolicy } from './asset-preloader'; import { type AudioCueLogEntry, type AudioEngine, @@ -290,6 +291,14 @@ export interface SceneLoaderOptions { * follow. */ readonly audioEngine?: AudioEngine; + /** + * Asset URL policy shared with validation, preloading, and runtime + * audio checks. Workbench bootstraps that harden + * `createAssetPreloader({ baseUrl, allowedSchemes })` should pass + * the same policy here so composition audio beds cannot bypass it + * when validation was skipped. + */ + readonly assetPolicy?: AssetUrlPolicy; /** * Workbench-supplied cue-log sink (PUL-F026 / ADR-004). When set, * the loader threads it through to every per-navigation @@ -1304,6 +1313,7 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { signal: controller.signal, outputPolicy, allowedSources: collectAudioSources(resolved), + ...(options.assetPolicy === undefined ? {} : { assetPolicy: options.assetPolicy }), onError, ...(options.onAudioCue === undefined ? {} : { onCue: options.onAudioCue }), // PUL-F014 / ADR-004: the composition audio bed, played diff --git a/src/runtime/validation.ts b/src/runtime/validation.ts index 909baf3..6676ac4 100644 --- a/src/runtime/validation.ts +++ b/src/runtime/validation.ts @@ -52,8 +52,8 @@ // `readonly Finding[]`; callers format for the workbench error sink, // a CI summary, or a JSON report. -import { DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; -import { assertAudioBedDeclaration } from './audio'; +import { type AssetUrlPolicy, DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; +import { type AudioBedDeclaration, assertAudioBedDeclaration } from './audio'; import { CompositionManifestError, assertCompositionManifest, @@ -142,7 +142,11 @@ export type FindingCode = // shape check reuses `assertAudioBedDeclaration` (the same gate the // composition registry and audio service use), so a bad bed fails // the validation pass at boot rather than at navigation time. - | 'composition-audio-bed-invalid'; + | 'composition-audio-bed-invalid' + // PUL-F014 / ADR-012 — a composition audio bed source does not + // satisfy the same asset URL policy (`baseUrl`, `allowedSchemes`) + // used for scene assets and preloading. + | 'composition-audio-bed-unresolvable'; /** * One composition registration as the validator sees it. Matches the @@ -178,17 +182,14 @@ export interface ValidationCompositionInput { * going to reject anyway. * - `compositions` — optional list of composition registrations. When * omitted, clauses (a) / manifest-shape are no-ops. - * - `assets` — optional asset-policy block mirroring the preloader's - * `baseUrl` / `allowedSchemes` options. Defaults match the + * - `assets` — optional asset-policy block shared with the + * preloader and runtime audio source checks. Defaults match the * preloader: no `baseUrl`, {@link DEFAULT_ALLOWED_SCHEMES}. */ export interface ValidationInput { readonly scenes: Iterable; readonly compositions?: Iterable; - readonly assets?: { - readonly baseUrl?: string; - readonly allowedSchemes?: readonly string[]; - }; + readonly assets?: AssetUrlPolicy; } /** @@ -227,7 +228,7 @@ export function validateRuntime(input: ValidationInput): readonly Finding[] { const findings: Finding[] = []; const inspected = runSceneShapePhase(input.scenes, findings); const validIds = runDuplicateIdPhase(inspected, findings); - runCompositionPhase(input.compositions, validIds, findings); + runCompositionPhase(input.compositions, validIds, input.assets, findings); runAssetPhase(inspected, input.assets, findings); return Object.freeze(findings); } @@ -350,6 +351,7 @@ function* idEntries(inspected: readonly Inspected[]): Iterable<{ id: string; val function runCompositionPhase( compositions: Iterable | undefined, validIds: ReadonlySet, + policy: AssetUrlPolicy | undefined, findings: Finding[], ): void { if (compositions === undefined) return; @@ -357,8 +359,7 @@ function runCompositionPhase( // The audio-bed shape check (PUL-F014) is independent of the // manifest shape — a bad bed must not suppress the reference // check, and a bad manifest must not suppress the bed check. - const bedFinding = checkCompositionAudioBed(composition); - if (bedFinding !== null) findings.push(bedFinding); + appendCompositionAudioBedFindings(composition, policy, findings); const manifestFinding = checkCompositionShape(composition); if (manifestFinding !== null) { findings.push(manifestFinding); @@ -371,20 +372,54 @@ function runCompositionPhase( /** * PUL-F014 — when a composition declares an `audioBed`, validate its * shape through `assertAudioBedDeclaration` (the same gate the - * composition registry and audio service use). Returns a - * `composition-audio-bed-invalid` finding on a malformed bed, or - * `null` when there is no bed or the bed is well-formed. + * composition registry and audio service use), then validate every + * bed source through the same asset URL policy scene assets use. */ -function checkCompositionAudioBed(composition: ValidationCompositionInput): Finding | null { - if (composition.audioBed === undefined) return null; +function appendCompositionAudioBedFindings( + composition: ValidationCompositionInput, + policy: AssetUrlPolicy | undefined, + findings: Finding[], +): void { + if (composition.audioBed === undefined) return; + const bed = composition.audioBed; try { - assertAudioBedDeclaration(composition.audioBed); - return null; + assertAudioBedDeclaration(bed); } catch (cause) { - return { + findings.push({ code: 'composition-audio-bed-invalid', message: `composition "${composition.id}": ${describeError(cause)}`, compositionId: composition.id, + }); + return; + } + const baseUrl = policy?.baseUrl; + const allowedSchemes = policy?.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES; + for (const source of audioBedSources(bed)) { + const finding = checkCompositionAudioBedSource(composition.id, source, baseUrl, allowedSchemes); + if (finding !== null) findings.push(finding); + } +} + +const audioBedSources = (bed: AudioBedDeclaration): readonly string[] => + typeof bed.src === 'string' ? [bed.src] : bed.src; + +function checkCompositionAudioBedSource( + compositionId: string, + source: string, + baseUrl: string | undefined, + allowedSchemes: readonly string[], +): Finding | null { + try { + resolveAssetUrl(source, baseUrl, allowedSchemes); + return null; + } catch (cause) { + return { + code: 'composition-audio-bed-unresolvable', + message: `composition "${compositionId}": audio bed source "${source}" is invalid: ${describeError( + cause, + )}`, + compositionId, + asset: source, }; } } diff --git a/tests/runtime/audio.test.ts b/tests/runtime/audio.test.ts index d91e5fe..9048466 100644 --- a/tests/runtime/audio.test.ts +++ b/tests/runtime/audio.test.ts @@ -691,6 +691,7 @@ describe('createAudioService — lifecycle / cleanup (ADR-004)', () => { service.stopGroup('scene-a'); service.mute(true); expect(fake.calls).toEqual([]); + expect(fake.masterMuted()).toBe(false); expect(fake.created).toHaveLength(1); expect(service.isDisposed()).toBe(true); }); @@ -1367,6 +1368,43 @@ describe('createAudioService — composition audio bed (PUL-F014)', () => { ).toThrow(AudioSourceError); }); + it('applies the supplied asset URL policy to a string bed source', () => { + const fake = fakeEngine(); + expect(() => + createAudioService(fake.engine, { + signal: liveSignal(), + bed: { src: 'http://cdn.example.test/bed.webm' }, + assetPolicy: { allowedSchemes: ['https:'] }, + }), + ).toThrow(AudioSourceError); + expect(fake.created).toHaveLength(0); + }); + + it('applies the supplied asset URL policy to every array bed source', () => { + const fake = fakeEngine(); + expect(() => + createAudioService(fake.engine, { + signal: liveSignal(), + bed: { + src: ['https://cdn.example.test/bed.webm', 'data:audio/webm;base64,AAAA'], + }, + assetPolicy: { allowedSchemes: ['https:'] }, + }), + ).toThrow(AudioSourceError); + expect(fake.created).toHaveLength(0); + }); + + it('resolves a relative bed source against the supplied asset baseUrl before constructing it', () => { + const { fake } = buildService({ + bed: bed(), + assetPolicy: { + baseUrl: 'https://cdn.example.test/decks/', + allowedSchemes: ['https:'], + }, + }); + expect(fake.created[0]?.src).toEqual(['https://cdn.example.test/audio/bed.webm']); + }); + it('rejects an out-of-range bed volume', () => { const fake = fakeEngine(); expect(() => diff --git a/tests/runtime/scene-loader-audio.test.ts b/tests/runtime/scene-loader-audio.test.ts index 7fef2b9..c2288b6 100644 --- a/tests/runtime/scene-loader-audio.test.ts +++ b/tests/runtime/scene-loader-audio.test.ts @@ -1263,6 +1263,38 @@ describe('scene loader — composition audio bed (PUL-F014 / ADR-004)', () => { }, ); + it('applies the loader asset policy to the composition bed before playback', async () => { + const audio = recordingAudioEngine(); + const stage = buildStage(); + const errors: unknown[] = []; + const loader = createSceneLoader({ + scenes: createSceneRegistry([buildScene({ id: 'head' })]), + compositions: createCompositionRegistry([ + { + id: 'deck', + manifest: ['head'], + audioBed: { src: 'http://cdn.example.test/bed.webm' }, + }, + ]), + stage: stage.element, + buildCtx: stubCtx, + createPreloader: () => () => Promise.resolve(), + timeline: noopTimeline, + audioEngine: audio.engine, + assetPolicy: { allowedSchemes: ['https:'] }, + onError: (err) => { + errors.push(err); + }, + }); + + await loader.handle(compositionTarget('deck')); + await loader.idle(); + + expect(audio.created).toHaveLength(0); + expect(stage.attrs.get('data-pulsar-navigation-error')).toMatch(/audio bed/); + expect((errors[0] as Error | undefined)?.message).toMatch(/http:/); + }); + it('suppresses the composition audio bed under mode=standalone', async () => { const audio = recordingAudioEngine(); const loader = createSceneLoader({ diff --git a/tests/runtime/validation.test.ts b/tests/runtime/validation.test.ts index a90ff60..824307b 100644 --- a/tests/runtime/validation.test.ts +++ b/tests/runtime/validation.test.ts @@ -851,5 +851,60 @@ describe('validateRuntime (PUL-Q005 — actionability)', () => { }); expect(findings).toEqual([]); }); + + it.each([ + ['http:', 'http://cdn.example.test/bed.webm'], + ['data:', 'data:audio/webm;base64,AAAA'], + ['blob:', 'blob:https://app.example.test/bed'], + ['file:', 'file:///tmp/bed.webm'], + ['ftp:', 'ftp://cdn.example.test/bed.webm'], + ] as const)( + 'reports composition-audio-bed-unresolvable for a string src with disallowed %s policy', + (_scheme, src) => { + const findings = validateRuntime({ + scenes: [buildScene({ id: 'real' })], + compositions: [{ id: 'deck', manifest: ['real'], audioBed: { src } }], + assets: { baseUrl: 'https://app.example.test/', allowedSchemes: ['https:'] }, + }); + expect(findingCodes(findings)).toEqual(['composition-audio-bed-unresolvable']); + expect(findings[0]).toMatchObject({ + compositionId: 'deck', + asset: src, + }); + expect(findings[0]?.message).toMatch(/^composition "deck": audio bed source/); + }, + ); + + it('reports only the disallowed member of an audioBed.src array', () => { + const findings = validateRuntime({ + scenes: [buildScene({ id: 'real' })], + compositions: [ + { + id: 'deck', + manifest: ['real'], + audioBed: { + src: ['https://cdn.example.test/bed.webm', 'http://cdn.example.test/bed.webm'], + }, + }, + ], + assets: { baseUrl: 'https://app.example.test/', allowedSchemes: ['https:'] }, + }); + expect(findingCodes(findings)).toEqual(['composition-audio-bed-unresolvable']); + expect(findings[0]?.asset).toBe('http://cdn.example.test/bed.webm'); + }); + + it('keeps the permissive default bed source policy when no production policy is supplied', () => { + const findings = validateRuntime({ + scenes: [buildScene({ id: 'real' })], + compositions: [ + { + id: 'deck', + manifest: ['real'], + audioBed: { src: ['http://cdn.example.test/bed.webm', 'data:audio/webm;base64,AAAA'] }, + }, + ], + }); + expect(findings).toEqual([]); + }); }); });