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

15 KiB

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://<region>.storage.bunnycdn.com/<zone>/<encodedKey> with header AccessKey: <storageAccessKey> 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/<zone>/a/b.bin with AccessKey: <storageAccessKey>

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/<zone>/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=<offset>-<offset+length-1>. 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<Uint8Array> 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://<pullZoneHostname>/<encodedKey>. 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=<encodedEdgeUrl> with header AccessKey: <purgeApiKey>. 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<void> (in addition to the single-key purge). The method SHALL POST https://api.bunny.net/purge?url=<encodedEdgeUrl(prefix)>* 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<pullZoneHostname>%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 <edgeUrl>?token=<urlSafeBase64>&expires=<unix> where <urlSafeBase64> 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://<region>.storage.bunnycdn.com/<zone>/<encodedPrefix>/ 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"