Initial Commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-cdn-rackspace (storage-cdn-rackspace)
|
||||
|
||||
**Package**: `@suaveplan/storage-cdn-rackspace`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: RackspaceCdnAdapter extends CdnAdapter
|
||||
|
||||
The package SHALL provide a `RackspaceCdnAdapter` 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 RackspaceCdnAdapter({ … })`
|
||||
- **THEN** the instance is `instanceof CdnAdapter` AND `instanceof AbstractStorage`
|
||||
|
||||
#### Scenario: capabilities declare CDN purge, signed URLs, native streams, partial reads
|
||||
|
||||
- **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: false, cdnSignedUrls: true, cdnOriginUploads: true }`
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.spec-conformance.test.ts::"instance is CdnAdapter and AbstractStorage"` AND `src/rackspace-cdn-adapter/rackspace-cdn-adapter.spec-conformance.test.ts::"declares the documented capabilities matrix"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: v2 identity auth with proactive token refresh
|
||||
|
||||
The adapter SHALL authenticate against Rackspace identity v2 by POSTing `{ auth: { "RAX-KSKEY:apiKeyCredentials": { username, apiKey } } }` to `<authUrl>/tokens`. The response token AND service-catalog SHALL be cached in `tokenCache`. The cache entry SHALL be refreshed proactively at least 5 minutes before the documented `expires` timestamp. On `401` from any subsequent call, the adapter SHALL invalidate the cache, re-auth once, AND retry the original call exactly once.
|
||||
|
||||
#### Scenario: cached token is reused within validity window
|
||||
|
||||
- **WHEN** the auth client has a cached token whose `expires` is more than 5 minutes ahead AND the caller invokes a storage op
|
||||
- **THEN** no auth request is issued AND the cached token is sent
|
||||
|
||||
#### Scenario: 401 triggers single re-auth and retry
|
||||
|
||||
- **WHEN** an op returns `401` AND a fresh auth round-trip succeeds
|
||||
- **THEN** the op is retried exactly once with the new token AND the second outcome is the operation's final result
|
||||
|
||||
#### Scenario: proactive refresh fires before cliff
|
||||
|
||||
- **WHEN** the injected `FakeClock` advances to `expires - 4 minutes`
|
||||
- **THEN** a refresh is in flight before any user op blocks on it
|
||||
|
||||
**Verified by:** `src/auth-client/auth-client.test.ts::"reuses cached token within validity window"` AND `src/auth-client/auth-client.test.ts::"re-auths exactly once on 401 and retries"` AND `src/auth-client/auth-client.test.ts::"refreshes proactively five minutes before expiry"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Service-catalog lookup resolves region endpoints
|
||||
|
||||
The adapter SHALL resolve `storageEndpoint` and `cdnEndpoint` from the auth-response service catalog filtered by `region`. When the configured region is absent from the catalog, the adapter SHALL throw `StorageCdnRackspaceError` with code `STORAGE_CDN_RACKSPACE.REGION_NOT_IN_CATALOG`.
|
||||
|
||||
#### Scenario: LON region resolves to LON endpoints
|
||||
|
||||
- **WHEN** the auth response contains `cloudFiles` and `cloudFilesCDN` entries for LON
|
||||
- **AND** `region: "LON"` is configured
|
||||
- **THEN** `storageEndpoint` and `cdnEndpoint` reflect the LON URLs from the catalog
|
||||
|
||||
#### Scenario: missing region throws REGION_NOT_IN_CATALOG
|
||||
|
||||
- **WHEN** the auth response does NOT contain an entry for the configured region
|
||||
- **THEN** the next op throws `StorageCdnRackspaceError` with `code === "STORAGE_CDN_RACKSPACE.REGION_NOT_IN_CATALOG"`
|
||||
|
||||
**Verified by:** `src/auth-client/auth-client.test.ts::"resolves LON region from service catalog"` AND `src/auth-client/auth-client.test.ts::"throws REGION_NOT_IN_CATALOG when region absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CRUD against Cloud Files origin
|
||||
|
||||
`set(key, bytes)` SHALL `PUT <storageEndpoint>/<container>/<encodedKey>` with the auth token, `Content-Length`, and `Content-Type` (from `options.contentType` or `application/octet-stream` default). `get(key)` SHALL `GET …`. `delete(key)` SHALL `DELETE …`. `exists(key)` SHALL `HEAD …`. `getMetadata` SHALL parse `X-Object-Meta-*`, `Content-Length`, `Content-Type`, `Last-Modified`, `ETag`.
|
||||
|
||||
#### Scenario: round-trip PUT/GET preserves bytes and content-type
|
||||
|
||||
- **WHEN** the injected `FetchLike` resolves `PUT` to `201 Created` and `GET` to `200` with the same body and `Content-Type`
|
||||
- **THEN** `await storage.set("k", bytes, { contentType: "image/png" })` succeeds AND `await storage.get("k")` returns those bytes AND `await storage.getMetadata("k")` reports `contentType: "image/png"`
|
||||
|
||||
#### Scenario: 404 from GET returns undefined
|
||||
|
||||
- **WHEN** `GET` resolves to `404 Not Found`
|
||||
- **THEN** `await storage.get("k")` returns `undefined`
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.test.ts::"PUT then GET round-trips bytes and contentType"` AND `src/rackspace-cdn-adapter/rackspace-cdn-adapter.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 a counter `storage_cdn_rackspace.range_fallback_total` SHALL increment. `capabilities.partialReads` SHALL be `true`.
|
||||
|
||||
#### Scenario: Swift 206 returns partial body verbatim
|
||||
|
||||
- **WHEN** `GET` with `Range: bytes=1-2` resolves to `206` with `[2,3]`
|
||||
- **THEN** `getPartial("k", 1, 2)` returns `[2,3]`
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.test.ts::"Range request returns 206 body verbatim"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: edgeUrl composes from CDN container URL
|
||||
|
||||
`edgeUrl(key)` SHALL return `new URL(encodedKey, cdnContainerBaseUrl).toString()` where `cdnContainerBaseUrl` is the SSL CDN URL fetched from `getCdnMetadata(container)` (cached at construction). The method SHALL be synchronous AND SHALL never throw.
|
||||
|
||||
#### Scenario: edgeUrl returns the SSL CDN URL for the key
|
||||
|
||||
- **WHEN** `getCdnMetadata` returned `X-Cdn-Ssl-Uri: https://abc.r88.cf6.rackcdn.com/`
|
||||
- **THEN** `storage.edgeUrl("a/b/file.png")` is `https://abc.r88.cf6.rackcdn.com/a/b/file.png`
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.test.ts::"edgeUrl returns SSL CDN URL composed with encoded key"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Purge via Rackspace CDN DELETE
|
||||
|
||||
`purge(key, signal?)` SHALL issue `DELETE <cdnEndpoint>/<container>/<encodedKey>` with the auth token. On `2xx` the call SHALL resolve. On `429` the call SHALL throw `StorageCdnRackspaceError` with code `STORAGE_CDN_RACKSPACE.PURGE_RATE_LIMITED` after retry exhaustion. On `404` the call SHALL resolve without error (already purged or never CDN-served).
|
||||
|
||||
#### Scenario: 2xx purge resolves
|
||||
|
||||
- **WHEN** `DELETE` resolves to `204`
|
||||
- **THEN** `await storage.purge("k")` resolves
|
||||
|
||||
#### Scenario: 429 surfaces PURGE_RATE_LIMITED after retry exhaustion
|
||||
|
||||
- **WHEN** every retry attempt resolves to `429`
|
||||
- **THEN** the call rejects with `StorageCdnRackspaceError` whose `code === "STORAGE_CDN_RACKSPACE.PURGE_RATE_LIMITED"`
|
||||
|
||||
**Verified by:** `src/cdn-client/cdn-client.test.ts::"purge resolves on 204"` AND `src/cdn-client/cdn-client.test.ts::"purge surfaces PURGE_RATE_LIMITED on persistent 429"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: signUrl produces Swift TempURL HMAC-SHA1 signature
|
||||
|
||||
`signUrl(key, { ttlSeconds, method })` SHALL produce a URL of the form `<storageEndpoint>/<container>/<encodedKey>?temp_url_sig=<hex>&temp_url_expires=<unix>` where `<hex>` is `HMAC-SHA1(tempUrlKey, "<METHOD>\n<EXPIRES>\n<PATH>")` per the Swift TempURL middleware spec. The method SHALL throw `STORAGE_CDN_RACKSPACE.TEMP_URL_KEY_MISSING` when no `tempUrlKey` was configured. `ttlSeconds <= 0` SHALL throw `STORAGE_CDN.INVALID_TTL`.
|
||||
|
||||
#### Scenario: signed URL matches Swift reference vector
|
||||
|
||||
- **WHEN** `tempUrlKey: "b3968d0207b54ece87cccc06515a89d4"`, `method: "GET"`, `path: "/v1/AUTH_account/container/object"`, `expires: 1323479485`
|
||||
- **THEN** the signature equals the reference vector documented by Swift TempURL middleware (`da39a3ee…` — actual value asserted in the test)
|
||||
|
||||
#### Scenario: missing tempUrlKey throws TEMP_URL_KEY_MISSING
|
||||
|
||||
- **WHEN** the adapter was constructed without `tempUrlKey` AND a caller invokes `signUrl(…)`
|
||||
- **THEN** the call throws `StorageCdnRackspaceError` with `code === "STORAGE_CDN_RACKSPACE.TEMP_URL_KEY_MISSING"`
|
||||
|
||||
**Verified by:** `src/temp-url/temp-url.test.ts::"signature matches Swift TempURL reference vector"` AND `src/temp-url/temp-url.test.ts::"throws TEMP_URL_KEY_MISSING when no key configured"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Strict key sanitisation at the boundary
|
||||
|
||||
Every method that accepts a key SHALL pass it through `sanitizeKey` (shared with `storage-http` semantics — empty, `..`, leading `/`, null bytes, control chars, >2048 → `STORAGE_CDN_RACKSPACE.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 `StorageCdnRackspaceError` AND no fetch is issued
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-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`, and `5xx` as transient. Network rejection SHALL be classified 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_RACKSPACE.UPSTREAM_ERROR` (HTTP) or `STORAGE_CDN_RACKSPACE.PURGE_RATE_LIMITED` (purge-only 429).
|
||||
|
||||
#### 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` AND the final result is the `200` body
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.test.ts::"retries 503 honouring Retry-After"`
|
||||
|
||||
---
|
||||
|
||||
### 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 with `STORAGE_CDN_RACKSPACE.UPSTREAM_ERROR` whose cause is `AbortError`. Mid-flight abort SHALL propagate to the in-flight fetch.
|
||||
|
||||
#### 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/rackspace-cdn-adapter/rackspace-cdn-adapter.test.ts::"pre-aborted signal short-circuits all methods"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: STORAGE_CDN_RACKSPACE error registry
|
||||
|
||||
The package SHALL declare `STORAGE_CDN_RACKSPACE_CODES` via `defineCodes("STORAGE_CDN_RACKSPACE", [...])` with exactly these 9 codes: `AUTH_FAILED`, `TOKEN_EXPIRED`, `CONTAINER_NOT_FOUND`, `CDN_NOT_ENABLED`, `TEMP_URL_KEY_MISSING`, `PURGE_RATE_LIMITED`, `REGION_NOT_IN_CATALOG`, `INVALID_OPTIONS`, `UPSTREAM_ERROR`. `StorageCdnRackspaceError` SHALL include `container`, `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_RACKSPACE_CODES` is produced by at least one test
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.spec-conformance.test.ts::"every STORAGE_CDN_RACKSPACE code is reachable"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Module-augmentation registry slot
|
||||
|
||||
The package SHALL declare `declare module "@suaveplan/storage"` augmenting the registry with `rackspace-cdn: RackspaceCdnAdapter` and the config map with the adapter options. The package SHALL export `registerRackspaceCdnProvider(): void` that registers the constructor idempotently.
|
||||
|
||||
#### Scenario: createStorage with type "rackspace-cdn" yields RackspaceCdnAdapter
|
||||
|
||||
- **WHEN** `registerRackspaceCdnProvider()` has been called
|
||||
- **AND** the caller invokes `createStorage({ type: "rackspace-cdn", config: { … } })`
|
||||
- **THEN** the returned instance is `instanceof RackspaceCdnAdapter`
|
||||
|
||||
**Verified by:** `src/registry/registry.test.ts::"createStorage with type rackspace-cdn returns RackspaceCdnAdapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation with provider attribute
|
||||
|
||||
The package SHALL emit `storage_cdn_rackspace.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 attribute `provider="rackspace"` AND `region` from the configuration. Spans SHALL be opened on the `@suaveplan/storage-cdn-rackspace` tracer with `http.url`, `http.method`, `http.status_code`, `storage.key`, `storage.container` attributes.
|
||||
|
||||
#### Scenario: successful PUT emits writes_total and duration
|
||||
|
||||
- **WHEN** a `set` succeeds against an in-memory `MeterProvider`
|
||||
- **THEN** `storage_cdn_rackspace.writes_total{outcome="ok",provider="rackspace",region="LON"}` increments by 1
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-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 computation 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-rackspace/src/` is run
|
||||
- **THEN** the output is empty
|
||||
|
||||
**Verified by:** `src/rackspace-cdn-adapter/rackspace-cdn-adapter.spec-conformance.test.ts::"src tree contains no node: or bun: imports"`
|
||||
Reference in New Issue
Block a user