15 KiB
Capability Spec: media
Package: @suaveplan/media
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
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
MediaErroris constructed withcause: nativeErr - THEN
error.causeMUST return the original native error anderror.codeMUST 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: 415andseverity: "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
.messageMUST be"root"and.causeMUST be the original Error.
Scenario: falls back to default for non-Error cause
- WHEN
asMediaError(MEDIA_CODES.PREDICATE_FAILED, "scalar", "fb")is invoked - THEN
.messageMUST be"fb"and.causeMUST 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
ftypmarker lives at offset 4 - THEN detection MUST return
"video".
Scenario: register rejects duplicate label
- WHEN a caller registers a second signature whose
labelmatches an existing entry - THEN a
MediaErrorwith codeMEDIA.DUPLICATE_SIGNATUREMUST be thrown.
Scenario: register rejects empty bytes
- WHEN a caller registers a signature with
bytes: [] - THEN a
MediaErrorwith codeMEDIA.INVALID_SIGNATUREMUST be thrown.
Scenario: register rejects non-byte values
- WHEN a caller registers a signature whose
bytesarray contains a value outside[0, 0xff]or a non-integer - THEN a
MediaErrorwith codeMEDIA.INVALID_SIGNATUREMUST be thrown.
Scenario: unregister removes by label
- WHEN a caller invokes
unregister("PNG") - THEN the method MUST return
trueand subsequentdetectof 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
detectMediaTypeis 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
inferMimeFromSignatureis 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
getMetadataAsyncis invoked with an extractor that returns{ width: 100, height: 50 } - THEN the returned bundle MUST satisfy
width === 100andheight === 50.
Scenario: extractor exception is wrapped
- WHEN the supplied extractor throws
- THEN
getMetadataAsyncMUST reject with aMediaErrorwhose code isMEDIA.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 withoutskipConstraintValidation - THEN a
MediaErrorwith codeMEDIA.INVALID_CONSTRAINTSMUST 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 === trueanderrors.length === 0.
Scenario: empty input throws MEDIA.EMPTY_INPUT
- WHEN
validateMedia(new Uint8Array([]), {})is invoked - THEN a
MediaErrorwith codeMEDIA.EMPTY_INPUTMUST be thrown.
Scenario: failures are reported per rule
- WHEN
validateMedia(pngBytes, { allowedTypes: ["video"], allowedMimeTypes: ["image/jpeg"] })is invoked - THEN
errorsMUST 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 === falseanderrorsMUST 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
typeMUST be"image".
Scenario: throws on failure
- WHEN
assertValidMedia(pngBytes, { allowedTypes: ["video"] })is invoked - THEN a
MediaErrorwith codeMEDIA.VALIDATION_FAILEDMUST 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
.meterMUST 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"