Files
2026-05-27 23:29:53 +01:00

15 KiB

Capability Spec Delta: media (media)

Package: @suaveplan/media Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: Stable error code registry

The package SHALL expose a MEDIA_CODES constant created via defineCodes("MEDIA", [...] as const) containing exactly eight stable codes: EMPTY_INPUT, INVALID_CONSTRAINTS, VALIDATION_FAILED, UNSUPPORTED_TYPE, PREDICATE_FAILED, DUPLICATE_SIGNATURE, METADATA_EXTRACTOR_FAILED, INVALID_SIGNATURE. Each code MUST have a metadata record (httpStatus, retryable, severity, description) accessible via MEDIA_ERROR_METADATA.

Scenario: registry shape is frozen

  • WHEN a consumer reads MEDIA_CODES
  • THEN the returned object MUST contain all eight expected keys and MUST be Object.isFrozen.

Scenario: every code is namespaced

  • WHEN a consumer iterates Object.values(MEDIA_CODES)
  • THEN every value MUST start with the MEDIA. namespace prefix.

Verified by: src/errors/errors.test.ts::"contains all eight stable codes" AND src/errors/errors.test.ts::"registry is frozen" AND src/errors/errors.test.ts::"codes carry MEDIA namespace prefix"


Requirement: MediaError extends SuaveplanError

The package SHALL export MediaError extends SuaveplanError<MediaErrorCode>. Constructing new MediaError(MEDIA_CODES.EMPTY_INPUT, "msg") MUST yield an instance whose .code === "MEDIA.EMPTY_INPUT", whose .message === "msg", and which satisfies both instanceof MediaError and instanceof Error. The class MUST expose a .metadata getter returning the matching record from MEDIA_ERROR_METADATA.

Scenario: round-trips through cause chain

  • WHEN a MediaError is constructed with cause: nativeErr
  • THEN error.cause MUST return the original native error and error.code MUST preserve the namespaced code.

Scenario: metadata getter resolves code

  • WHEN a consumer reads new MediaError(MEDIA_CODES.UNSUPPORTED_TYPE, "x").metadata
  • THEN the returned record MUST report httpStatus: 415 and severity: "warning".

Verified by: src/errors/errors.test.ts::"constructs with code, message, instanceof Error" AND src/errors/errors.test.ts::"preserves cause through chain" AND src/errors/errors.test.ts::"exposes metadata via getter"


Requirement: asMediaError lift helper

The package SHALL expose asMediaError(code, cause, fallback) that wraps an unknown thrown value into a MediaError. When cause instanceof Error and its message is non-empty, the wrapped error's .message MUST equal the cause's message; otherwise it MUST equal the supplied fallback. error.cause MUST be an Error instance regardless of the cause's original shape.

Scenario: preserves Error message and cause

  • WHEN asMediaError(MEDIA_CODES.PREDICATE_FAILED, new Error("root"), "fb") is invoked
  • THEN .message MUST be "root" and .cause MUST be the original Error.

Scenario: falls back to default for non-Error cause

  • WHEN asMediaError(MEDIA_CODES.PREDICATE_FAILED, "scalar", "fb") is invoked
  • THEN .message MUST be "fb" and .cause MUST be a synthesised Error whose message is "scalar".

Verified by: src/errors/errors.test.ts::"preserves Error cause when wrapping" AND src/errors/errors.test.ts::"falls back to default message when cause has none"


Requirement: Pluggable signature registry

The package SHALL expose a SignatureRegistry class with register, unregister, list, match, and detect methods. BUILTIN_SIGNATURES MUST be a frozen array covering at minimum PNG, JPEG, GIF, BMP, WEBP, RIFF, MP4/MOV, WebM/MKV, MP3 (ID3 + sync), FLAC, and OGG. A process-wide defaultSignatureRegistry MUST be exposed, seeded with BUILTIN_SIGNATURES.

Scenario: built-in registry detects PNG

  • WHEN defaultSignatureRegistry.detect(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00])) is invoked
  • THEN the result MUST be "image".

Scenario: signature offset is honoured

  • WHEN the registry is asked to detect MP4 bytes whose ftyp marker lives at offset 4
  • THEN detection MUST return "video".

Scenario: register rejects duplicate label

  • WHEN a caller registers a second signature whose label matches an existing entry
  • THEN a MediaError with code MEDIA.DUPLICATE_SIGNATURE MUST be thrown.

Scenario: register rejects empty bytes

  • WHEN a caller registers a signature with bytes: []
  • THEN a MediaError with code MEDIA.INVALID_SIGNATURE MUST be thrown.

Scenario: register rejects non-byte values

  • WHEN a caller registers a signature whose bytes array contains a value outside [0, 0xff] or a non-integer
  • THEN a MediaError with code MEDIA.INVALID_SIGNATURE MUST be thrown.

Scenario: unregister removes by label

  • WHEN a caller invokes unregister("PNG")
  • THEN the method MUST return true and subsequent detect of PNG bytes MUST return "unknown".

Verified by: src/signatures/signatures.test.ts::"detects PNG via default registry" AND src/signatures/signatures.test.ts::"honours non-zero offset (MP4 ftyp)" AND src/signatures/signatures.test.ts::"rejects duplicate label" AND src/signatures/signatures.test.ts::"register rejects empty byte array" AND src/signatures/signatures.test.ts::"register rejects non-byte values" AND src/signatures/signatures.test.ts::"unregister removes signature and returns true"


Requirement: Byte and filename detection

The package SHALL expose detectMediaType(input, opts?), detectSignature(input, opts?), and detectMediaTypeFromName(filename, opts?). The bytes-based helpers MUST delegate to opts.registry ?? defaultSignatureRegistry. The filename helper MUST call getMimeType from @suaveplan/mime and bucket the result by top-level type prefix (image/, video/, audio/).

Scenario: PNG bytes route to image

  • WHEN detectMediaType is invoked with PNG magic bytes
  • THEN the result MUST be "image".

Scenario: empty input is detected as unknown

  • WHEN detectMediaType(new Uint8Array([])) is invoked
  • THEN the result MUST be "unknown".

Scenario: filename .mp4 routes to video

  • WHEN detectMediaTypeFromName("clip.mp4") is invoked
  • THEN the result MUST be "video".

Verified by: src/detection/detection.test.ts::"detects PNG from magic bytes" AND src/detection/detection.test.ts::"returns unknown for empty input" AND src/detection/detection.test.ts::"detects video from .mp4 filename"


Requirement: Canonical MIME inference

The package SHALL expose inferMimeFromSignature(sig) and defaultMimeForType(type). inferMimeFromSignature MUST return the canonical MIME for each built-in label (PNG → image/png, JPEG → image/jpeg, MP4/MOV → video/mp4, WebM/MKV → video/webm, FLAC → audio/flac, OGG → audio/ogg, MP3-ID3 / MP3-sync → audio/mpeg). For unknown labels it MUST fall back to defaultMimeForType(sig.type). For a null signature it MUST return "application/octet-stream".

Scenario: PNG signature maps to image/png

  • WHEN inferMimeFromSignature is called with a PNG signature record
  • THEN the result MUST be "image/png".

Scenario: null signature falls back to octet-stream

  • WHEN inferMimeFromSignature(null) is invoked
  • THEN the result MUST be "application/octet-stream".

Verified by: src/detection/detection.test.ts::"returns canonical MIME for PNG signature" AND src/detection/detection.test.ts::"returns octet-stream for null signature"


Requirement: Metadata extraction with extractor hook

The package SHALL expose getMetadata(input, opts?) and getMetadataAsync(input, opts?). Both MUST return a MediaMetadata bundle populated with type, mimeType, size, and (when a signature matched) signatureLabel. When opts.filename is supplied, the MIME MUST be resolved via getMimeType(filename). The async variant MUST accept a MediaMetadataExtractor and merge its return value over the base bundle.

Scenario: getMetadata reports type, mime, size, label

  • WHEN getMetadata(pngBytes, { filename: "x.png" }) is invoked
  • THEN the returned bundle MUST satisfy type === "image", mimeType === "image/png", size === pngBytes.byteLength, signatureLabel === "PNG".

Scenario: extractor merges async fields

  • WHEN getMetadataAsync is invoked with an extractor that returns { width: 100, height: 50 }
  • THEN the returned bundle MUST satisfy width === 100 and height === 50.

Scenario: extractor exception is wrapped

  • WHEN the supplied extractor throws
  • THEN getMetadataAsync MUST reject with a MediaError whose code is MEDIA.METADATA_EXTRACTOR_FAILED, preserving the original error as .cause.

Verified by: src/metadata/metadata.test.ts::"returns type, mime, size, label for PNG bytes" AND src/metadata/metadata.test.ts::"merges extractor output" AND src/metadata/metadata.test.ts::"wraps extractor error in MediaError"


Requirement: Zod-validated constraints

MediaConstraintsSchema SHALL validate every constraint field. Malformed constraints supplied to validateMedia or validateMediaAsync MUST cause the call to throw MediaError(MEDIA.INVALID_CONSTRAINTS) unless the caller opts out via skipConstraintValidation: true.

Scenario: negative maxSize is rejected

  • WHEN validateMedia(input, { maxSize: -1 }) is invoked without skipConstraintValidation
  • THEN a MediaError with code MEDIA.INVALID_CONSTRAINTS MUST be thrown.

Scenario: skipConstraintValidation bypasses Zod

  • WHEN validateMedia(input, badConstraints, { skipConstraintValidation: true }) is invoked
  • THEN the call MUST NOT throw MEDIA.INVALID_CONSTRAINTS.

Verified by: src/validation/validation.test.ts::"throws INVALID_CONSTRAINTS for malformed input" AND src/validation/validation.test.ts::"skips Zod check when skipConstraintValidation: true"


Requirement: Synchronous validation

validateMedia(input, constraints, opts?) SHALL run the empty-input guard, build the metadata bundle, and apply every constraint check (minSize, maxSize, allowedTypes, allowedMimeTypes, maxWidth, minWidth, maxHeight, minHeight, maxDuration). It MUST return a MediaResult whose valid flag is errors.length === 0. Empty input MUST throw MediaError(MEDIA.EMPTY_INPUT).

Scenario: compliant input returns valid result

  • WHEN validateMedia(pngBytes, { maxSize: 1024, allowedTypes: ["image"] }) is invoked
  • THEN the result MUST satisfy valid === true and errors.length === 0.

Scenario: empty input throws MEDIA.EMPTY_INPUT

  • WHEN validateMedia(new Uint8Array([]), {}) is invoked
  • THEN a MediaError with code MEDIA.EMPTY_INPUT MUST be thrown.

Scenario: failures are reported per rule

  • WHEN validateMedia(pngBytes, { allowedTypes: ["video"], allowedMimeTypes: ["image/jpeg"] }) is invoked
  • THEN errors MUST contain entries for both the unsupported type and unsupported MIME.

Verified by: src/validation/validation.test.ts::"passes when all constraints met" AND src/validation/validation.test.ts::"rejects empty input with MediaError" AND src/validation/validation.test.ts::"fails when type not in allowedTypes" AND src/validation/validation.test.ts::"fails when MIME not in allowedMimeTypes"


Requirement: Asynchronous validation with predicate

validateMediaAsync SHALL behave identically to validateMedia but MUST also run constraints.predicate(metadata, input) when present. A string return value MUST be appended to the errors array. A null / undefined return MUST pass. A thrown exception MUST surface as MediaError(MEDIA.PREDICATE_FAILED) with the original error chained.

Scenario: predicate returning string contributes error

  • WHEN validateMediaAsync(input, { predicate: () => "rule failed" }) is invoked
  • THEN the result MUST satisfy valid === false and errors MUST include "rule failed".

Scenario: predicate exception wraps in MEDIA.PREDICATE_FAILED

  • WHEN the supplied predicate throws
  • THEN the call MUST reject with MediaError(MEDIA.PREDICATE_FAILED), preserving the original error as .cause.

Verified by: src/validation/validation.test.ts::"runs predicate and reports its error string" AND src/validation/validation.test.ts::"predicate throwing yields PREDICATE_FAILED"


Requirement: assertValidMedia throwing variant

assertValidMedia(input, constraints, opts?) SHALL invoke validateMedia and return the resulting MediaMetadata when result.valid === true. When any error is reported it MUST throw MediaError(MEDIA.VALIDATION_FAILED) whose message contains the joined errors.

Scenario: returns metadata on success

  • WHEN assertValidMedia(pngBytes, { allowedTypes: ["image"] }) is invoked
  • THEN the returned bundle's type MUST be "image".

Scenario: throws on failure

  • WHEN assertValidMedia(pngBytes, { allowedTypes: ["video"] }) is invoked
  • THEN a MediaError with code MEDIA.VALIDATION_FAILED MUST be thrown.

Verified by: src/validation/validation.test.ts::"returns metadata on success" AND src/validation/validation.test.ts::"throws VALIDATION_FAILED on failure"


Requirement: Observability bundle factory

The package SHALL expose createMediaObservability(opts?) returning a MediaObservability bundle with meter, tracer, four counters (detectTotal, metadataTotal, validateTotal, errorTotal), and two histograms (opLatencyMs, inputSizeBytes). When opts.observability is supplied the function MUST short-circuit and return the same instance.

Scenario: factory produces complete bundle

  • WHEN createMediaObservability() is invoked with no options
  • THEN the returned bundle MUST include meter, tracer, every counter, and every histogram.

Scenario: pre-built bundle short-circuits

  • WHEN createMediaObservability({ observability: existing }) is invoked
  • THEN the returned bundle MUST be the same reference as existing.

Scenario: injected meter is preserved

  • WHEN createMediaObservability({ meter }) is invoked with a custom meter
  • THEN the returned bundle's .meter MUST be the injected instance.

Verified by: src/observability/observability.test.ts::"returns a fully-populated bundle with global providers by default" AND src/observability/observability.test.ts::"short-circuits when a pre-built observability bundle is given" AND src/observability/observability.test.ts::"uses an injected meter when supplied"