# Capability Spec Delta: storage-http (complete-storage-uri-adapters) **Package**: `@suaveplan/storage-http` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: `http://` and `https://` URI plugin factories The package SHALL export `httpUriPlugin(options)` and `httpsUriPlugin(options)` from `./uri-plugin/uri-plugin`. Each factory returns a `UriPlugin` whose `scheme` is the corresponding lowercase literal (`"http"` or `"https"`) and whose `adapter` is the supplied `HttpStorage` instance. The two factories SHALL share their parsing logic; only the `scheme` field SHALL differ. #### Scenario: https factory returns scheme "https" - **WHEN** `httpsUriPlugin({ adapter })` is called - **THEN** the returned plugin's `scheme === "https"` and `runUriPluginConformance` passes #### Scenario: http factory returns scheme "http" - **WHEN** `httpUriPlugin({ adapter })` is called - **THEN** the returned plugin's `scheme === "http"` and the same parser is shared with the https factory (verified by structural equivalence of parse results for matching authority/path) **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"https factory exposes scheme https"` AND `src/uri-plugin/uri-plugin.test.ts::"http factory exposes scheme http"` --- ### Requirement: Query-string preservation on key The plugin SHALL include the URI's query string on the parsed `key` so presigned-URL signatures (e.g. `?X-Amz-Signature=...`) survive the round-trip to `HttpStorage.get`. Fragments (`#...`) SHALL be dropped. #### Scenario: presigned query survives parse - **WHEN** `plugin.parse("https://host/p?X-Amz-Signature=abc")` is called - **THEN** the returned `key` ends with `"?X-Amz-Signature=abc"` #### Scenario: fragment is stripped - **WHEN** `plugin.parse("https://host/p#section")` is called - **THEN** the returned `key` does NOT include `#section` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"preserves presigned query on key"` AND `src/uri-plugin/uri-plugin.test.ts::"strips fragment from key"` --- ### Requirement: Range request support via query channel The plugin SHALL parse a `?range=bytes=-` query channel (or an equivalent `offset`/`length` pair) into an `{ offset, length }` bag and SHALL route the resolver's read call to `adapter.getPartial` when present. Malformed range values SHALL raise `STORAGE_HTTP_URI.RANGE_INVALID`. #### Scenario: well-formed range routes to getPartial - **WHEN** the resolver routes `read("https://host/p?range=bytes=0-1023")` and the plugin's adapter exposes `getPartial` - **THEN** `adapter.getPartial` is invoked with `{ offset: 0, length: 1024 }` and the bytes returned propagate unchanged #### Scenario: malformed range raises RANGE_INVALID - **WHEN** `plugin.parse("https://host/p?range=bytes=abc-")` is called - **THEN** the call throws `StorageHttpUriError` with `code === "STORAGE_HTTP_URI.RANGE_INVALID"` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"routes well-formed range to getPartial"` AND `src/uri-plugin/uri-plugin.test.ts::"raises RANGE_INVALID on malformed range"` --- ### Requirement: Header injection via FetchLike The plugin SHALL surface header injection through the bound `HttpStorage.options.headers` resolver. The plugin SHALL NOT introduce its own per-request header surface; consumers wanting per-URI headers configure `HttpStorage` with a `HttpHeadersResolver` function that inspects the key. #### Scenario: configured header resolver receives the URI key - **WHEN** `read("https://host/p")` is routed and `HttpStorage` was configured with `headers: (key, op) => ({ "x-test": key })` - **THEN** the outgoing fetch sees the header `"x-test": ""` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"forwards URI key to header resolver"` --- ### Requirement: Redirect policy passthrough The plugin SHALL delegate redirect handling to the bound `FetchLike`. Redirect loops raised by the adapter SHALL be re-wrapped as `StorageHttpUriError(STORAGE_HTTP_URI.REDIRECT_LOOP)` preserving the original on `cause`. #### Scenario: adapter redirect loop becomes REDIRECT_LOOP - **WHEN** the bound adapter raises a redirect-loop error - **THEN** the resolver's caller sees `StorageHttpUriError` with `code === "STORAGE_HTTP_URI.REDIRECT_LOOP"` and `cause` set **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"re-wraps redirect loop as REDIRECT_LOOP"` --- ### Requirement: `build(key, meta?)` inverse The plugin SHALL implement `build(key, meta?)` returning a URI whose scheme matches the factory (`http`/`https`), host derived from `adapter.options.baseUrl`, and path/query taken from `key`. The result SHALL round-trip through `parse` for every key produced by `list`. #### Scenario: build round-trips a list-produced key - **WHEN** `plugin.build("a/b?ver=1")` is called against a plugin whose adapter `baseUrl === "https://host"` - **THEN** the result is `"https://host/a/b?ver=1"` and `parse`s back **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"build round-trips list-produced keys"` --- ### Requirement: Signed-URL redaction The plugin SHALL redact a documented set of sensitive query parameters from any URI surfaced on OTel span attributes or in the `uri` discriminant of raised errors. The deny-list SHALL be exposed as `redactSignedUrl(uri): string` for reuse and SHALL match case-insensitively against the keys `X-Amz-Signature`, `X-Amz-Security-Token`, `token`, `signature`, `sig`, `key`, `auth`, `password`. #### Scenario: presigned signature is redacted on the span attribute - **WHEN** `read("https://host/p?X-Amz-Signature=abc")` is routed and the OTel span carries a `storage.uri` attribute - **THEN** the attribute value is `"https://host/p?X-Amz-Signature="` #### Scenario: redactSignedUrl is exported and idempotent - **WHEN** `redactSignedUrl(redactSignedUrl(uri))` is computed for any input - **THEN** the result equals the single-application result **Verified by:** `src/uri-plugin/redact-signed-url.test.ts::"redacts the documented deny-list"` AND `src/uri-plugin/redact-signed-url.test.ts::"is idempotent"` --- ### Requirement: `STORAGE_HTTP_URI_CODES` registry The package SHALL export `STORAGE_HTTP_URI_CODES` produced by `defineCodes("STORAGE_HTTP_URI", [...] as const)` covering exactly 6 codes: `INVALID_URI`, `UNSUPPORTED_SCHEME`, `RANGE_INVALID`, `REDIRECT_LOOP`, `HOST_MISMATCH`, `KEY_TOO_LONG`. Every code SHALL be reachable from a real call site exercised by the test suite, and the structured error class `StorageHttpUriError` SHALL extend `SuaveplanError` carrying optional `uri`/`scheme` discriminants. #### Scenario: every code is reachable - **WHEN** the test suite exercises every documented failure path - **THEN** every member of `STORAGE_HTTP_URI_CODES` appears on at least one raised `StorageHttpUriError` instance **Verified by:** `src/uri-plugin/error-codes.test.ts::"exposes 6 codes all reachable"` --- ### Requirement: AbortSignal threading The plugin SHALL forward an `AbortSignal` unchanged to every adapter call. A pre-aborted signal SHALL short-circuit with `STORAGE_HTTP.ABORTED` without invoking the adapter. #### Scenario: pre-aborted signal short-circuits - **WHEN** the resolver routes `read("https://x/y", AbortSignal.abort())` - **THEN** the rejection has `code === "STORAGE_HTTP.ABORTED"` and the adapter's `get` is NEVER invoked **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"short-circuits on pre-aborted signal"` --- ### Requirement: Universal-tier purity Every source file under `src/uri-plugin/` SHALL be universal-tier: ZERO imports from `node:*`, `bun:*`, browser-specific globals (`window`, `document`), or React. The plugin runs identically in workers, edge runtimes, and Node. #### Scenario: tier-purity grep returns empty - **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-http/src/uri-plugin/` is run - **THEN** the command returns zero matches **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"uri-plugin module has no node: or bun: imports"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports for `"./uri-plugin/uri-plugin"`, `"./uri-plugin/redact-signed-url"`, and `"./uri-plugin/error-codes"`. #### Scenario: subpath imports resolve - **WHEN** `import("@suaveplan/storage-http/uri-plugin/uri-plugin")` is executed - **THEN** the module resolves and re-exports `httpUriPlugin` and `httpsUriPlugin` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"is importable via the documented subpath"` --- ### Requirement: Conformance harness pass Both `httpUriPlugin` and `httpsUriPlugin` SHALL pass `runUriPluginConformance` with fixtures covering authority + path + query forms, range query variants, malformed inputs, and a non-empty `buildRoundTrip` set per scheme. #### Scenario: both factories pass conformance - **WHEN** `runUriPluginConformance` is invoked against each factory's output with the documented fixtures - **THEN** both calls return without throwing **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"httpUriPlugin passes runUriPluginConformance"` AND `src/uri-plugin/uri-plugin.test.ts::"httpsUriPlugin passes runUriPluginConformance"`