258 lines
17 KiB
Markdown
258 lines
17 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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.
|