Initial Commit
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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=<offset>-<end>` 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": "<computed key>"`
|
||||
|
||||
**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=<redacted>"`
|
||||
|
||||
#### 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<StorageHttpUriErrorCode>` 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"`
|
||||
Reference in New Issue
Block a user