Files
suaveplan/openspec/specs/storage-cdn/spec.md
2026-05-27 23:29:53 +01:00

7.5 KiB

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<void>, signUrl(key: string, options: SignUrlOptions, signal?: AbortSignal): Promise<string>, 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<StorageCdnErrorCode> 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"