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 (operationvwith no dimensions) - THEN the call throws
CaddyImgproxyErrorwithcode === 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 leadings|uprefix) - THEN the call throws
CaddyImgproxyErrorwithcode === 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
calculateDimensionsis called with aspect9:16,hintType: "h",hintValue: 1080, default800 - 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
calculateDimensionsis called withisAspect: 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
operationToImgproxyis 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
signImgproxyPathis called with a fixedkey,salt, andpath - 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
hashimplementation is supplied - THEN the seam's
hmacis 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:
- Resolve
(width, height)viacalculateDimensions(parsed, config.defaultWidth). - Resolve the processing string via
operationToImgproxy(parsed.operation, width, height, parsed.gravity). - Append
q:<quality>whenparsed.quality > 0. - Append
plain/s3://<bucket>/<domain>/<path>@<format>. - When both
config.keyandconfig.saltare 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
buildImgproxyUrlis called with a parsed/uv16:9/avatar.png, domainexample.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
buildImgproxyUrlis called with signed and unsigned configs - THEN the
caddy_imgproxy_sign_totalcounter records the matchingmodetag
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,targetPathstarts 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 awarn, and the counter recordsoutcome=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_totalcounter — incremented on everyparseImgproxyUrlcall, tagged withoutcome∈ok | invalid | missing_dims.caddy_imgproxy_rewrite_totalcounter — incremented on everyImgproxyHandler.rewritecall, tagged withoutcomeandoperation.caddy_imgproxy_sign_totalcounter — incremented on everybuildImgproxyUrlcall, tagged withmode∈signed | unsafe.caddy_imgproxy_rewrite_duration_mshistogram — recorded on everyImgproxyHandler.rewritecall.
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.