Initial Commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# Add @suaveplan/storage-cdn-bunnycdn — BunnyCDN Storage + Pull Zone adapter
|
||||
|
||||
**Package**: `@suaveplan/storage-cdn-bunnycdn`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (genesis-original)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/storage-cdn-bunnycdn/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The user operates production traffic from BunnyCDN. BunnyCDN's architecture is two services: **Storage Zones** (the origin — HTTP PUT/GET/DELETE against `https://<region>.storage.bunnycdn.com/<zone>/<key>` with `AccessKey` header) and **Pull Zones** (the edge — `https://<zone>.b-cdn.net/<key>` plus optional Token Authentication for signed URLs and a per-Pull-Zone purge endpoint). This package wraps both behind the `CdnAdapter` contract so application code can use it identically to Rackspace.
|
||||
|
||||
BunnyCDN's API is simpler than Rackspace's (no service catalog, no Swift TempURL middleware — just `AccessKey` headers and HMAC-SHA256 token auth on Pull Zones). The package mirrors the Rackspace structure for consistency.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `BunnyCdnAdapter` class extending `CdnAdapter` from `@suaveplan/storage-cdn`.
|
||||
- ADDED: `createBunnyCdnAdapter(options)` factory.
|
||||
- ADDED: `BunnyCdnAdapterOptions` interface + Zod schema. Required: `storageZone` (zone name), `storageAccessKey`, `pullZoneHostname` (e.g. `mycdn.b-cdn.net`). Optional: `storageRegion` (default `de` — Falkenstein; supported: `de`, `ny`, `la`, `sg`, `syd`, `uk`), `tokenAuthKey` (for signed URLs), `purgeApiKey` (account-level API key for the Bunny.net management API), `fetch`, `retry`, `headers`.
|
||||
- ADDED: `BunnyStorageClient` — `PUT/GET/DELETE/HEAD/LIST` against `https://<region>.storage.bunnycdn.com/<zone>/<key>` with `AccessKey: <storageAccessKey>` header. `LIST` returns the documented JSON array shape.
|
||||
- ADDED: `BunnyPullZoneClient` — purge a single URL via `POST https://api.bunny.net/purge?url=<edgeUrl>` with `AccessKey: <purgeApiKey>`; purge by prefix supported via `url=<prefix>*`.
|
||||
- ADDED: `BunnyTokenAuth` — HMAC-SHA256 Token Authentication per BunnyCDN docs. Computes `token = base64(SHA256(tokenAuthKey + path + expires))` with URL-safe base64; appends `?token=<token>&expires=<unix>` (or `&token_path=<path>` for path-scoped tokens).
|
||||
- ADDED: `STORAGE_CDN_BUNNYCDN_CODES` — 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` class with `zone`/`region`/`httpStatus` discriminants.
|
||||
- ADDED: Capabilities — `partialReads: true` (Storage supports `Range`), `nativeStreams: true`, `partialWrites: false`, `atomicWrites: false`, `contentAddressable: false`; `cdn: { cdnPurge: true, cdnPrefixPurge: true, cdnSignedUrls: true, cdnOriginUploads: true }`.
|
||||
- ADDED: Module-augmentation registry — `declare module "@suaveplan/storage"` adds `bunnycdn: BunnyCdnAdapter` slot; `registerBunnyCdnProvider()` idempotent.
|
||||
- ADDED: `@suaveplan/retry` on transient (`429`, `5xx`, network) with `Retry-After` honour.
|
||||
- ADDED: OTel surface — same shape as Rackspace adapter, with `provider="bunnycdn"` and `storage_region` attributes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Bunny.net Stream / Optimizer / Shield.** Edge-side video, image optimization, and origin shielding are separate Bunny.net products with separate APIs. Out of scope.
|
||||
- **Pull Zone management API.** Creating/configuring Pull Zones via the management API is deployment tooling, not the runtime adapter. The adapter assumes the Pull Zone is already configured.
|
||||
- **Edge-side logging / analytics.** Bunny.net log delivery is operations tooling.
|
||||
- **Per-region replication strategy.** Bunny Storage replicates automatically to selected regions; the adapter uses `storageRegion` only to pick the write endpoint. Replication policy is configured in the Bunny.net dashboard.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. References:
|
||||
|
||||
- BunnyCDN Storage HTTP API: <https://docs.bunny.net/reference/put_-storagezonename-path-filename->
|
||||
- BunnyCDN Pull Zone purge API: <https://docs.bunny.net/reference/purgepublic_indexpost>
|
||||
- BunnyCDN Token Authentication: <https://docs.bunny.net/docs/cdn-token-authentication>
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Two distinct access keys (`storageAccessKey`, `purgeApiKey`) | Storage zones use a per-zone storage password; purge uses the account-level API key. They are different secrets with different scopes; conflating them is a security mistake. |
|
||||
| `cdnPrefixPurge: true` | BunnyCDN's purge endpoint accepts URLs ending in `*` for prefix purge. This is genuinely supported (unlike Rackspace where it requires per-key calls). |
|
||||
| `tokenAuthKey` separate from storage key | Token Authentication signing key is configured on the Pull Zone, not on the Storage Zone. Caller provides it explicitly. |
|
||||
| Region in storage URL but Pull Zone is global | Storage writes go to the regional endpoint; Pull Zone reads come from the global CDN. The split matches Bunny's architecture and avoids the "edge URL with region in it" anti-pattern. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts` — CRUD via injected `FetchLike`, range reads, edgeUrl composition, purge happy path + prefix purge, signed-URL round-trip, AbortSignal threading, retry on 429/503.
|
||||
- `src/bunny-cdn-adapter/bunny-cdn-adapter.spec-conformance.test.ts` — `instanceof CdnAdapter`, capabilities matrix, every `STORAGE_CDN_BUNNYCDN` code reachable, tier-purity grep.
|
||||
- `src/storage-client/storage-client.test.ts` — PUT/GET/DELETE/HEAD/LIST round-trips, regional endpoint selection, `AccessKey` header attached.
|
||||
- `src/pull-zone-client/pull-zone-client.test.ts` — single URL purge, prefix purge (URL ending `*`), purge auth failure → `PURGE_AUTH_FAILED`.
|
||||
- `src/token-auth/token-auth.test.ts` — HMAC-SHA256 signature matches Bunny reference vector, path-scoped tokens, expired token rejected.
|
||||
- `src/registry/registry.test.ts` — `createStorage({ type: "bunnycdn", config })` returns `BunnyCdnAdapter`, idempotent re-registration.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
- No dependents yet (`bunx turbo ls --filter=...@suaveplan/storage-cdn-bunnycdn`).
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
- `@suaveplan/storage-cdn` — `StorageProviderRegistry` augmented with `bunnycdn` slot. Additive. [x] no action needed.
|
||||
- Hard dependency on `@suaveplan/storage-cdn` (must be archived first).
|
||||
@@ -0,0 +1,282 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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 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"`
|
||||
@@ -0,0 +1,69 @@
|
||||
# Tasks — storage-cdn-bunnycdn
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
> Hard dependency: `@suaveplan/storage-cdn` (change `add-storage-cdn`) MUST be archived first.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/changes/add-storage-cdn/specs/storage-cdn/spec.md` for the contract
|
||||
- [ ] 1.2 Read `openspec/changes/add-storage-cdn-rackspace/` for the sibling provider pattern
|
||||
- [ ] 1.3 Read BunnyCDN Storage HTTP API, Pull Zone purge API, and Token Authentication docs
|
||||
- [ ] 1.4 Draft `proposal.md`, `design.md`, `specs/storage-cdn-bunnycdn/spec.md`, `tasks.md`
|
||||
- [ ] 1.5 Numbered Requirement/Scenario checklist per §0c step 6
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [ ] 2.1 Create `packages/storage/storage-cdn-bunnycdn/`
|
||||
- [ ] 2.2 `package.json` v0.0.0, `"sideEffects": false`, subpath exports
|
||||
- [ ] 2.3 Deps: `@suaveplan/error`, `@suaveplan/storage`, `@suaveplan/storage-cdn`, `@suaveplan/retry`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib`
|
||||
- [ ] 2.4 DevDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
|
||||
- [ ] 2.5 `tsconfig`, `vite.config.ts`, `vitest.config.ts` via `createTestConfig`
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [ ] 3.1 `src/error-codes.ts` — `defineCodes("STORAGE_CDN_BUNNYCDN", [...])` with 8 codes, `StorageCdnBunnycdnError`
|
||||
- [ ] 3.2 `src/types/*.ts` + `.schemas.ts` — `BunnyCdnAdapterOptions` + Zod schema (region whitelist)
|
||||
- [ ] 3.3 `src/storage-client/storage-client.ts` — PUT/GET/DELETE/HEAD/LIST against regional Storage Zone with `AccessKey` header
|
||||
- [ ] 3.4 `src/pull-zone-client/pull-zone-client.ts` — single-URL purge + prefix purge via `api.bunny.net/purge`
|
||||
- [ ] 3.5 `src/token-auth/token-auth.ts` — HMAC-SHA256 Token Authentication via WebCrypto, URL-safe base64
|
||||
- [ ] 3.6 `src/key-sanitizer/key-sanitizer.ts` — strict boundary + per-segment encoder
|
||||
- [ ] 3.7 `src/retry-helper/retry-helper.ts` — transient classifier + Retry-After honour
|
||||
- [ ] 3.8 `src/otel/otel.ts` — meter/tracer with `provider="bunnycdn"` + `storage_region` attrs
|
||||
- [ ] 3.9 `src/bunny-cdn-adapter/bunny-cdn-adapter.ts` — `BunnyCdnAdapter extends CdnAdapter` + `purgePrefix` extra method
|
||||
- [ ] 3.10 `src/registry/registry.ts` — `registerBunnyCdnProvider()` idempotent
|
||||
- [ ] 3.11 `src/index.ts` — explicit named re-exports
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [ ] 4.1 `src/storage-client/storage-client.test.ts` — `de` unprefixed host, `ny` prefixed host, PUT/GET/DELETE/HEAD/LIST round-trips, 401 → STORAGE_AUTH_FAILED, 404 → undefined
|
||||
- [ ] 4.2 `src/pull-zone-client/pull-zone-client.test.ts` — single purge 200, prefix purge wildcard, 401 → PURGE_AUTH_FAILED, missing API key → PURGE_API_KEY_MISSING
|
||||
- [ ] 4.3 `src/token-auth/token-auth.test.ts` — reference-vector byte equality, path-scoped token, missing key error
|
||||
- [ ] 4.4 `src/key-sanitizer/key-sanitizer.test.ts` — traversal, absolute, overlong, per-segment encoding
|
||||
- [ ] 4.5 `src/retry-helper/retry-helper.test.ts` — transient classifier, `Retry-After` honour, non-retry on 401
|
||||
- [ ] 4.6 `src/otel/otel.test.ts` — measurement attrs include `provider` and `storage_region`
|
||||
- [ ] 4.7 `src/bunny-cdn-adapter/bunny-cdn-adapter.test.ts` — full CRUD, range, edgeUrl, purge, prefix purge, signUrl, AbortSignal, retry, list mapping
|
||||
- [ ] 4.8 `src/bunny-cdn-adapter/bunny-cdn-adapter.spec-conformance.test.ts` — type conformance, capabilities, every code reachable, tier-purity grep
|
||||
- [ ] 4.9 `src/registry/registry.test.ts` — factory dispatch, idempotency
|
||||
- [ ] 4.10 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
- [ ] 4.11 All test imports via `@suaveplan/testing/runner` (§7)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [ ] 5.1 Sibling `.md` for every implementation `.ts`
|
||||
- [ ] 5.2 `README.md` documents region selection (`de|ny|la|sg|syd|uk`), two-key model (storage vs purge), Token Authentication setup
|
||||
|
||||
## 6. Gates
|
||||
|
||||
- [ ] 6.1 `bun run check`
|
||||
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-cdn-bunnycdn`
|
||||
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/storage-cdn-bunnycdn` (100/100/100/100)
|
||||
- [ ] 6.4 `bun scripts/lint/check-test-runner-imports.ts`
|
||||
- [ ] 6.5 Tier-purity grep returns empty
|
||||
- [ ] 6.6 `bun scripts/openspec-conformance.ts @suaveplan/storage-cdn-bunnycdn`
|
||||
|
||||
## 7. Manifest & archival
|
||||
|
||||
- [ ] 7.1 `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 7.2 Update `STATUS.md`
|
||||
- [ ] 7.3 Fold `specs/storage-cdn-bunnycdn/spec.md` → `openspec/specs/storage-cdn-bunnycdn/spec.md`
|
||||
- [ ] 7.4 Move change folder → `openspec/archive/<yyyy-mm-dd>-add-storage-cdn-bunnycdn/`
|
||||
Reference in New Issue
Block a user