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

17 KiB

Capability Spec Delta: caddy-imgproxy

Package: @suaveplan/caddy-imgproxy Status: PROPOSED

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

ADDED Requirements

Requirement: parseImgproxyUrl parses the compact grammar into a typed result

parseImgproxyUrl(path: string) MUST accept paths of the shape /<s|u><op?><gravity?><dims?><modifiers?>/<path>.<format> and return a frozen ParsedImgproxyUrl carrying signed, operation (""|cover|crop|fit|stretch|resize), gravity (t|b|l|r|c|tl|tr|bl|br, defaulting to c), aspectW, aspectH, pixelW, pixelH, hintType (""|w|h), hintValue, quality, path, format, isAspect, and hasDims. The function MUST raise CaddyImgproxyError(INVALID_URL) when the regex does not match, CaddyImgproxyError(MISSING_DIMENSIONS) when an operation is specified without dimensions. Quality 0 and quality > 100 MUST raise INVALID_URL because the regex rejects them.

Scenario: parses a passthrough URL

  • WHEN parseImgproxyUrl("/u/photo.png") is called
  • THEN the result has operation === "", gravity === "c", path === "photo", format === "png", hasDims === false

Verified by: src/parse/parse-url.test.ts::"parses passthrough unsigned URL" AND src/parse/parse-url.test.ts::"parses passthrough signed URL"

Scenario: parses cover with aspect ratio + gravity + height hint

  • WHEN parseImgproxyUrl("/uvt9:16h1080/photos/hero.webp") is called
  • THEN the result has operation === "cover", gravity === "t", aspectW === 9, aspectH === 16, hintType === "h", hintValue === 1080, path === "photos/hero", format === "webp", isAspect === true

Verified by: src/parse/parse-url.test.ts::"parses cover with aspect ratio, top gravity, height hint"

Scenario: parses square shorthand dimensions

  • WHEN parseImgproxyUrl("/uv128/avatar.png") is called
  • THEN the result has pixelW === 128, pixelH === 128, isAspect === false, hasDims === true

Verified by: src/parse/parse-url.test.ts::"parses square shorthand - cover 128"

Scenario: raises MISSING_DIMENSIONS when operation is specified without dimensions

  • WHEN parseImgproxyUrl("/uv/photo.png") is called (operation v with no dimensions)
  • THEN the call throws CaddyImgproxyError with code === CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS

Verified by: src/parse/parse-url.test.ts::"raises MISSING_DIMENSIONS when operation is specified without dimensions"

Scenario: raises INVALID_URL on malformed input

  • WHEN parseImgproxyUrl("/x/photo.png") is called (no leading s|u prefix)
  • THEN the call throws CaddyImgproxyError with code === CADDY_IMGPROXY_CODES.INVALID_URL

Verified by: src/parse/parse-url.test.ts::"raises INVALID_URL on missing prefix" AND src/parse/parse-url.test.ts::"raises INVALID_URL on quality zero" AND src/parse/parse-url.test.ts::"raises INVALID_URL on quality over 100"

Scenario: parses modifiers in arbitrary order

  • WHEN parseImgproxyUrl("/ucbl4:3q90w1920/photo.png") is called
  • THEN hintType === "w", hintValue === 1920, quality === 90, gravity === "bl", aspectW === 4, aspectH === 3

Verified by: src/parse/parse-url.test.ts::"parses quality before width hint" AND src/parse/parse-url.test.ts::"parses quality before height hint"


Requirement: calculateDimensions resolves aspect ratios + hints into integer pixels

calculateDimensions(parsed, defaultWidth) MUST return { width: 0, height: 0 } when parsed.hasDims === false. When parsed.isAspect === false it MUST return { width: parsed.pixelW, height: parsed.pixelH } verbatim. When parsed.isAspect === true, an h-hint with positive value MUST drive height = hintValue and width = trunc(height * aspectW / aspectH); otherwise width = hintValue || defaultWidth and height = trunc(width * aspectH / aspectW). All multiplications MUST truncate toward zero (matching Go integer division).

Scenario: 16:9 with default width

  • WHEN calculateDimensions({ ..., isAspect: true, aspectW: 16, aspectH: 9, hintType: "", hintValue: 0, hasDims: true }, 800) is called
  • THEN the result is { width: 800, height: 450 }

Verified by: src/dimensions/calculate-dimensions.test.ts::"resolves 16:9 with default width"

Scenario: 9:16 with height hint

  • WHEN calculateDimensions is called with aspect 9:16, hintType: "h", hintValue: 1080, default 800
  • THEN the result is { width: 607, height: 1080 } (trunc(1080 * 9 / 16))

Verified by: src/dimensions/calculate-dimensions.test.ts::"resolves 9:16 with height hint"

Scenario: pixel dimensions pass through

  • WHEN calculateDimensions is called with isAspect: false, pixelW: 800, pixelH: 600
  • THEN the result is { width: 800, height: 600 }

Verified by: src/dimensions/calculate-dimensions.test.ts::"returns pixel dimensions unchanged" AND src/dimensions/calculate-dimensions.test.ts::"returns zero when hasDims is false"


Requirement: gravityToImgproxy maps the 9-direction grammar

gravityToImgproxy(code) MUST translate t→"no", b→"so", l→"we", r→"ea", tl→"nowe", tr→"noea", bl→"sowe", br→"soea", c→"ce", and any unknown / empty input to "ce" (center default).

Scenario: every direction round-trips to its imgproxy token

  • WHEN gravityToImgproxy(code) is called for every code in the grammar
  • THEN the returned token matches the imgproxy convention

Verified by: src/gravity/gravity.test.ts::"translates every direction to imgproxy tokens" AND src/gravity/gravity.test.ts::"defaults unknown codes to ce"


Requirement: operationToImgproxy emits processing strings per the Go table

operationToImgproxy(op, width, height, gravity) MUST emit g:<g>/rs:fill:W:H for cover with both dims, g:<g>/w:W / g:<g>/h:H when only one dim is supplied, rs:fit:W:H for fit (no gravity prefix), g:<g>/c:W:H for crop, rs:force:W:H for stretch, and w:W / h:H for resize. Passthrough ("") and unknown operations MUST return "".

Scenario: cover with both dims emits gravity-prefixed fill

  • WHEN operationToImgproxy("cover", 1920, 1080, "no") is called
  • THEN the result is "g:no/rs:fill:1920:1080"

Verified by: src/operation/operation.test.ts::"cover with both dims emits gravity-prefixed fill" AND src/operation/operation.test.ts::"cover with width only emits g:<g>/w:W" AND src/operation/operation.test.ts::"cover with height only emits g:<g>/h:H"

Scenario: fit / crop / stretch / resize / passthrough each emit the right shape

  • WHEN operationToImgproxy is called for each operation with valid dims
  • THEN the resulting processing string matches the Go contract

Verified by: src/operation/operation.test.ts::"fit emits rs:fit:W:H" AND src/operation/operation.test.ts::"crop emits g:<g>/c:W:H" AND src/operation/operation.test.ts::"stretch emits rs:force:W:H" AND src/operation/operation.test.ts::"resize emits w:W or h:H" AND src/operation/operation.test.ts::"passthrough and unknown operations emit empty string"


Requirement: extractDomain strips port and drops the images. subdomain

extractDomain(host) MUST drop any :<port> suffix, split on ., and remove a leading images. label. Hosts without the images. prefix MUST be returned unchanged (minus the port). Hosts with fewer than two labels after port removal MUST also be returned unchanged.

Scenario: images-prefixed host is collapsed

  • WHEN extractDomain("images.example.com:8080") is called
  • THEN the result is "example.com"

Verified by: src/domain/extract-domain.test.ts::"strips port and drops the images. subdomain" AND src/domain/extract-domain.test.ts::"returns short hosts unchanged" AND src/domain/extract-domain.test.ts::"returns hosts without images. unchanged"


Requirement: signImgproxyPath emits HMAC-SHA256 base64url-no-pad signatures

signImgproxyPath({ key, salt, path, hash }) MUST compute HMAC-SHA256(keyBytes, saltBytes ‖ pathBytes) and base64url-encode the digest without padding. key and salt MUST be even-length lower-hex strings (validated via Zod boundary or INVALID_KEY / INVALID_SALT error codes). path is treated as UTF-8 bytes. The hash parameter is an optional injectable seam matching { hmac: (algo, key, data) => Uint8Array }; the default uses @suaveplan/crypto-hash's hmac.

Scenario: signature matches a known-answer test vector

  • WHEN signImgproxyPath is called with a fixed key, salt, and path
  • THEN the returned signature matches the pre-computed Go output verbatim

Verified by: src/signing/sign-path.test.ts::"matches the known-answer signature vector" AND src/signing/sign-path.test.ts::"raises INVALID_KEY for malformed hex key" AND src/signing/sign-path.test.ts::"raises INVALID_SALT for malformed hex salt"

Scenario: injectable hash seam is honoured

  • WHEN a custom hash implementation is supplied
  • THEN the seam's hmac is invoked instead of the default

Verified by: src/signing/sign-path.test.ts::"uses the injected hash implementation"


Requirement: buildImgproxyUrl composes processing + quality + s3 path + signature

buildImgproxyUrl({ parsed, domain, config }) MUST compose the final imgproxy path:

  1. Resolve (width, height) via calculateDimensions(parsed, config.defaultWidth).
  2. Resolve the processing string via operationToImgproxy(parsed.operation, width, height, parsed.gravity).
  3. Append q:<quality> when parsed.quality > 0.
  4. Append plain/s3://<bucket>/<domain>/<path>@<format>.
  5. When both config.key and config.salt are present, prefix with /<signature>; otherwise prefix with /unsafe.

The function MUST increment the caddy_imgproxy_sign_total counter tagged with mode=signed or mode=unsafe on every call.

Scenario: signed URL for cover with aspect ratio

  • WHEN buildImgproxyUrl is called with a parsed /uv16:9/avatar.png, domain example.com, signed config
  • THEN the result starts with /<base64url-signature>/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png

Verified by: src/url-builder/url-builder.test.ts::"composes signed URL for cover with aspect ratio" AND src/url-builder/url-builder.test.ts::"composes /unsafe URL when no key/salt" AND src/url-builder/url-builder.test.ts::"inserts q:<quality> when parsed.quality > 0" AND src/url-builder/url-builder.test.ts::"emits passthrough URL when operation is empty"

Scenario: telemetry sign_total counter ticks per call

  • WHEN buildImgproxyUrl is called with signed and unsigned configs
  • THEN the caddy_imgproxy_sign_total counter records the matching mode tag

Verified by: src/url-builder/url-builder.test.ts::"records caddy_imgproxy_sign_total for signed and unsafe modes"


Requirement: ImgproxyConfigSchema validates the public config

The exported Zod schema ImgproxyConfigSchema MUST require upstream: string (default "localhost:8080"), s3Bucket: string (default "images"), defaultWidth: number (positive integer, default 800), and optional key / salt (even-length lower-hex strings). parseConfig(raw) MUST raise CaddyImgproxyError(INVALID_OPTIONS) on schema failures, CaddyImgproxyError(INVALID_KEY) on malformed hex key, and CaddyImgproxyError(INVALID_SALT) on malformed hex salt.

Scenario: parseConfig applies defaults when fields are omitted

  • WHEN parseConfig({}) is called
  • THEN the result is { upstream: "localhost:8080", s3Bucket: "images", defaultWidth: 800 }

Verified by: src/config/config.test.ts::"applies defaults when fields are omitted" AND src/config/config.test.ts::"raises INVALID_OPTIONS when defaultWidth is zero" AND src/config/config.test.ts::"raises INVALID_KEY when key is malformed hex" AND src/config/config.test.ts::"raises INVALID_SALT when salt is malformed hex" AND src/config/config.test.ts::"accepts a fully-specified config"


Requirement: ImgproxyHandler exposes rewrite() with injectable Clock + LoggerLike

ImgproxyHandler MUST accept { config, clock?, logger? } and expose rewrite({ path, host }) returning either { rewritten: true; targetPath: string; operation: string } (when parseImgproxyUrl succeeds) or { rewritten: false; reason: "passthrough" | "invalid"; error?: CaddyImgproxyError } (when the path does not match the grammar or matches a direct imgproxy passthrough such as /unsafe/... / /health). On every call the handler MUST record the caddy_imgproxy_rewrite_total counter (tagged with outcome + operation) and the caddy_imgproxy_rewrite_duration_ms histogram, using the injected Clock for timing. When a parse error occurs the handler MUST invoke logger.warn with { path, code }.

Scenario: handler rewrites a valid URL

  • WHEN handler.rewrite({ path: "/uv16:9/avatar.png", host: "images.example.com" }) is called with a signed config
  • THEN the result has rewritten: true, targetPath starts with a signature prefix, operation === "cover", and the duration histogram has one recorded sample

Verified by: src/handler/handler.test.ts::"rewrites a valid imgproxy URL" AND src/handler/handler.test.ts::"records caddy_imgproxy_rewrite_duration_ms via the injected clock"

Scenario: handler reports passthrough for direct imgproxy paths

  • WHEN handler.rewrite({ path: "/unsafe/anything", host: "example.com" }) or { path: "/health", host: "..." } is called
  • THEN the result is { rewritten: false, reason: "passthrough" }

Verified by: src/handler/handler.test.ts::"reports passthrough for /unsafe paths" AND src/handler/handler.test.ts::"reports passthrough for /health paths"

Scenario: handler reports invalid for unparsable paths

  • WHEN handler.rewrite({ path: "/garbage", host: "example.com" }) is called
  • THEN the result is { rewritten: false, reason: "invalid", error: CaddyImgproxyError }, the logger receives a warn, and the counter records outcome=invalid

Verified by: src/handler/handler.test.ts::"reports invalid for unparsable paths and warns the logger"


Requirement: CADDY_IMGPROXY_CODES registry via defineCodes

The package MUST publish a stable CADDY_IMGPROXY_CODES registry built with defineCodes("CADDY_IMGPROXY", [...]) and a CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode> class with a public constructor delegating to super. The registered codes MUST be INVALID_URL, MISSING_DIMENSIONS, INVALID_OPTIONS, INVALID_KEY, INVALID_SALT, INVALID_HOST. Every code MUST be reachable from at least one call site exercised by the tests.

Scenario: every code is namespaced and reachable

  • WHEN the registry is inspected and the tests run
  • THEN each value has the form CADDY_IMGPROXY.<REASON> and at least one test throws or carries it

Verified by: src/error-codes/error-codes.test.ts::"namespaces every code under CADDY_IMGPROXY." AND src/error-codes/error-codes.test.ts::"exposes the expected leaf names" AND src/error-codes/error-codes.test.ts::"carries the supplied code, message, cause, and context" AND src/error-codes/error-codes.test.ts::"every registered code is thrown from at least one call site"


Requirement: Telemetry instruments emit on every public call

The package MUST register an OTel meter named @suaveplan/caddy-imgproxy exposing:

  • caddy_imgproxy_parse_total counter — incremented on every parseImgproxyUrl call, tagged with outcome ∈ ok | invalid | missing_dims.
  • caddy_imgproxy_rewrite_total counter — incremented on every ImgproxyHandler.rewrite call, tagged with outcome and operation.
  • caddy_imgproxy_sign_total counter — incremented on every buildImgproxyUrl call, tagged with mode ∈ signed | unsafe.
  • caddy_imgproxy_rewrite_duration_ms histogram — recorded on every ImgproxyHandler.rewrite call.

A real in-memory MeterProvider registered before module import MUST observe metric points after public calls.

Verified by: src/telemetry/telemetry.test.ts::"records parse outcomes" AND src/telemetry/telemetry.test.ts::"records rewrite outcomes and durations" AND src/telemetry/telemetry.test.ts::"records sign modes"


Requirement: Package surface, exports, and tier purity

The package MUST be universal-tier (no DOM/Node-only APIs at the top-level boundary) with sideEffects: false. Every feature module (parse, dimensions, gravity, operation, signing, url-builder, domain, handler, config, error-codes, telemetry, types) MUST be reachable via a subpath export in package.json so consumers can tree-shake. Every .ts implementation file MUST have a sibling .md and a sibling .test.ts. Barrels MUST use explicit named re-exports (no export * in non-root barrels).

Scenario: every feature module is tree-shakeable

  • WHEN node -e "console.log(Object.keys(require('./package.json').exports))" is executed
  • THEN the output enumerates every feature module subpath

Verified by: src/parse/parse-url.test.ts::"parses passthrough unsigned URL" (smoke import via subpath in test setup) AND the §13 pre-publish gate's subpath-count check.