# Add @suaveplan/storage-cdn — CDN adapter interface over AbstractStorage **Package**: `@suaveplan/storage-cdn` **Status**: PROPOSED **Type**: new-package (genesis-original) - **Design**: ./design.md - **Spec delta**: ./specs/storage-cdn/spec.md ## Why `@suaveplan/storage-http` (separate proposal) covers the generic "URL prefix + key" read surface. Real CDN providers (Rackspace Cloud Files + Rackspace CDN, BunnyCDN Storage + Pull Zones, Cloudflare R2 + custom domain, Fastly + origin, etc.) add **three capabilities the generic adapter cannot express**: 1. **Cache purge / invalidation** — purge a key or prefix from edge POPs after origin updates. 2. **Signed / temporary URLs** — produce a short-lived URL the caller can hand to a browser without exposing credentials. 3. **Origin vs edge split** — writes go to a private origin endpoint; reads go through a public edge URL. The adapter needs both. `storage-cdn` is the **types-only foundation** that defines a common contract (`CdnAdapter extends AbstractStorage` + `CdnCapabilities` + `purge()` / `signUrl()` / `edgeUrl()`) so consumers can write provider-agnostic code and provider packages (`storage-cdn-rackspace`, `storage-cdn-bunnycdn`, future entrants) implement against the same surface. The package mirrors the role `@suaveplan/db` plays for database adapters, or `@suaveplan/network` plays for transports: a small core that establishes the contract, with concrete adapters in sibling packages. ## What changes - ADDED: `CdnAdapter` abstract class extending `AbstractStorage` from `@suaveplan/storage`. Adds three abstract methods: `purge(key: string, signal?: AbortSignal): Promise`, `signUrl(key: string, options: SignUrlOptions, signal?: AbortSignal): Promise`, `edgeUrl(key: string): string`. - ADDED: `CdnCapabilities` interface extending `StorageCapabilities` with `cdnPurge: boolean`, `cdnSignedUrls: boolean`, `cdnPrefixPurge: boolean`, `cdnOriginUploads: boolean`. Defaults all `false`. - ADDED: `SignUrlOptions` interface — `{ ttlSeconds: number; method?: "GET" | "PUT" | "HEAD"; responseContentType?: string; responseContentDisposition?: string }`. Zod schema for boundary validation. - ADDED: `CdnAdapterOptions` base interface (provider packages extend it) — `originBaseUrl: string` (write endpoint, must end with `/`), `edgeBaseUrl: string` (public read endpoint, must end with `/`), `credentials` (provider-specific subtype slot), `fetch?: FetchLike`. - ADDED: `STORAGE_CDN_CODES` registry via `defineCodes("STORAGE_CDN", [...])` — 8 codes: `PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`. - ADDED: `StorageCdnError extends SuaveplanError` with `key`, `provider`, and `purgeId` discriminants. - ADDED: `defaultPurgeNotSupported()` and `defaultSignUrlNotSupported()` reference implementations — concrete `CdnAdapter` subclasses that lack a capability MUST throw via these helpers so the error surface is uniform. - ADDED: `assertCdnAdapter(value)` type-guard for runtime narrowing — useful when accepting a `StorageLike` and needing to opt into CDN features. - ADDED: Module-augmentation slot — `declare module "@suaveplan/storage"` augments `StorageCapabilities` with `cdn?: CdnCapabilities` (optional nested capability object). Provider packages set `capabilities.cdn = { … }`. ## Out of scope - **Concrete provider implementations.** `storage-cdn-rackspace` and `storage-cdn-bunnycdn` are separate change proposals. This package is **types-only foundation** plus the error registry and helper functions — no provider-specific code. - **Listing.** Inherited from `AbstractStorage`; provider packages decide whether their origin API supports it. The CDN abstraction itself adds no listing contract. - **Cache-control header policy.** `Cache-Control` / `Expires` belong to the consumer's content strategy and are emitted by the origin server config, not the adapter. The adapter MAY pass-through headers via `set` options but does not own the policy. - **Origin shielding / multi-origin failover.** A failover/shielding layer is a separate concern composed via `storage-mux` (future) or application logic. - **Edge-side rendering integration** (image transforms, video JIT, etc.). Out of scope; provider-specific and orthogonal to storage semantics. ## Source mapping Genesis-original. No upstream in `../suaveplan/`. The package contract derives from: - `openspec/specs/storage/spec.md` — `AbstractStorage` base. - Industry conventions: AWS S3 `PutObject` + CloudFront `CreateInvalidation`; Rackspace Cloud Files + CDN purge; BunnyCDN Storage + Pull Zone purge; Cloudflare R2 + cache purge API. The three abstract methods (`purge`, `signUrl`, `edgeUrl`) are the intersection of every major CDN. ### Requirements with no source line | Requirement | Justification | |---|---| | Extend `AbstractStorage` | Every storage adapter in genesis extends `AbstractStorage` for `StorageProviderRegistry` compatibility. | | `originBaseUrl` and `edgeBaseUrl` distinct fields | The two URLs are genuinely different on every real CDN (Rackspace `storage101..clouddrive.com` vs `.cdn.clouddrive.com`; BunnyCDN `storage.bunnycdn.com/` vs `.b-cdn.net`). Conflating them produces broken signed URLs and wasted edge cache. | | `signUrl` returns `Promise` not `string` | Some providers compute signed URLs via an API call (Rackspace temp URLs are HMAC-local, but R2 presigned URLs require an STS call). Returning a Promise keeps both shapes uniform. | | `purge` is a single-key method | Multi-key / prefix purge is a separate capability flag (`cdnPrefixPurge`). Many providers rate-limit prefix purge heavily; forcing single-key as the minimum contract keeps the cheap path cheap. | | Error code `EDGE_ORIGIN_MISMATCH` | A common foot-gun is configuring `originBaseUrl` and `edgeBaseUrl` against different containers/zones. The adapter validates at construction time and surfaces a clear code rather than mysterious 404s at runtime. | ## Verification approach - `src/cdn-adapter/cdn-adapter.test.ts` — abstract class shape, capability defaults, default-not-supported helpers throw correctly. - `src/cdn-adapter/cdn-adapter.spec-conformance.test.ts` — type-level conformance with `AbstractStorage`, `assertCdnAdapter` discriminates correctly. - `src/sign-url/sign-url.test.ts` — `SignUrlOptionsSchema` validates `ttlSeconds > 0`, method whitelist, response-* fields optional. - `src/error-codes/error-codes.test.ts` — every `STORAGE_CDN_*` code reachable from a real call site. ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/storage-cdn` to enumerate. The package is new — no dependents currently. The two follow-up changes (`add-storage-cdn-rackspace`, `add-storage-cdn-bunnycdn`) declare a hard dependency on this package. - No dependents yet (verified by `bunx turbo ls --filter=...@suaveplan/storage-cdn` returning the package itself). - Forward-declared consumers: `@suaveplan/storage-cdn-rackspace`, `@suaveplan/storage-cdn-bunnycdn`. ### Affected upstreams - `@suaveplan/storage` — `StorageCapabilities` module-augmented with optional `cdn?: CdnCapabilities`. Additive; no breaking change. [x] no action needed.