20 KiB
Capability Spec Delta: storage-http (storage-http)
Package: @suaveplan/storage-http
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
Requirements
Requirement: HttpStorage extends AbstractStorage
The package SHALL provide an HttpStorage class extending AbstractStorage from @suaveplan/storage. The class SHALL implement every abstract method on the base (get, set, delete, exists, list, clear, getWithMetadata, getMetadata, getSize, getPartial, setPartial, getItemSize, createReadStream, createWriteStream) with the signatures declared on the base, including the trailing signal?: AbortSignal parameter. Methods unsupported in read-only mode SHALL throw StorageHttpError with code STORAGE_HTTP.READ_ONLY.
Scenario: instance is structurally an AbstractStorage
- WHEN a caller constructs
new HttpStorage({ baseUrl: "https://cdn.example.com/start/here/" }) - THEN the instance is
instanceof AbstractStorageand every abstract method is callable
Scenario: capabilities matrix declares partial reads and native streams
- WHEN a caller reads
storage.capabilities - THEN
partialReads === true,nativeStreams === true,atomicWrites === false,partialWrites === false,contentAddressable === false
Verified by: src/http-storage/http-storage.test.ts::"implements every AbstractStorage abstract method" AND src/http-storage/http-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"
Requirement: Options validated at the boundary via Zod
The package SHALL expose HttpStorageOptions (interface) and HttpStorageOptionsSchema (Zod). The constructor SHALL parse options through the schema and throw StorageHttpError with code STORAGE_HTTP.INVALID_OPTIONS on validation failure. baseUrl SHALL be required, MUST be an absolute http:// or https:// URL, AND MUST end with /. headers MAY be a static record or a function (key, op) => Record<string, string> | Promise<…> called per request. fetch, uploader, keyEncoder, timeout, retry, and etagCache SHALL be optional.
Scenario: baseUrl without trailing slash is rejected
- WHEN a caller constructs
new HttpStorage({ baseUrl: "https://cdn.example.com/start/here" }) - THEN the constructor throws
StorageHttpErrorwithcode === "STORAGE_HTTP.INVALID_OPTIONS"
Scenario: non-http(s) baseUrl is rejected
- WHEN a caller constructs
new HttpStorage({ baseUrl: "ftp://example.com/" }) - THEN the constructor throws
StorageHttpErrorwithcode === "STORAGE_HTTP.INVALID_OPTIONS"
Verified by: src/http-storage/http-storage.test.ts::"rejects baseUrl missing trailing slash with INVALID_OPTIONS" AND src/http-storage/http-storage.test.ts::"rejects non-http(s) baseUrl with INVALID_OPTIONS"
Requirement: GET retrieves bytes from baseUrl + key
get(key, signal?) SHALL issue an HTTP GET to new URL(encodedKey, baseUrl) and return the response body as Uint8Array. The injected headers callback (if provided) SHALL be invoked with (key, "get") and its result merged into the request init. On 200 OK, the body SHALL be returned. On 404 Not Found, the method SHALL return undefined (not throw). On other non-success status, the method SHALL throw StorageHttpError per the status → code map in design.md §3b.
Scenario: round-trip GET returns the body bytes
- WHEN the injected
FetchLikeresolves tonew Response(new Uint8Array([1,2,3]), { status: 200 })forhttps://cdn.example.com/start/here/a/b/test.doc - AND the caller invokes
await storage.get("a/b/test.doc") - THEN the result is a
Uint8Arraybyte-equal to[1,2,3]
Scenario: 404 Not Found yields undefined, not a throw
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 404 }) - AND the caller invokes
await storage.get("missing") - THEN the result is
undefinedand no error is thrown
Verified by: src/http-storage/http-storage.test.ts::"GET round-trips body bytes from baseUrl + key" AND src/http-storage/http-storage.test.ts::"GET returns undefined on 404 without throwing"
Requirement: Strict key sanitisation at the boundary
Every method that accepts a key SHALL pass it through sanitizeKey before issuing any fetch call. The sanitiser SHALL reject empty strings, keys containing .. segments, keys starting with / or matching a drive-letter prefix (C:\…), keys containing null bytes (), keys containing control chars (0x00–0x1F, 0x7F), and keys longer than 2048 characters. Windows \ separators SHALL be normalised to POSIX /. The default keyEncoder SHALL apply encodeURIComponent per /-separated segment, preserving /. Rejections SHALL throw StorageHttpError with code STORAGE_HTTP.INVALID_KEY or STORAGE_HTTP.KEY_TOO_LONG.
Scenario: path-traversal attempt is rejected before fetch
- WHEN a caller calls
storage.get("../../etc/passwd") - THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.INVALID_KEY"AND the injectedFetchLikeis not invoked
Scenario: overlong key is rejected with KEY_TOO_LONG
- WHEN a caller calls
storage.get("a".repeat(2049)) - THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.KEY_TOO_LONG"
Scenario: spaces in key segments are URL-encoded, slashes preserved
- WHEN a caller calls
storage.get("hello world/sub dir/file.txt") - THEN the fetched URL is
https://cdn.example.com/start/here/hello%20world/sub%20dir/file.txt
Verified by: src/key-sanitizer/key-sanitizer.test.ts::"rejects path traversal segments" AND src/key-sanitizer/key-sanitizer.test.ts::"rejects keys longer than 2048 chars with KEY_TOO_LONG" AND src/key-sanitizer/key-sanitizer.test.ts::"encodes spaces per segment, preserves slashes"
Requirement: HEAD-backed exists and getMetadata
exists(key, signal?) SHALL issue HEAD <URL> and return true on 2xx, false on 404/410, and throw on other non-success status. getMetadata(key, signal?) SHALL issue HEAD <URL> (or read from the ETag cache if populated) and return { size: number, contentType?: string, lastModified?: Date, etag?: string }. size SHALL come from Content-Length; absence yields size: -1 (sentinel for "unknown").
Scenario: HEAD 200 yields exists=true and parsed metadata
- WHEN the injected
FetchLikeresolves aHEADtonew Response(null, { status: 200, headers: { "content-length": "42", "content-type": "text/plain", "etag": "\"abc\"" } }) - THEN
await storage.exists("k")istrueANDawait storage.getMetadata("k")is{ size: 42, contentType: "text/plain", etag: '"abc"' }
Scenario: HEAD 404 yields exists=false without throwing
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 404 }) - THEN
await storage.exists("k")isfalse
Verified by: src/http-storage/http-storage.test.ts::"HEAD 200 yields exists=true and parsed metadata" AND src/http-storage/http-storage.test.ts::"HEAD 404 yields exists=false without throwing"
Requirement: Range reads via getPartial
getPartial(key, offset, length, signal?) SHALL issue GET <URL> with header Range: bytes=<offset>-<offset+length-1>. On 206 Partial Content, the body SHALL be returned verbatim. On 200 OK (origin ignored the Range), the full body SHALL be consumed, sliced to [offset, offset+length), returned, AND the counter storage_http.range_fallback_total{outcome="ok"} SHALL be incremented. On 416 Range Not Satisfiable, the method SHALL throw StorageHttpError with code STORAGE_HTTP.NOT_FOUND when the requested range exceeds known size. capabilities.partialReads SHALL be true.
Scenario: 206 returns the partial bytes verbatim
- WHEN the injected
FetchLikeresolves tonew Response(new Uint8Array([2,3]), { status: 206, headers: { "content-range": "bytes 1-2/10" } }) - AND the caller invokes
await storage.getPartial("k", 1, 2) - THEN the result is byte-equal to
[2,3]
Scenario: 200 fallback slices client-side and increments counter
- WHEN the injected
FetchLikeresolves tonew Response(new Uint8Array([0,1,2,3,4]), { status: 200 })for a Range request - AND the caller invokes
await storage.getPartial("k", 1, 2) - THEN the result is byte-equal to
[1,2]ANDstorage_http.range_fallback_totalhas been incremented exactly once
Verified by: src/range/range.test.ts::"206 Partial Content returns body verbatim" AND src/range/range.test.ts::"200 fallback slices client-side and increments range_fallback_total"
Requirement: Native streams via createReadStream
createReadStream(key, signal?) SHALL return a ReadableStream<Uint8Array>. The stream SHALL be the Response.body of a GET <URL> when present (no buffering). If the response status is non-success, the returned stream SHALL error with StorageHttpError. Cancelling the stream SHALL abort the underlying fetch via the injected AbortSignal. capabilities.nativeStreams SHALL be true.
Scenario: response body is returned without buffering
- WHEN the injected
FetchLikeresolves to aResponsewhose body is aReadableStreamof three 1KiB chunks - AND the caller invokes
storage.createReadStream("k")and reads to completion - THEN the reader observes the same three chunks in order without coalescing
Verified by: src/http-storage/http-storage.test.ts::"createReadStream returns Response.body without buffering"
Requirement: Conditional reads via ETag cache
When options.etagCache is enabled, get SHALL attach If-None-Match: <etag> whenever a cached ETag exists for the key. On 304 Not Modified, the cached body SHALL be returned without retransmission. On 200 OK, the cache entry SHALL be replaced (subject to LRU eviction at the configured capacity). The cache SHALL be in-memory and per-instance.
Scenario: 304 returns cached body without retransmitting
- WHEN a first
get("k")populates the cache withetag: '"v1"'and bodyB1 - AND a second
get("k")is issued with the injectedFetchLikeresponding304 Not Modifiedto a request carryingIf-None-Match: "v1" - THEN the second call returns a body byte-equal to
B1AND no body bytes are consumed from the fetch
Verified by: src/etag-cache/etag-cache.test.ts::"304 Not Modified returns cached body without retransmission"
Requirement: Retries on transient status and Retry-After honour
The adapter SHALL classify HTTP status 408, 425, 429, and 5xx as transient. Network rejection from FetchLike SHALL be classified as transient. On transient outcome, the adapter SHALL retry via @suaveplan/retry.retry using injectable TimersLike for backoff. When the response carries Retry-After (delta-seconds or HTTP-date), the next attempt SHALL wait at least that duration. Non-transient 4xx SHALL NOT retry. Exhausted retries SHALL surface as STORAGE_HTTP.UPSTREAM_ERROR (HTTP exhaustion) or STORAGE_HTTP.NETWORK_ERROR (fetch-reject exhaustion).
Scenario: 503 with Retry-After is retried after the specified delay
- WHEN the injected
FetchLikeresolves to503withRetry-After: 2on the first call, then200on the second - AND the injected
TimersLikeis aFakeClock - THEN the caller observes one
setTimeoutadvance of at least 2000 ms AND the final result is the200body
Scenario: 401 Unauthorized is NOT retried
- WHEN the injected
FetchLikeresolves to401 Unauthorized - THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.UNAUTHORIZED"ANDFetchLikewas invoked exactly once
Verified by: src/retry-helper/retry-helper.test.ts::"retries 503 honouring Retry-After" AND src/retry-helper/retry-helper.test.ts::"does not retry 401 Unauthorized"
Requirement: AbortSignal threaded into every fetch
Every public async method SHALL accept an optional signal: AbortSignal and pass it into the underlying FetchLike init.signal. When the caller passes an already-aborted signal, the method SHALL throw StorageHttpError with code STORAGE_HTTP.ABORTED before any fetch is issued. When the signal aborts mid-request, the in-flight fetch SHALL be aborted and the method SHALL reject with STORAGE_HTTP.ABORTED.
Scenario: pre-aborted signal short-circuits before fetch
- WHEN a caller passes an already-aborted signal to any read method
- THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.ABORTED"AND the injectedFetchLikeis not invoked
Scenario: mid-flight abort propagates into fetch
- WHEN a
getis in flight and the caller callscontroller.abort() - THEN the injected
FetchLikeobserves the aborted signal AND the call rejects withSTORAGE_HTTP.ABORTED
Verified by: src/http-storage/http-storage.test.ts::"rejects with STORAGE_HTTP.ABORTED when signal is pre-aborted" AND src/http-storage/http-storage.test.ts::"propagates mid-flight abort into the underlying fetch"
Requirement: Writes via injected HttpUploader
When options.uploader is provided, set(key, bytes, signal?) SHALL invoke uploader.put(key, bytes, headersForKey, signal) exactly once with the sanitised key and the per-request headers (from the headers callback). When uploader.delete is defined, delete(key, signal?) SHALL invoke it. When no uploader is provided, set and delete SHALL throw StorageHttpError with code STORAGE_HTTP.READ_ONLY. createWriteStream SHALL collect to memory and dispatch uploader.put on close; without an uploader it SHALL throw STORAGE_HTTP.READ_ONLY immediately.
Scenario: set with injected uploader invokes put exactly once
- WHEN an
HttpUploaderis injected and the caller invokesawait storage.set("a/b.bin", new Uint8Array([1,2,3])) - THEN
uploader.putwas invoked exactly once with("a/b.bin", <bytes>, <headers>, <signal>)
Scenario: set without uploader throws READ_ONLY
- WHEN the storage was constructed without
uploaderAND a caller invokesstorage.set("k", bytes) - THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.READ_ONLY"
Verified by: src/http-storage/http-storage.test.ts::"set invokes injected uploader.put exactly once" AND src/http-storage/http-storage.test.ts::"set without uploader throws READ_ONLY"
Requirement: list and setPartial are read-only by default
list(prefix?, options?) SHALL always throw StorageHttpError with code STORAGE_HTTP.READ_ONLY — HTTP origins do not have a uniform directory protocol (see proposal "Out of scope"). setPartial SHALL throw STORAGE_HTTP.READ_ONLY when no uploader is wired; when an uploader is wired, setPartial SHALL fall through to AbstractStorage.defaultSetPartial (read-modify-write) and re-upload the full object. clear() SHALL throw STORAGE_HTTP.READ_ONLY in all configurations.
Scenario: list always throws READ_ONLY
- WHEN any caller invokes
storage.list()orstorage.list("a/") - THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.READ_ONLY"
Scenario: clear always throws READ_ONLY
- WHEN any caller invokes
storage.clear()even with an uploader wired - THEN the call rejects with
StorageHttpErrorwhosecode === "STORAGE_HTTP.READ_ONLY"
Verified by: src/http-storage/http-storage.test.ts::"list always throws READ_ONLY" AND src/http-storage/http-storage.test.ts::"clear always throws READ_ONLY"
Requirement: STORAGE_HTTP error registry
The package SHALL declare STORAGE_HTTP_CODES via defineCodes("STORAGE_HTTP", [...]) with exactly these 10 codes: NOT_FOUND, UNAUTHORIZED, FORBIDDEN, NETWORK_ERROR, INVALID_KEY, KEY_TOO_LONG, INVALID_OPTIONS, READ_ONLY, ABORTED, UPSTREAM_ERROR. The package SHALL declare StorageHttpError extends SuaveplanError<StorageHttpErrorCode> with optional key, httpStatus, and url discriminants. Every code SHALL be reachable from a real call site exercised by tests.
Scenario: every declared code is reachable from a test
- WHEN the test suite is run
- THEN every code in
STORAGE_HTTP_CODEShas at least one test that exercises a code path producing that code
Verified by: src/http-storage/http-storage.spec-conformance.test.ts::"every STORAGE_HTTP code is reachable from a real call site"
Requirement: Module-augmentation registry slot
The package SHALL declare declare module "@suaveplan/storage" augmenting StorageProviderRegistry with http: HttpStorage and StorageProviderConfig with http: HttpStorageOptions. The package SHALL export registerHttpProvider(): void that registers the constructor with StorageFactory. Registration SHALL be idempotent — calling it twice SHALL not throw and SHALL not double-register.
Scenario: createStorage with type "http" yields HttpStorage
- WHEN
registerHttpProvider()has been called - AND the caller invokes
createStorage({ type: "http", config: { baseUrl: "https://cdn.example.com/start/here/" } }) - THEN the returned instance is
instanceof HttpStorage
Scenario: registration is idempotent
- WHEN
registerHttpProvider()is invoked twice - THEN neither call throws AND
createStorage({ type: "http", config })continues to yield a single registered constructor
Verified by: src/registry/registry.test.ts::"createStorage with type http returns HttpStorage" AND src/registry/registry.test.ts::"registerHttpProvider is idempotent"
Requirement: OpenTelemetry instrumentation
The package SHALL emit histograms storage_http.op_duration_ms{op,outcome,status_class} and storage_http.io_bytes{op,direction}, and counters storage_http.reads_total, storage_http.writes_total, storage_http.deletes_total, storage_http.errors_total, storage_http.range_fallback_total — each with outcome attribute. Every public operation SHALL open a span on the @suaveplan/storage-http tracer with attributes http.url, http.method, http.status_code (when known), http.response_content_length (when known), and storage.key (sanitised, never raw caller input). Spans SHALL record exceptions on failure and end in the error status.
Scenario: successful GET emits one read counter and one duration histogram observation
- WHEN a
getsucceeds against an in-memoryMeterProvider - THEN
storage_http.reads_total{outcome="ok"}increments by 1 ANDstorage_http.op_duration_ms{op="get",outcome="ok",status_class="2xx"}records exactly one observation
Scenario: failed GET records an error span and increments errors_total
- WHEN a
getresolves to500against an in-memorySpanExporterandMeterProvider - THEN the exported span has status
errorANDstorage_http.errors_total{outcome="upstream_error"}increments by 1
Verified by: src/otel/otel.test.ts::"successful GET emits read counter and duration histogram" AND src/otel/otel.test.ts::"failed GET records error span and increments errors_total"
Requirement: Tier purity — universal, fetch-only
The package SHALL NOT import from node:*, bun:*, or any DOM-only API (e.g. window, document). Every runtime dependency SHALL be available in the universal subset: fetch, URL, URLSearchParams, AbortController, Uint8Array, ReadableStream. 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-http/src/is run - THEN the output is empty
Verified by: src/http-storage/http-storage.spec-conformance.test.ts::"src tree contains no node: or bun: imports"