# Capability Spec Delta: storage-cdn-bunnycdn (storage-cdn-bunnycdn) **Package**: `@suaveplan/storage-cdn-bunnycdn` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## Requirements ### Requirement: BunnyCdnAdapter extends CdnAdapter The package SHALL provide a `BunnyCdnAdapter` class extending `CdnAdapter` from `@suaveplan/storage-cdn`. The class SHALL implement every abstract method on `AbstractStorage` AND the three `CdnAdapter` methods (`purge`, `signUrl`, `edgeUrl`). #### Scenario: instance is CdnAdapter and AbstractStorage - **WHEN** a caller constructs `new BunnyCdnAdapter({ storageZone, storageAccessKey, pullZoneHostname })` - **THEN** the instance is `instanceof CdnAdapter` AND `instanceof AbstractStorage` #### Scenario: capabilities declare prefix purge and signed URLs - **WHEN** a caller reads `storage.capabilities` - **THEN** `partialReads === true`, `nativeStreams === true`, `partialWrites === false`, `atomicWrites === false`, `contentAddressable === false` - **AND** `storage.capabilities.cdn` equals `{ cdnPurge: true, cdnPrefixPurge: true, cdnSignedUrls: true, cdnOriginUploads: true }` **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.spec-conformance.test.ts::"instance is CdnAdapter and AbstractStorage"` AND `src/bunny-cdn-adapter/bunny-cdn-adapter.spec-conformance.test.ts::"declares the documented capabilities matrix"` --- ### Requirement: Options validated via Zod schema The package SHALL declare `BunnyCdnAdapterOptions` and `BunnyCdnAdapterOptionsSchema`. The constructor SHALL parse options through the schema and throw `StorageCdnBunnycdnError` with `STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS` on failure. `storageZone`, `storageAccessKey`, AND `pullZoneHostname` SHALL be required non-empty strings. `storageRegion` SHALL be one of `de|ny|la|sg|syd|uk` (default `de`); other values throw `STORAGE_CDN_BUNNYCDN.INVALID_REGION`. `pullZoneHostname` SHALL NOT include scheme or path — only the bare host (e.g. `mycdn.b-cdn.net`). #### Scenario: pullZoneHostname with scheme is rejected - **WHEN** a caller constructs with `pullZoneHostname: "https://mycdn.b-cdn.net"` - **THEN** the constructor throws `StorageCdnBunnycdnError` with `code === "STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS"` #### Scenario: unknown storageRegion is rejected - **WHEN** a caller constructs with `storageRegion: "tokyo"` - **THEN** the constructor throws `StorageCdnBunnycdnError` with `code === "STORAGE_CDN_BUNNYCDN.INVALID_REGION"` **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"rejects pullZoneHostname with scheme"` AND `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"rejects unknown storageRegion"` --- ### Requirement: Storage Zone CRUD with AccessKey header `set(key, bytes)` SHALL `PUT https://.storage.bunnycdn.com//` with header `AccessKey: ` and the bytes as body. `get(key)` SHALL `GET` the same URL. `delete(key)` SHALL `DELETE` it. `exists(key)` SHALL `HEAD` it. The `de` region SHALL resolve to `storage.bunnycdn.com` (the documented unprefixed origin); other regions SHALL prepend the region code. #### Scenario: de region uses unprefixed storage host - **WHEN** `storageRegion: "de"` is configured AND a caller invokes `set("a/b.bin", bytes)` - **THEN** the injected `FetchLike` sees a `PUT https://storage.bunnycdn.com//a/b.bin` with `AccessKey: ` #### Scenario: ny region prefixes the host - **WHEN** `storageRegion: "ny"` is configured AND a caller invokes `set("a/b.bin", bytes)` - **THEN** the injected `FetchLike` sees a `PUT https://ny.storage.bunnycdn.com//a/b.bin` #### Scenario: 401 from storage zone surfaces STORAGE_AUTH_FAILED - **WHEN** `PUT` returns `401 Unauthorized` - **THEN** the call throws `StorageCdnBunnycdnError` with `code === "STORAGE_CDN_BUNNYCDN.STORAGE_AUTH_FAILED"` **Verified by:** `src/storage-client/storage-client.test.ts::"de region uses unprefixed storage host"` AND `src/storage-client/storage-client.test.ts::"ny region prefixes the host"` AND `src/storage-client/storage-client.test.ts::"401 surfaces STORAGE_AUTH_FAILED"` --- ### Requirement: GET returns undefined on 404 `get(key)` SHALL return `undefined` (not throw) when the Storage Zone returns `404 Not Found`. `exists(key)` SHALL return `false` on `404`. #### Scenario: 404 from GET yields undefined - **WHEN** `GET` returns `404` - **THEN** `await storage.get("k")` returns `undefined` **Verified by:** `src/storage-client/storage-client.test.ts::"GET 404 returns undefined"` --- ### Requirement: Range reads via getPartial `getPartial(key, offset, length, signal?)` SHALL issue `GET` with header `Range: bytes=-`. On `206` the body SHALL be returned verbatim. On `200` the body SHALL be sliced client-side AND `storage_cdn_bunnycdn.range_fallback_total` SHALL increment. `capabilities.partialReads` SHALL be `true`. #### Scenario: 206 returns partial body verbatim - **WHEN** `GET` with Range resolves to `206` with `[2,3]` - **THEN** `getPartial("k", 1, 2)` returns `[2,3]` **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"Range request returns 206 body verbatim"` --- ### Requirement: Native streams via createReadStream `createReadStream(key, signal?)` SHALL return the `Response.body` `ReadableStream` directly without buffering. Cancelling the stream SHALL abort the in-flight fetch. #### Scenario: response body returned without buffering - **WHEN** the response body is three 1KiB chunks - **AND** the caller reads `createReadStream("k")` to completion - **THEN** the reader observes three chunks in order **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"createReadStream returns Response.body without buffering"` --- ### Requirement: edgeUrl composes from Pull Zone hostname `edgeUrl(key)` SHALL return `https:///`. The method SHALL be synchronous AND SHALL never throw. #### Scenario: edgeUrl composes Pull Zone URL with encoded key - **WHEN** `pullZoneHostname: "mycdn.b-cdn.net"` AND the caller invokes `storage.edgeUrl("a b/c.png")` - **THEN** the result is `https://mycdn.b-cdn.net/a%20b/c.png` **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"edgeUrl composes Pull Zone URL with encoded key"` --- ### Requirement: Single-URL purge via Bunny.net management API `purge(key, signal?)` SHALL `POST https://api.bunny.net/purge?url=` with header `AccessKey: `. On `2xx` the call SHALL resolve. On `401` it SHALL throw `STORAGE_CDN_BUNNYCDN.PURGE_AUTH_FAILED`. When `purgeApiKey` is missing the call SHALL throw `STORAGE_CDN_BUNNYCDN.PURGE_API_KEY_MISSING`. #### Scenario: 200 purge resolves - **WHEN** `POST` to the purge endpoint resolves to `200` - **THEN** `await storage.purge("k")` resolves #### Scenario: missing purge key throws PURGE_API_KEY_MISSING - **WHEN** the adapter was constructed without `purgeApiKey` AND a caller invokes `storage.purge("k")` - **THEN** the call throws `StorageCdnBunnycdnError` with `code === "STORAGE_CDN_BUNNYCDN.PURGE_API_KEY_MISSING"` **Verified by:** `src/pull-zone-client/pull-zone-client.test.ts::"single URL purge resolves on 200"` AND `src/pull-zone-client/pull-zone-client.test.ts::"missing purge key throws PURGE_API_KEY_MISSING"` --- ### Requirement: Prefix purge via trailing wildcard The adapter SHALL expose `purgePrefix(prefix: string, signal?: AbortSignal): Promise` (in addition to the single-key `purge`). The method SHALL `POST https://api.bunny.net/purge?url=*` with the purge API key. `capabilities.cdn.cdnPrefixPurge` SHALL be `true`. #### Scenario: prefix purge appends wildcard to URL - **WHEN** a caller invokes `storage.purgePrefix("images/")` - **AND** the injected `FetchLike` observes the request - **THEN** the URL has query `url=https%3A%2F%2F%2Fimages%2F*` **Verified by:** `src/pull-zone-client/pull-zone-client.test.ts::"prefix purge appends wildcard to URL"` --- ### Requirement: signUrl produces BunnyCDN Token Authentication signature `signUrl(key, { ttlSeconds, method })` SHALL produce a URL of the form `?token=&expires=` where `` is the URL-safe base64 (with `+`→`-`, `/`→`_`, no padding) of `SHA256(tokenAuthKey + path + expires)` per the BunnyCDN Token Authentication spec. Path-scoped tokens SHALL be supported via the optional `tokenPath` option. The method SHALL throw `STORAGE_CDN_BUNNYCDN.TOKEN_AUTH_KEY_MISSING` when no `tokenAuthKey` was configured. `ttlSeconds <= 0` SHALL throw `STORAGE_CDN.INVALID_TTL`. #### Scenario: signed URL matches BunnyCDN reference vector - **WHEN** the documented BunnyCDN test inputs are supplied (key, path, expires) - **THEN** the resulting token equals the documented expected value byte-for-byte #### Scenario: missing tokenAuthKey throws - **WHEN** the adapter was constructed without `tokenAuthKey` AND a caller invokes `signUrl(…)` - **THEN** the call throws `StorageCdnBunnycdnError` with `code === "STORAGE_CDN_BUNNYCDN.TOKEN_AUTH_KEY_MISSING"` **Verified by:** `src/token-auth/token-auth.test.ts::"signature matches BunnyCDN reference vector"` AND `src/token-auth/token-auth.test.ts::"throws TOKEN_AUTH_KEY_MISSING when no key configured"` --- ### Requirement: list via BunnyCDN Storage list endpoint `list(prefix?, options?)` SHALL `GET https://.storage.bunnycdn.com///` and map the documented JSON array of `{ ObjectName, Path, Length, LastChanged, IsDirectory, … }` entries to `{ keys, total, hasMore: false, continuationToken: undefined }` for files only (`IsDirectory === false`). When `options.recursive === true` the adapter SHALL recurse client-side. When the prefix points at a non-existent directory the adapter SHALL return `{ keys: [], total: 0, hasMore: false }`. #### Scenario: list maps Bunny JSON to AbstractStorage shape - **WHEN** the injected `FetchLike` returns the documented JSON array with two files and one directory - **AND** the caller invokes `storage.list("img/")` - **THEN** the result has `keys` containing exactly the two file paths AND `total === 2` **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"list maps Bunny JSON to AbstractStorage shape"` --- ### Requirement: Strict key sanitisation at the boundary Every method that accepts a key SHALL pass it through `sanitizeKey` (same semantics as `storage-http`: empty, `..`, leading `/`, null bytes, control chars, >2048 chars → `STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS`). Per-segment `encodeURIComponent` SHALL be applied for URL composition, preserving `/`. #### Scenario: path traversal is rejected before fetch - **WHEN** a caller calls `storage.get("../../etc/passwd")` - **THEN** the call rejects with `StorageCdnBunnycdnError` AND no fetch is issued **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"rejects path-traversal keys before fetch"` --- ### Requirement: Retries on transient status and Retry-After honour The adapter SHALL classify status `408`, `425`, `429`, `5xx` AND network rejection as transient. Retries SHALL go through `@suaveplan/retry.retry` with injectable `TimersLike`. `Retry-After` SHALL be honoured when present. Exhausted retries SHALL surface as `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`. #### Scenario: 503 with Retry-After is retried - **WHEN** the first call resolves to `503` with `Retry-After: 1` then the second resolves to `200` - **THEN** the caller observes one wait of at least 1000 ms under `FakeClock` #### Scenario: 401 is NOT retried - **WHEN** any call returns `401` - **THEN** the call rejects without further attempts **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"retries 503 honouring Retry-After"` AND `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"does not retry 401"` --- ### Requirement: AbortSignal threaded into every fetch Every public async method SHALL accept `signal?: AbortSignal` and thread it into the underlying fetch. Pre-aborted signal SHALL short-circuit before any fetch is issued. Mid-flight abort SHALL propagate. #### Scenario: pre-aborted signal short-circuits - **WHEN** a caller passes an already-aborted signal to any method - **THEN** the call rejects before any fetch is issued **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"pre-aborted signal short-circuits all methods"` --- ### Requirement: STORAGE_CDN_BUNNYCDN error registry The package SHALL declare `STORAGE_CDN_BUNNYCDN_CODES` via `defineCodes("STORAGE_CDN_BUNNYCDN", [...])` with exactly these 8 codes: `STORAGE_AUTH_FAILED`, `PURGE_AUTH_FAILED`, `ZONE_NOT_FOUND`, `TOKEN_AUTH_KEY_MISSING`, `PURGE_API_KEY_MISSING`, `INVALID_REGION`, `INVALID_OPTIONS`, `UPSTREAM_ERROR`. `StorageCdnBunnycdnError` SHALL include `zone`, `region`, and `httpStatus` discriminants. Every code SHALL be reachable from a real call site exercised by tests. #### Scenario: every code is reachable - **WHEN** the test suite runs - **THEN** every code in `STORAGE_CDN_BUNNYCDN_CODES` is produced by at least one test **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.spec-conformance.test.ts::"every STORAGE_CDN_BUNNYCDN code is reachable"` --- ### Requirement: Module-augmentation registry slot The package SHALL declare `declare module "@suaveplan/storage"` augmenting the registry with `bunnycdn: BunnyCdnAdapter` and the config map with the adapter options. The package SHALL export `registerBunnyCdnProvider(): void` that registers the constructor idempotently. #### Scenario: createStorage with type "bunnycdn" yields BunnyCdnAdapter - **WHEN** `registerBunnyCdnProvider()` has been called - **AND** the caller invokes `createStorage({ type: "bunnycdn", config: { … } })` - **THEN** the returned instance is `instanceof BunnyCdnAdapter` **Verified by:** `src/registry/registry.test.ts::"createStorage with type bunnycdn returns BunnyCdnAdapter"` --- ### Requirement: OpenTelemetry instrumentation with provider attribute The package SHALL emit `storage_cdn_bunnycdn.op_duration_ms` and `…io_bytes` histograms; `…reads_total`, `…writes_total`, `…deletes_total`, `…purges_total`, `…signed_urls_total`, `…errors_total`, `…range_fallback_total` counters. Every measurement SHALL carry `provider="bunnycdn"` AND `storage_region` from the configuration. Spans SHALL be opened on the `@suaveplan/storage-cdn-bunnycdn` tracer with `http.url`, `http.method`, `http.status_code`, `storage.key`, `storage.zone` attributes. #### Scenario: successful PUT emits writes_total with provider and region attrs - **WHEN** a `set` succeeds against an in-memory `MeterProvider` - **THEN** `storage_cdn_bunnycdn.writes_total{outcome="ok",provider="bunnycdn",storage_region="de"}` increments by 1 **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts::"PUT emits writes_total with provider and region attrs"` --- ### Requirement: Tier purity — universal, fetch-only The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. HMAC/SHA256 SHALL use `globalThis.crypto.subtle` (WebCrypto), available in Node 18+, Bun, Deno, browsers, and Workers. #### Scenario: tier-purity grep finds no Node or DOM imports - **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-cdn-bunnycdn/src/` is run - **THEN** the output is empty **Verified by:** `src/bunny-cdn-adapter/bunny-cdn-adapter.spec-conformance.test.ts::"src tree contains no node: or bun: imports"`