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
1 change: 1 addition & 0 deletions changelog.d/148.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Apply configured asset URL policy to composition audio-bed validation and playback.
48 changes: 28 additions & 20 deletions src/runtime/asset-preloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
42 changes: 26 additions & 16 deletions src/runtime/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -651,6 +651,13 @@ export interface AudioServiceOptions {
* test callers); scheme validation still applies.
*/
readonly allowedSources?: Iterable<string>;
/**
* 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;
/**
Expand Down Expand Up @@ -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
Expand All @@ -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<string> | 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)}`,
Expand All @@ -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`. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/scene-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
75 changes: 55 additions & 20 deletions src/runtime/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<unknown>;
readonly compositions?: Iterable<ValidationCompositionInput>;
readonly assets?: {
readonly baseUrl?: string;
readonly allowedSchemes?: readonly string[];
};
readonly assets?: AssetUrlPolicy;
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -350,15 +351,15 @@ function* idEntries(inspected: readonly Inspected[]): Iterable<{ id: string; val
function runCompositionPhase(
compositions: Iterable<ValidationCompositionInput> | undefined,
validIds: ReadonlySet<string>,
policy: AssetUrlPolicy | undefined,
findings: Finding[],
): void {
if (compositions === undefined) return;
for (const composition of compositions) {
// 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);
Expand All @@ -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,
};
}
}
Expand Down
38 changes: 38 additions & 0 deletions tests/runtime/audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(() =>
Expand Down
Loading
Loading