# Capability Spec Delta: storage-cdn (storage-cdn) **Package**: `@suaveplan/storage-cdn` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## Requirements ### Requirement: CdnAdapter abstract class extends AbstractStorage The package SHALL provide a `CdnAdapter` abstract class extending `AbstractStorage` from `@suaveplan/storage`. The class SHALL declare three abstract methods: `purge(key: string, signal?: AbortSignal): Promise`, `signUrl(key: string, options: SignUrlOptions, signal?: AbortSignal): Promise`, and `edgeUrl(key: string): string`. Every concrete subclass MUST implement all three. The class SHALL declare a readonly `capabilities` property of type `StorageCapabilities & { cdn: CdnCapabilities }`. #### Scenario: subclass is instance of both AbstractStorage and CdnAdapter - **WHEN** a concrete subclass of `CdnAdapter` is constructed - **THEN** the instance is `instanceof AbstractStorage` AND `instanceof CdnAdapter` #### Scenario: missing abstract method is a type error at compile time - **WHEN** a subclass omits `purge`, `signUrl`, or `edgeUrl` - **THEN** `bunx turbo typecheck` fails with TS2515 ("Non-abstract class does not implement inherited abstract member") **Verified by:** `src/cdn-adapter/cdn-adapter.spec-conformance.test.ts::"subclass is instance of AbstractStorage and CdnAdapter"` AND `src/cdn-adapter/cdn-adapter.spec-conformance.test.ts::"missing abstract method fails typecheck"` --- ### Requirement: CdnCapabilities interface with four boolean flags The package SHALL declare `CdnCapabilities` with exactly these four boolean fields: `cdnPurge`, `cdnPrefixPurge`, `cdnSignedUrls`, `cdnOriginUploads`. The package SHALL export `DEFAULT_CDN_CAPABILITIES` with every flag `false`. Concrete subclasses MUST override per native support. #### Scenario: defaults are all false - **WHEN** a caller reads `DEFAULT_CDN_CAPABILITIES` - **THEN** every field is `false` **Verified by:** `src/cdn-adapter/cdn-adapter.test.ts::"DEFAULT_CDN_CAPABILITIES has every flag false"` --- ### Requirement: SignUrlOptions validated via Zod schema The package SHALL declare `SignUrlOptions` interface with required `ttlSeconds: number` AND optional `method?: "GET" | "PUT" | "HEAD"`, `responseContentType?: string`, `responseContentDisposition?: string`. The package SHALL export `SignUrlOptionsSchema` (Zod) enforcing `ttlSeconds` integer > 0 and `method` whitelist. Subclasses MUST parse options through the schema before signing and throw `StorageCdnError` with code `STORAGE_CDN.INVALID_TTL` on `ttlSeconds <= 0` and `STORAGE_CDN.INVALID_OPTIONS` on other schema failures. #### Scenario: zero or negative ttl is rejected with INVALID_TTL - **WHEN** a caller calls `SignUrlOptionsSchema.parse({ ttlSeconds: 0 })` or `({ ttlSeconds: -5 })` - **THEN** the parse throws AND a wrapping helper maps the error to `StorageCdnError` with `code === "STORAGE_CDN.INVALID_TTL"` #### Scenario: method outside whitelist is rejected - **WHEN** a caller calls `SignUrlOptionsSchema.parse({ ttlSeconds: 60, method: "DELETE" })` - **THEN** the parse throws **Verified by:** `src/sign-url/sign-url.test.ts::"rejects zero or negative ttlSeconds with INVALID_TTL"` AND `src/sign-url/sign-url.test.ts::"rejects method outside GET/PUT/HEAD whitelist"` --- ### Requirement: Default-not-supported helpers The package SHALL export `defaultPurgeNotSupported(): never` and `defaultSignUrlNotSupported(): never` helpers. Each SHALL throw `StorageCdnError` with codes `STORAGE_CDN.PURGE_NOT_SUPPORTED` and `STORAGE_CDN.SIGN_URL_NOT_SUPPORTED` respectively. Concrete `CdnAdapter` subclasses that lack a capability MUST invoke these helpers from their method bodies so the error surface is uniform across providers. #### Scenario: defaultPurgeNotSupported throws the documented error - **WHEN** a caller invokes `defaultPurgeNotSupported()` - **THEN** the call throws `StorageCdnError` with `code === "STORAGE_CDN.PURGE_NOT_SUPPORTED"` #### Scenario: defaultSignUrlNotSupported throws the documented error - **WHEN** a caller invokes `defaultSignUrlNotSupported()` - **THEN** the call throws `StorageCdnError` with `code === "STORAGE_CDN.SIGN_URL_NOT_SUPPORTED"` **Verified by:** `src/cdn-adapter/cdn-adapter.test.ts::"defaultPurgeNotSupported throws PURGE_NOT_SUPPORTED"` AND `src/cdn-adapter/cdn-adapter.test.ts::"defaultSignUrlNotSupported throws SIGN_URL_NOT_SUPPORTED"` --- ### Requirement: assertCdnAdapter type guard The package SHALL export `isCdnAdapter(value: unknown): value is CdnAdapter` and `assertCdnAdapter(value: unknown): asserts value is CdnAdapter`. The guards SHALL narrow on `instanceof CdnAdapter`. The assert form SHALL throw `StorageCdnError` with code `STORAGE_CDN.INVALID_OPTIONS` when the value is not a `CdnAdapter`. #### Scenario: isCdnAdapter narrows when given a subclass instance - **WHEN** a caller invokes `isCdnAdapter(concreteCdnInstance)` - **THEN** the result is `true` AND TypeScript narrows the variable to `CdnAdapter` #### Scenario: assertCdnAdapter throws on non-CdnAdapter - **WHEN** a caller invokes `assertCdnAdapter(new MemoryStorage())` - **THEN** the call throws `StorageCdnError` **Verified by:** `src/guards/guards.test.ts::"isCdnAdapter narrows on instanceof"` AND `src/guards/guards.test.ts::"assertCdnAdapter throws on non-CdnAdapter"` --- ### Requirement: STORAGE_CDN error registry The package SHALL declare `STORAGE_CDN_CODES` via `defineCodes("STORAGE_CDN", [...])` with exactly these 8 codes: `PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`. The package SHALL declare `StorageCdnError extends SuaveplanError` with optional `key`, `provider`, and `purgeId` discriminants. Every code SHALL be reachable from a real call site exercised by tests in this package (or in a downstream provider test, with the reachability annotated). #### Scenario: every declared code is reachable - **WHEN** the test suite is run - **THEN** every code in `STORAGE_CDN_CODES` has at least one test in this package or an annotated forward reference to a provider package test **Verified by:** `src/error-codes/error-codes.test.ts::"every STORAGE_CDN code is reachable from a real call site"` --- ### Requirement: Module augmentation adds cdn capability slot The package SHALL declare `declare module "@suaveplan/storage"` augmenting `StorageCapabilities` with `cdn?: CdnCapabilities`. The augmentation SHALL be additive — base storage adapters that do not augment SHALL continue to typecheck unchanged. #### Scenario: capabilities.cdn is optional on the base type - **WHEN** a caller reads `someAbstractStorage.capabilities.cdn` - **THEN** the type is `CdnCapabilities | undefined` and reading `undefined` does not throw at runtime **Verified by:** `src/cdn-adapter/cdn-adapter.spec-conformance.test.ts::"capabilities.cdn slot is optional on base AbstractStorage"` --- ### Requirement: Tier purity — universal types-only The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. The package SHALL be types-and-helpers-only — no I/O, no network, no filesystem. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers. #### Scenario: tier-purity grep finds no Node or DOM imports - **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-cdn/src/` is run - **THEN** the output is empty **Verified by:** `src/cdn-adapter/cdn-adapter.spec-conformance.test.ts::"src tree contains no node: or bun: imports"`