Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,357 @@
<!-- markdownlint-disable MD024 -->
# 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 `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"`