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.
ADDED 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 CdnAdapterANDinstanceof 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.cdnequals{ 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
StorageCdnBunnycdnErrorwithcode === "STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS"
Scenario: unknown storageRegion is rejected
- WHEN a caller constructs with
storageRegion: "tokyo" - THEN the constructor throws
StorageCdnBunnycdnErrorwithcode === "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 invokesset("a/b.bin", bytes) - THEN the injected
FetchLikesees aPUT https://storage.bunnycdn.com/<zone>/a/b.binwithAccessKey: <storageAccessKey>
Scenario: ny region prefixes the host
- WHEN
storageRegion: "ny"is configured AND a caller invokesset("a/b.bin", bytes) - THEN the injected
FetchLikesees aPUT https://ny.storage.bunnycdn.com/<zone>/a/b.bin
Scenario: 401 from storage zone surfaces STORAGE_AUTH_FAILED
- WHEN
PUTreturns401 Unauthorized - THEN the call throws
StorageCdnBunnycdnErrorwithcode === "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
GETreturns404 - THEN
await storage.get("k")returnsundefined
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
GETwith Range resolves to206with[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 invokesstorage.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
POSTto the purge endpoint resolves to200 - THEN
await storage.purge("k")resolves
Scenario: missing purge key throws PURGE_API_KEY_MISSING
- WHEN the adapter was constructed without
purgeApiKeyAND a caller invokesstorage.purge("k") - THEN the call throws
StorageCdnBunnycdnErrorwithcode === "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
FetchLikeobserves 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
tokenAuthKeyAND a caller invokessignUrl(…) - THEN the call throws
StorageCdnBunnycdnErrorwithcode === "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
FetchLikereturns the documented JSON array with two files and one directory - AND the caller invokes
storage.list("img/") - THEN the result has
keyscontaining exactly the two file paths ANDtotal === 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
StorageCdnBunnycdnErrorAND 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
503withRetry-After: 1then the second resolves to200 - 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_CODESis 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
setsucceeds against an in-memoryMeterProvider - 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"