133 lines
7.5 KiB
Markdown
133 lines
7.5 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|