Initial Commit
This commit is contained in:
142
openspec/archive/2026-05-25-enterprise-caddy-imgproxy/design.md
Normal file
142
openspec/archive/2026-05-25-enterprise-caddy-imgproxy/design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Design — @suaveplan/caddy-imgproxy
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/media/image/caddy-imgproxy/
|
||||
package.json # 0.1.0, exports per feature module
|
||||
vite.config.ts # multi-entry build mirroring exports
|
||||
vitest.config.ts # provider: istanbul, thresholds 100/100/100/100
|
||||
tsconfig.lib.json
|
||||
README.md
|
||||
tests/setup/polyfills.ts
|
||||
src/
|
||||
index.ts # explicit named re-exports
|
||||
parse/
|
||||
parse-url.ts # parseImgproxyUrl
|
||||
parse-url.test.ts
|
||||
parse-url.md
|
||||
index.ts # explicit named barrel
|
||||
dimensions/
|
||||
calculate-dimensions.ts # calculateDimensions
|
||||
calculate-dimensions.test.ts
|
||||
calculate-dimensions.md
|
||||
index.ts
|
||||
gravity/
|
||||
gravity.ts # gravityToImgproxy + GRAVITY_MAP
|
||||
gravity.test.ts
|
||||
gravity.md
|
||||
index.ts
|
||||
operation/
|
||||
operation.ts # operationToImgproxy
|
||||
operation.test.ts
|
||||
operation.md
|
||||
index.ts
|
||||
signing/
|
||||
sign-path.ts # signImgproxyPath, HashLike injection
|
||||
sign-path.test.ts
|
||||
sign-path.md
|
||||
index.ts
|
||||
url-builder/
|
||||
url-builder.ts # buildImgproxyUrl orchestrator
|
||||
url-builder.test.ts
|
||||
url-builder.md
|
||||
index.ts
|
||||
domain/
|
||||
extract-domain.ts
|
||||
extract-domain.test.ts
|
||||
extract-domain.md
|
||||
index.ts
|
||||
handler/
|
||||
handler.ts # ImgproxyHandler orchestrator
|
||||
handler.test.ts
|
||||
handler.md
|
||||
index.ts
|
||||
config/
|
||||
config.ts # ImgproxyConfigSchema, parseConfig
|
||||
config.test.ts
|
||||
config.md
|
||||
index.ts
|
||||
error-codes/
|
||||
error-codes.ts # CADDY_IMGPROXY_CODES + CaddyImgproxyError
|
||||
error-codes.test.ts
|
||||
error-codes.md
|
||||
index.ts
|
||||
telemetry/
|
||||
telemetry.ts # counters + histogram
|
||||
telemetry.test.ts
|
||||
telemetry.md
|
||||
index.ts
|
||||
types/
|
||||
types.ts # ParsedImgproxyUrl, ImgproxyConfig, RewriteResult
|
||||
index.ts
|
||||
```
|
||||
|
||||
## URL grammar (verbatim port of Go regex)
|
||||
|
||||
```text
|
||||
^/
|
||||
([su]) # 1 signed | unsigned
|
||||
([vcfsr])? # 2 op (v/c/f/s/r) opt
|
||||
(tl|tr|bl|br|[tblrc])? # 3 gravity opt
|
||||
(?:
|
||||
([1-9]\d*):([1-9]\d*) # 4,5 aspect W:H
|
||||
| ([1-9]\d*)x(\d+) # 6,7 pixel WxH (W>0)
|
||||
| (0)x([1-9]\d*) # 8,9 pixel 0xH (auto-width)
|
||||
| ([1-9]\d*) # 10 square shorthand
|
||||
)?
|
||||
((?:[wh][1-9]\d*|q(?:100|[1-9]\d?))*) # 11 modifiers
|
||||
/(.+)\.([a-z]+)$ # 12 path / 13 format
|
||||
```
|
||||
|
||||
Modifier sub-regex: `([whq])(\d+)` — extract `w<int>`, `h<int>`, `q<int>` in any order.
|
||||
|
||||
## Dimensions algorithm
|
||||
|
||||
```ts
|
||||
if (!hasDims) return { width: 0, height: 0 };
|
||||
if (!isAspect) return { width: pixelW, height: pixelH };
|
||||
|
||||
// aspect path
|
||||
if (hintType === "h" && hintValue > 0) {
|
||||
height = hintValue;
|
||||
width = Math.trunc((height * aspectW) / aspectH);
|
||||
} else {
|
||||
width = hintValue > 0 ? hintValue : defaultWidth;
|
||||
height = Math.trunc((width * aspectH) / aspectW);
|
||||
}
|
||||
```
|
||||
|
||||
(`Math.trunc` matches Go integer division.)
|
||||
|
||||
## Operation → imgproxy table
|
||||
|
||||
| op | both W&H | only W | only H | none |
|
||||
|---|---|---|---|---|
|
||||
| cover | `g:<g>/rs:fill:W:H` | `g:<g>/w:W` | `g:<g>/h:H` | `""` |
|
||||
| fit | `rs:fit:W:H` | `w:W` | `h:H` | `""` |
|
||||
| crop | `g:<g>/c:W:H` | `""` | `""` | `""` |
|
||||
| stretch | `rs:force:W:H` | `""` | `""` | `""` |
|
||||
| resize | — | `w:W` | `h:H` | `""` |
|
||||
| `""` (passthrough) | `""` | `""` | `""` | `""` |
|
||||
|
||||
## Signing
|
||||
|
||||
`HMAC-SHA256(keyBytes, saltBytes || pathBytes)` → base64url, no padding. Implementation uses `hmac` from `@suaveplan/crypto-hash` with `Uint8Array` inputs. Hex key/salt decoded via `fromHex`.
|
||||
|
||||
## Telemetry
|
||||
|
||||
| Instrument | Kind | Tags | Where |
|
||||
|---|---|---|---|
|
||||
| `caddy_imgproxy_parse_total` | counter | `outcome` ∈ `ok|invalid|unknown_op|missing_dims` | `parseImgproxyUrl` |
|
||||
| `caddy_imgproxy_rewrite_total` | counter | `outcome` ∈ `rewritten|passthrough|invalid`, `operation` | `ImgproxyHandler.rewrite` |
|
||||
| `caddy_imgproxy_sign_total` | counter | `mode` ∈ `signed|unsafe` | `buildImgproxyUrl` |
|
||||
| `caddy_imgproxy_rewrite_duration_ms` | histogram | — | `ImgproxyHandler.rewrite` |
|
||||
|
||||
## Tier & deps
|
||||
|
||||
Universal-tier. Deps: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto-hash`, `@suaveplan/opentelemetry`, `@opentelemetry/api`, `tslib`, `zod`. DevDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`, `@types/node`.
|
||||
|
||||
## Test plan
|
||||
|
||||
Re-port every `imgproxy_test.go` case to `parse-url.test.ts` + `dimensions/...test.ts` + `operation/...test.ts` + `gravity/...test.ts` + `domain/...test.ts` + `url-builder/...test.ts`. Plus genesis-only tests: Zod boundary failures, error-code reachability, telemetry emission via a real `MeterProvider`, signing KAT against a fixed key/salt pair (mirrors Go `signPath` output).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Enterprise migration: @suaveplan/caddy-imgproxy
|
||||
|
||||
**Package**: `@suaveplan/caddy-imgproxy`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (migration from upstream)
|
||||
**Date**: 2026-05-25
|
||||
**Design**: ./design.md
|
||||
**Spec delta**: ./specs/caddy-imgproxy/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Upstream `../suaveplan/packages/server/caddy-imgproxy/` ships a Go Caddy module that rewrites compact URL grammars (`/u<op><gravity><dims><modifiers>/<path>.<format>`) into signed imgproxy URLs. The TypeScript side of the package is a 4-line placeholder. Genesis needs a real, universal-tier TypeScript port so the same grammar can be consumed from any genesis runtime (CDN workers, edge gateways, build-time tooling, integration tests for Caddy stacks) without leaving the monorepo's enterprise quality bar.
|
||||
|
||||
The migration re-implements the Go semantics exactly — same regex grammar, same gravity/operation mapping, same HMAC-SHA256 signing, same `s3://<bucket>/<domain>/<path>@<format>` layout — and adds the standard genesis layer: stable error codes via `defineCodes`, Zod-validated config, injectable `Clock` for deterministic spans, OTel counters + a duration histogram, and full 100% test coverage. The resulting package is the canonical reference for the URL grammar; Caddy hosts can either delegate to it via a sidecar or treat the Go module as a parallel implementation tested against the same fixtures.
|
||||
|
||||
## What changes
|
||||
|
||||
New package `@suaveplan/caddy-imgproxy` at `packages/media/image/caddy-imgproxy/`. Public surface:
|
||||
|
||||
- ADDED: `parseImgproxyUrl(path)` — parses the compact grammar (`/u`, `/s`, `v|c|f|s|r`, `tl|tr|bl|br|t|b|l|r|c`, aspect / pixel / square dims, `w|h|q` modifiers) into a typed `ParsedImgproxyUrl`. Mirrors the Go `parseURL` semantics byte-for-byte; raises `CaddyImgproxyError(INVALID_URL | UNKNOWN_OPERATION | MISSING_DIMENSIONS)`.
|
||||
- ADDED: `calculateDimensions(parsed, defaultWidth)` — resolves aspect-ratio + hint into concrete (width, height) pixels with the same precedence as Go (`h` hint takes height-first, `w` hint or default takes width-first).
|
||||
- ADDED: `gravityToImgproxy(code)` — translates `t|b|l|r|c|tl|tr|bl|br` to imgproxy's `no|so|we|ea|ce|nowe|noea|sowe|soea`.
|
||||
- ADDED: `operationToImgproxy(op, width, height, gravity)` — emits the imgproxy processing string (`g:<g>/rs:fill:W:H` for cover, `rs:fit:W:H` for fit, `g:<g>/c:W:H` for crop, `rs:force:W:H` for stretch, `w:W`/`h:H` for resize).
|
||||
- ADDED: `signImgproxyPath({ key, salt, path, hash? })` — HMAC-SHA256 over `salt || path` (raw bytes), base64url no-padding encoded. Uses `@suaveplan/crypto-hash`'s `hmac` + `toBase64Url`; injectable `hash` for offline / native swaps.
|
||||
- ADDED: `buildImgproxyUrl({ parsed, domain, config })` — composes the full imgproxy path including signature prefix (or `/unsafe` when no key/salt). Inserts `q:<quality>` segment when present.
|
||||
- ADDED: `extractDomain(host)` — strips port, drops the `images.` subdomain.
|
||||
- ADDED: `ImgproxyHandler` — orchestrator with `rewrite(request)`: returns `{ rewritten: true; targetPath } | { rewritten: false; reason }` for downstream proxy clients. Accepts injectable `Clock`, `LoggerLike`, `IdGenerator` for tracing; tagged spans + counters emitted on every call.
|
||||
- ADDED: `ImgproxyConfigSchema` (Zod) and `parseConfig(raw)` — validates `upstream`, `s3Bucket`, `defaultWidth >= 1`, hex-encoded `key`/`salt` (even-length, lower-hex).
|
||||
- ADDED: `CADDY_IMGPROXY_CODES` + `CaddyImgproxyError` via `defineCodes("CADDY_IMGPROXY", [...])` — codes `INVALID_URL`, `UNKNOWN_OPERATION`, `MISSING_DIMENSIONS`, `INVALID_OPTIONS`, `INVALID_KEY`, `INVALID_SALT`, `INVALID_HOST`.
|
||||
- ADDED: OTel instruments at meter `@suaveplan/caddy-imgproxy` — counters `caddy_imgproxy_parse_total{outcome}`, `caddy_imgproxy_rewrite_total{outcome,operation}`, `caddy_imgproxy_sign_total{mode}`; histogram `caddy_imgproxy_rewrite_duration_ms`.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| URL grammar parsing (`/u`, `/s`, ops, gravity, dims, modifiers) | ✅ (Go) | ✅ | TS port mirrors regex + state machine verbatim |
|
||||
| Aspect-ratio → pixel calculation with `w`/`h` hint | ✅ | ✅ | |
|
||||
| Square-shorthand dims (e.g. `/uv128/avatar.png`) | ✅ | ✅ | |
|
||||
| Quality modifier `q1..q100` | ✅ | ✅ | |
|
||||
| Gravity codes (9 directions) | ✅ | ✅ | |
|
||||
| Operation → imgproxy processing string | ✅ | ✅ | |
|
||||
| HMAC-SHA256 URL signing, base64url no-pad | ✅ | ✅ | Via `@suaveplan/crypto-hash` |
|
||||
| `/unsafe` fallback when no key/salt | ✅ | ✅ | |
|
||||
| S3 path `s3://bucket/domain/path@format` | ✅ | ✅ | |
|
||||
| Domain extraction (`images.example.com` → `example.com`) | ✅ | ✅ | |
|
||||
| Caddyfile directive parsing | ✅ (Go-only) | 🚫 | Caddyfile lives in the Go module; TS package targets the URL-grammar contract. Justified: the Caddyfile is a Caddy-runtime artefact with no TypeScript equivalent; consumers wanting Caddy hosting use the Go module, the TS package serves edge/CDN/test consumers. |
|
||||
| HTTP reverse proxy execution | ✅ (Go) | 🚫 | Same justification: handler runs inside Caddy. TS package returns a rewrite decision; the caller owns the network round trip. |
|
||||
| Structured logging (zap) | ✅ | ⬆️ | Replaced with injectable `LoggerLike` from `@suaveplan/types` |
|
||||
| Defaults (`localhost:8080`, `images`, 800) | ✅ | ✅ | Encoded in `ImgproxyConfigSchema` |
|
||||
| Stable error codes via `defineCodes` | 🚫 | ⬆️ | Genesis-only: typed `CaddyImgproxyError` |
|
||||
| Zod-validated public config | 🚫 | ⬆️ | Genesis-only |
|
||||
| OTel counters + histogram | 🚫 | ⬆️ | Genesis-only |
|
||||
| 100% test coverage | ✅ (Go) | ✅ | TS port re-implements every Go test case |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/caddy-imgproxy` to enumerate. No existing dependents — fresh package landing.
|
||||
|
||||
- No dependents (verified by the package being at `–` in `STATUS.md` and absent from any `workspace:^` consumer).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The Go Caddy module itself is untouched. The Go source remains in `../suaveplan/packages/server/caddy-imgproxy/`. A future change MAY align the two via a shared test-vector corpus, but that work is not in this proposal.
|
||||
- HTTP reverse-proxy delivery (TS package returns rewrite decisions only).
|
||||
- Caddyfile directive parsing (Caddy runtime concern).
|
||||
@@ -0,0 +1,257 @@
|
||||
<!-- 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.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Tasks — @suaveplan/caddy-imgproxy
|
||||
|
||||
1. Scaffold `packages/media/image/caddy-imgproxy/` (package.json @ 0.1.0, vite/vitest/tsconfig, tests/setup/polyfills.ts).
|
||||
2. Implement `src/types/types.ts` (`ParsedImgproxyUrl`, `ImgproxyConfig`, `RewriteResult`).
|
||||
3. Implement `src/error-codes/error-codes.ts` (`CADDY_IMGPROXY_CODES` + `CaddyImgproxyError`) — verify every code is reachable from `src/`.
|
||||
4. Implement `src/telemetry/telemetry.ts` (3 counters + 1 histogram, meter `@suaveplan/caddy-imgproxy`).
|
||||
5. Implement `src/parse/parse-url.ts` — verbatim port of Go regex + state machine, telemetry on every call.
|
||||
6. Implement `src/dimensions/calculate-dimensions.ts` — aspect-ratio + hint resolution.
|
||||
7. Implement `src/gravity/gravity.ts` — 9-direction map + `gravityToImgproxy`.
|
||||
8. Implement `src/operation/operation.ts` — 5-op switch matching Go.
|
||||
9. Implement `src/signing/sign-path.ts` — `signImgproxyPath`, injectable hash via `HashLike`-shaped seam (default = `@suaveplan/crypto-hash`).
|
||||
10. Implement `src/url-builder/url-builder.ts` — composes signature + processing + `s3://...@<format>` segment.
|
||||
11. Implement `src/domain/extract-domain.ts` — strips port, drops `images.` subdomain.
|
||||
12. Implement `src/config/config.ts` — Zod schema + `parseConfig` raising `INVALID_OPTIONS`/`INVALID_KEY`/`INVALID_SALT`.
|
||||
13. Implement `src/handler/handler.ts` — `ImgproxyHandler` orchestrator with injectable `Clock` + `LoggerLike`, span + histogram emission.
|
||||
14. Author co-located `.md` for every implementation file (Purpose ≥2 sentences, Features, Usage Basic, Usage Advanced, API Reference, Implementation Notes, ≥200 words).
|
||||
15. Author `src/index.ts` explicit named re-export barrel and per-feature `index.ts` barrels (no `export *`).
|
||||
16. Author `README.md` at package root with H1, install, quick start, Modules section.
|
||||
17. Run `bunx turbo lint typecheck test build --filter=@suaveplan/caddy-imgproxy` — green.
|
||||
18. Run `bunx turbo test:coverage --filter=@suaveplan/caddy-imgproxy` — 100/100/100/100.
|
||||
19. Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
20. Flip the row in `STATUS.md` from `–` to `✅` with one-line README description.
|
||||
21. Fold `specs/caddy-imgproxy/spec.md` into `openspec/specs/caddy-imgproxy/spec.md`.
|
||||
22. Move `openspec/changes/enterprise-caddy-imgproxy/` → `openspec/archive/2026-05-25-enterprise-caddy-imgproxy/`.
|
||||
Reference in New Issue
Block a user