Initial Commit
This commit is contained in:
201
openspec/changes/complete-storage-uri-adapters/design.md
Normal file
201
openspec/changes/complete-storage-uri-adapters/design.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Design — complete-storage-uri-adapters
|
||||
|
||||
> Non-normative. The contracts are in
|
||||
> `./specs/<adapter>/spec.md`; this file is rationale, grammar, and
|
||||
> error-code matrices.
|
||||
|
||||
## Why one change instead of four
|
||||
|
||||
The four plugins share a verbatim integration playbook:
|
||||
|
||||
1. `parse(uri)` — RFC 3986 scheme + per-scheme authority/path/query
|
||||
validation; raise `<PKG>_URI.INVALID_URI` on malformed inputs.
|
||||
2. Dispatch to the bound adapter's existing `get`/`set`/`delete`/`list`/
|
||||
`exists`/`stat` ops.
|
||||
3. `build(key, meta?)` — RFC 3986 inverse for `list` re-serialisation.
|
||||
4. Span-attribute redaction of sensitive query parameters.
|
||||
5. `AbortSignal` threading on every delegated call.
|
||||
6. Per-adapter `defineCodes(...)` registry with 6 codes minimum.
|
||||
7. Conformance via `runUriPluginConformance`.
|
||||
|
||||
Four separate `complete-storage-<x>-uri` proposals would multiply
|
||||
ceremony — four design docs, four parity tables, four archive cycles —
|
||||
for a pattern that is identical at the contract level. A single change
|
||||
keeps the four spec deltas isolated (one folder per adapter under
|
||||
`./specs/`) so the canonical specs land independently at archive while
|
||||
the proposal stays a single review unit.
|
||||
|
||||
S3 is excluded because the `complete-storage-s3` change already owns the
|
||||
S3 surface end-to-end (multipart, SSE-C/KMS/S3, signed-DELETE,
|
||||
checksums); its `s3UriPlugin` is wired alongside those features and
|
||||
needs grammar (`s3://bucket/key`) plus bucket-mismatch error semantics
|
||||
that have no analogue in the other four.
|
||||
|
||||
## URI grammar per scheme
|
||||
|
||||
ABNF excerpts (RFC 5234, RFC 3986). `scheme` is matched case-insensitively
|
||||
per RFC 3986 §3.1; canonical form is lowercased.
|
||||
|
||||
### `file://` (RFC 8089)
|
||||
|
||||
```abnf
|
||||
file-uri = "file:" "//" auth-path
|
||||
auth-path = [ "localhost" ] "/" path-absolute
|
||||
/ "/" drive-letter ":" path-absolute ; Windows
|
||||
drive-letter = ALPHA
|
||||
```
|
||||
|
||||
| Form | Behaviour |
|
||||
|-----------------------------------|----------------------------------------|
|
||||
| `file:///etc/passwd` | absolute POSIX; key relative to `rootDir` |
|
||||
| `file://localhost/etc/passwd` | same as above (RFC 8089 §2) |
|
||||
| `file:///C:/Users/x` | Windows; drive letter preserved |
|
||||
| `file://example.com/x` | `STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY` |
|
||||
| `file:///../escape` | `STORAGE_FILE_URI.PATH_ESCAPE` |
|
||||
|
||||
### `http://`, `https://` (RFC 3986 §3)
|
||||
|
||||
```abnf
|
||||
http-uri = ( "http" / "https" ) "://" authority path-abempty [ "?" query ] [ "#" fragment ]
|
||||
authority = [ userinfo "@" ] host [ ":" port ]
|
||||
```
|
||||
|
||||
Query string is preserved on the key (presigned URLs depend on it).
|
||||
Fragment is dropped (servers ignore it).
|
||||
|
||||
### `bunnycdn://` (genesis-original)
|
||||
|
||||
```abnf
|
||||
bunny-storage = "bunnycdn://" zone "/" path [ "?" query ]
|
||||
bunny-pull = "bunnycdn+pull://" pull-zone "/" path [ "?" query ]
|
||||
zone = 1*( unreserved / pct-encoded )
|
||||
```
|
||||
|
||||
| Form | Behaviour |
|
||||
|-------------------------------------------------|----------------------------------------|
|
||||
| `bunnycdn://uploads/img/a.jpg` | storage zone; rw against `BunnyCdnAdapter` |
|
||||
| `bunnycdn+pull://cdn.example.com/img/a.jpg` | pull zone; read-only |
|
||||
| `bunnycdn://wrong/img/a.jpg` (zone mismatch) | `ZONE_MISMATCH` |
|
||||
| `bunnycdn+pull://...` + `set` | `UNSUPPORTED_OPERATION` |
|
||||
|
||||
### `rackspace://` (genesis-original)
|
||||
|
||||
```abnf
|
||||
rackspace-uri = "rackspace://" container "/" path [ "?" query ]
|
||||
container = "_" / 1*( unreserved / pct-encoded )
|
||||
```
|
||||
|
||||
`_` rebinds to the plugin's configured container (handy for tests).
|
||||
|
||||
## Query-string semantics + redaction policy
|
||||
|
||||
Every plugin redacts sensitive query parameters from OTel span
|
||||
attributes and error `.uri` discriminants. The deny-list is per-scheme:
|
||||
|
||||
| Plugin | Redacted parameter keys (case-insensitive) |
|
||||
|--------------------|----------------------------------------------------------------------------|
|
||||
| `fileUriPlugin` | (none — file URIs do not carry query strings) |
|
||||
| `httpUriPlugin` | `X-Amz-Signature`, `X-Amz-Security-Token`, `token`, `signature`, `sig`, `key`, `auth`, `password` |
|
||||
| `httpsUriPlugin` | (same as `httpUriPlugin`) |
|
||||
| `bunnyUriPlugin` | `token`, `signature`, `expires` |
|
||||
| `rackspaceUriPlugin` | `temp_url_sig`, `temp_url_expires`, `X-Auth-Token` |
|
||||
|
||||
Redaction is implemented per-plugin in
|
||||
`src/uri-plugin/redact-signed-url.ts` (the file does not exist for
|
||||
`fileUriPlugin` — file URIs are pass-through). The function replaces
|
||||
each matched value with the literal string `"<redacted>"` and preserves
|
||||
parameter ordering so the redacted URI remains diff-friendly in logs.
|
||||
|
||||
Range parameters (`?range=bytes=0-1023`) are NOT redacted — they
|
||||
describe the request, not the secret.
|
||||
|
||||
## Error-code matrix per adapter
|
||||
|
||||
Each adapter ships its own registry via
|
||||
`defineCodes("STORAGE_<NAME>_URI", [...] as const)`. Six codes minimum;
|
||||
each reachable from a real call site exercised by tests.
|
||||
|
||||
| Code | file | http | bunny | rackspace |
|
||||
|--------------------------------------|:----:|:----:|:-----:|:---------:|
|
||||
| `INVALID_URI` | x | x | x | x |
|
||||
| `KEY_TOO_LONG` | x | x | x | x |
|
||||
| `UNSUPPORTED_AUTHORITY` | x | | | |
|
||||
| `PATH_ESCAPE` | x | | | |
|
||||
| `PERMISSION_DENIED` | x | | | |
|
||||
| `WINDOWS_DRIVE_INVALID` | x | | | |
|
||||
| `UNSUPPORTED_SCHEME` | | x | | |
|
||||
| `RANGE_INVALID` | | x | | |
|
||||
| `REDIRECT_LOOP` | | x | | |
|
||||
| `HOST_MISMATCH` | | x | | |
|
||||
| `ZONE_MISMATCH` | | | x | |
|
||||
| `PULL_ZONE_NOT_CONFIGURED` | | | x | |
|
||||
| `TOKEN_INVALID` | | | x | |
|
||||
| `UNSUPPORTED_OPERATION` | | | x | |
|
||||
| `CONTAINER_MISMATCH` | | | | x |
|
||||
| `REGION_UNSUPPORTED` | | | | x |
|
||||
| `TEMP_URL_INVALID` | | | | x |
|
||||
| `AUTH_TOKEN_EXPIRED` | | | | x |
|
||||
|
||||
`ABORTED` is NOT a new code — each adapter already exposes one (e.g.
|
||||
`STORAGE_FILE.ABORTED`); the plugin re-uses it for pre-aborted signals
|
||||
rather than introducing a duplicate.
|
||||
|
||||
## File layout per adapter
|
||||
|
||||
```text
|
||||
packages/storage/<adapter>/src/
|
||||
uri-plugin/
|
||||
index.ts # explicit named re-exports (rule 10)
|
||||
uri-plugin.ts # factory + parse/build
|
||||
uri-plugin.md
|
||||
uri-plugin.test.ts # conformance + per-scheme cases
|
||||
error-codes.ts # defineCodes + structured error class
|
||||
error-codes.md
|
||||
error-codes.test.ts
|
||||
redact-signed-url.ts # (file-plugin omitted)
|
||||
redact-signed-url.md
|
||||
redact-signed-url.test.ts
|
||||
```
|
||||
|
||||
## Conformance fixtures
|
||||
|
||||
Each adapter's test calls:
|
||||
|
||||
```ts
|
||||
runUriPluginConformance(plugin, {
|
||||
validUris: [...per-scheme happy paths],
|
||||
invalidUris: [...per-scheme malformed inputs],
|
||||
buildRoundTrip: [{ key, meta, uri }, ...],
|
||||
});
|
||||
```
|
||||
|
||||
The harness asserts:
|
||||
|
||||
1. `plugin.scheme` matches every `validUris[i]` scheme.
|
||||
2. `plugin.parse(uri).key` is a non-empty string for every valid URI.
|
||||
3. Every invalid URI throws (preferably `STORAGE_URI.INVALID_URI`; the
|
||||
harness tolerates per-package `INVALID_URI` re-throws via
|
||||
`StorageUriError` instance check).
|
||||
4. When `buildRoundTrip` is non-empty, `plugin.build` MUST be defined and
|
||||
must return the exact expected URI string for each entry.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- **Two schemes from one bunny factory** — `bunnycdn` and
|
||||
`bunnycdn+pull` share parsing logic but register as two `UriPlugin`
|
||||
instances because the resolver indexes one plugin per scheme. The
|
||||
factory returns both via destructuring:
|
||||
`const { storage, pull } = bunnyUriPlugin(opts)`.
|
||||
- **Plugins live in the adapter packages, not in `storage-uri`** — keeps
|
||||
the resolver dependency-free (per the foundation contract from
|
||||
`add-storage-uri`).
|
||||
- **Range parsing via query string** — `?range=bytes=0-1023` is an
|
||||
awkward channel, but it keeps `UriPlugin.parse` returning the
|
||||
un-changed `ParsedUri` shape from `storage-uri` without extending the
|
||||
contract for a single optional dimension. Future revisions MAY hoist
|
||||
range into `ParsedUri.meta` formally.
|
||||
- **No `node:` imports in CDN plugins** — universal-tier so the same
|
||||
code runs in workers and edge runtimes that consume CDN URIs.
|
||||
- **`file://` plugin is server-tier** — already true of
|
||||
`@suaveplan/storage-file`; the URI plugin inherits the same
|
||||
constraint.
|
||||
273
openspec/changes/complete-storage-uri-adapters/proposal.md
Normal file
273
openspec/changes/complete-storage-uri-adapters/proposal.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Complete `uriPlugin` exports on four storage adapter packages
|
||||
|
||||
- **Change ID**: complete-storage-uri-adapters
|
||||
- **Packages**: `@suaveplan/storage-file`, `@suaveplan/storage-http`, `@suaveplan/storage-cdn-bunnycdn`, `@suaveplan/storage-cdn-rackspace` (modify — additive)
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-17
|
||||
- **Type**: capability-completion (multi-package, one change)
|
||||
- **Design**: ./design.md
|
||||
- **Spec deltas**:
|
||||
- ./specs/storage-file/spec.md
|
||||
- ./specs/storage-http/spec.md
|
||||
- ./specs/storage-cdn-bunnycdn/spec.md
|
||||
- ./specs/storage-cdn-rackspace/spec.md
|
||||
- **Depends on**: `add-storage-uri` (archived `openspec/archive/2026-05-16-add-storage-uri/`)
|
||||
- **Sibling in-flight**: `complete-storage-s3` ships the `s3UriPlugin` — explicitly out of scope here.
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/storage-uri` shipped on 2026-05-16 as a scheme-routed dispatch
|
||||
layer over `StorageLike` adapters. Its contract requires every adapter
|
||||
package to contribute a `UriPlugin` so the resolver stays scheme-agnostic.
|
||||
The original change's task list (`openspec/archive/2026-05-16-add-storage-uri/tasks.md`
|
||||
§5) enumerated the per-adapter plugin work; the file-, http-, bunnycdn-,
|
||||
and rackspace adapters never shipped theirs. Today consumers cannot
|
||||
register any of these four schemes with a `StorageUriResolver` without
|
||||
hand-rolling a plugin per adapter — defeating the routing layer's purpose.
|
||||
|
||||
A single consolidated change covers all four because the pattern is
|
||||
identical: parse the scheme's URI grammar into `{ key, meta? }`, bind to
|
||||
the adapter's existing read/write/delete/stat/list/exists ops, redact
|
||||
sensitive query parameters in span attributes, thread `AbortSignal`, raise
|
||||
a per-adapter `STORAGE_<NAME>_URI.*` code registry, and pass
|
||||
`runUriPluginConformance`. Four separate changes would multiply
|
||||
ceremony — four proposals, four design docs, four archive cycles — for
|
||||
work that shares a verbatim integration playbook. `storage-s3`'s plugin
|
||||
is *excluded* and lives in `complete-storage-s3` because the S3 surface
|
||||
needs presigned-URL, SSE, and multipart wiring that none of these four
|
||||
schemes share.
|
||||
|
||||
## What changes
|
||||
|
||||
### `@suaveplan/storage-file` — `file://` URI plugin
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `fileUriPlugin(options)`
|
||||
where `options: { adapter: NodeFsStorage; rootDir: string }`. Plugin
|
||||
parses `file:///abs/path`, `file://localhost/abs/path`,
|
||||
and `file:///C:/Windows/Path` (Windows drive-letter form) per RFC 8089.
|
||||
Empty / `localhost` authorities are normalised to the local filesystem.
|
||||
Non-empty non-`localhost` authorities raise
|
||||
`STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY` (no network mounts).
|
||||
- ADDED: `build(key)` inverse that re-emits `file:///${rootDir}/${key}`
|
||||
with proper percent-encoding (RFC 3986 path segments) and Windows
|
||||
drive-letter preservation.
|
||||
- ADDED: relative-vs-absolute resolution: the plugin REJECTS any URI
|
||||
whose decoded path escapes `rootDir` via `..` traversal — raised as
|
||||
`STORAGE_FILE_URI.PATH_ESCAPE`.
|
||||
- ADDED: permission-denied passthrough — `EACCES` / `EPERM` from
|
||||
`adapter` re-classified as `STORAGE_FILE_URI.PERMISSION_DENIED` so URI
|
||||
consumers can branch without depending on `STORAGE_FILE` directly.
|
||||
- ADDED: `STORAGE_FILE_URI_CODES` via `defineCodes("STORAGE_FILE_URI", ...)`
|
||||
— 6 codes: `INVALID_URI`, `UNSUPPORTED_AUTHORITY`, `PATH_ESCAPE`,
|
||||
`PERMISSION_DENIED`, `WINDOWS_DRIVE_INVALID`, `KEY_TOO_LONG`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts` (uses
|
||||
`runUriPluginConformance` plus cross-platform path fixtures).
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"` in `package.json`.
|
||||
|
||||
### `@suaveplan/storage-http` — `http://` + `https://` URI plugins
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `httpUriPlugin(options)`
|
||||
AND `httpsUriPlugin(options)` where `options: { adapter: HttpStorage }`.
|
||||
The two factories share an internal builder; the only difference is the
|
||||
registered `scheme` field.
|
||||
- ADDED: URI grammar: full RFC 3986 hierarchy
|
||||
`https://user:pass@host:port/path?query#fragment`. The plugin parses
|
||||
authority + path + query and forwards them to `HttpStorage` via the
|
||||
`composeUrl` helper. Query strings reach the adapter as part of the key
|
||||
to preserve presigned-URL `?X-Amz-Signature=...` semantics.
|
||||
- ADDED: range-request support — `getPartial(key, { offset, length })`
|
||||
reachable via the URI plugin when the URI bears an explicit
|
||||
`?range=bytes=<offset>-<end>` meta channel; otherwise the plugin
|
||||
passes through to `adapter.get`. Range parsing is REJECTED with
|
||||
`STORAGE_HTTP_URI.RANGE_INVALID` for malformed forms.
|
||||
- ADDED: header injection — the plugin reads
|
||||
`HttpStorage.options.headers` via dependency injection, but URI
|
||||
consumers MAY override per-request headers through the resolver's
|
||||
AbortSignal-bearing options bag (no new resolver surface; the plugin
|
||||
routes them through `adapter.options.headers`).
|
||||
- ADDED: redirect policy — the adapter's `FetchLike` is responsible for
|
||||
redirects; the plugin documents that opaque redirects expose the final
|
||||
URL via the `Response.url` returned to `HttpStorage` and surfaces a
|
||||
`STORAGE_HTTP_URI.REDIRECT_LOOP` when the adapter raises one.
|
||||
- ADDED: `build(key, meta?)` inverse — re-emits the canonical URL with
|
||||
the scheme set per factory. Authority/path encoding handled via
|
||||
`new URL(key, baseUrl)`.
|
||||
- ADDED: query-string redaction policy — span attributes drop any
|
||||
query parameter whose key matches the case-insensitive deny-list
|
||||
`X-Amz-Signature`, `X-Amz-Security-Token`, `token`, `signature`,
|
||||
`sig`, `key`, `auth`, `password`. Redaction implemented as
|
||||
`redactSignedUrl(uri)`; exported for reuse.
|
||||
- ADDED: `STORAGE_HTTP_URI_CODES` via
|
||||
`defineCodes("STORAGE_HTTP_URI", ...)` — 6 codes: `INVALID_URI`,
|
||||
`UNSUPPORTED_SCHEME`, `RANGE_INVALID`, `REDIRECT_LOOP`, `HOST_MISMATCH`,
|
||||
`KEY_TOO_LONG`.
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts`.
|
||||
|
||||
### `@suaveplan/storage-cdn-bunnycdn` — `bunnycdn://` URI plugin
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `bunnyUriPlugin(options)`
|
||||
where `options: { adapter: BunnyCdnAdapter; storageZone: string; pullZoneHostname?: string }`.
|
||||
- ADDED: URI grammar: `bunnycdn://<storage-zone>/<path>` for the
|
||||
storage-zone (write) surface; pull-zone reads route through
|
||||
`bunnycdn+pull://<pull-zone>/<path>` (a sibling sub-scheme registered
|
||||
under the same plugin). The plugin discriminates on the second sub-form
|
||||
via `scheme === "bunnycdn+pull"`; storage-zone authority is normalised
|
||||
against the configured `storageZone` and a mismatch raises
|
||||
`STORAGE_CDN_BUNNY_URI.ZONE_MISMATCH`.
|
||||
- ADDED: signed-URL support — when the URI carries
|
||||
`?token=<bunny-token>&expires=<unix>` query parameters, the plugin
|
||||
consumes them via the existing `buildTokenAuthQuery` helper. The token
|
||||
bytes are NEVER persisted on `ParsedUri.meta`; they live only on the
|
||||
ephemeral fetch path.
|
||||
- ADDED: `build(key, meta?)` inverse — emits
|
||||
`bunnycdn://<storage-zone>/<key>` or
|
||||
`bunnycdn+pull://<pull-zone>/<key>` based on `meta.surface ∈ {"storage", "pull"}`
|
||||
(defaults to `"storage"`).
|
||||
- ADDED: redaction policy — span `cdn.uri` attribute strips
|
||||
`token`, `signature`, `expires` query parameters; only path + zone
|
||||
attribute survive.
|
||||
- ADDED: `STORAGE_CDN_BUNNY_URI_CODES` via
|
||||
`defineCodes("STORAGE_CDN_BUNNY_URI", ...)` — 6 codes:
|
||||
`INVALID_URI`, `ZONE_MISMATCH`, `PULL_ZONE_NOT_CONFIGURED`,
|
||||
`TOKEN_INVALID`, `KEY_TOO_LONG`, `UNSUPPORTED_OPERATION`
|
||||
(raised when a pull-zone URI is `set`/`delete`'d — pull zones are
|
||||
read-only).
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts`.
|
||||
|
||||
### `@suaveplan/storage-cdn-rackspace` — `rackspace://` URI plugin
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `rackspaceUriPlugin(options)`
|
||||
where `options: { adapter: RackspaceCdnAdapter; container: string; region?: RackspaceRegion }`.
|
||||
- ADDED: URI grammar: `rackspace://<container>/<object-key>`. The
|
||||
container appears as the URI authority; the object key is the path.
|
||||
Authority `_` or empty re-binds to the plugin's configured `container`.
|
||||
- ADDED: auth-token threading — the plugin does NOT carry the auth
|
||||
token in URIs (auth lives on `RackspaceAuthClient`). It threads the
|
||||
ambient signal into `RackspaceCdnAdapter` calls and propagates
|
||||
`RackspaceCdnAdapter`'s token-expiry/refresh behaviour transparently.
|
||||
- ADDED: temp-URL signed-URL passthrough — when the URI carries
|
||||
`?temp_url_sig=<sig>&temp_url_expires=<unix>`, the plugin forwards them
|
||||
to `buildTempUrlSignature` for verification; on mismatch raises
|
||||
`STORAGE_CDN_RACKSPACE_URI.TEMP_URL_INVALID`.
|
||||
- ADDED: `build(key, meta?)` inverse — emits
|
||||
`rackspace://<container>/<key>`; `meta.region` (when set) is encoded as
|
||||
`?region=<region>` for downstream resolvers that re-bind regions per
|
||||
URI.
|
||||
- ADDED: redaction policy — span attributes strip
|
||||
`temp_url_sig`, `temp_url_expires`, `X-Auth-Token`.
|
||||
- ADDED: `STORAGE_CDN_RACKSPACE_URI_CODES` via
|
||||
`defineCodes("STORAGE_CDN_RACKSPACE_URI", ...)` — 6 codes:
|
||||
`INVALID_URI`, `CONTAINER_MISMATCH`, `REGION_UNSUPPORTED`,
|
||||
`TEMP_URL_INVALID`, `AUTH_TOKEN_EXPIRED`, `KEY_TOO_LONG`.
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts`.
|
||||
|
||||
### Shared scaffolding
|
||||
|
||||
- Each plugin uses the existing `parseUri` helper from `@suaveplan/storage-uri`
|
||||
for RFC 3986 split; per-scheme validation layers on top.
|
||||
- Each plugin THREADS `AbortSignal` through every delegated adapter
|
||||
call. Pre-aborted signals raise the per-package `*.ABORTED`-equivalent
|
||||
(each adapter already has an `ABORTED` code; the URI plugin re-uses it
|
||||
rather than adding a duplicate).
|
||||
- Each plugin passes `runUriPluginConformance` with valid + invalid +
|
||||
buildRoundTrip fixtures.
|
||||
- Each plugin gets sibling `.md` per CLAUDE.md §4 and an updated
|
||||
`README.md` `## Modules` link.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **`@suaveplan/storage-s3` `s3UriPlugin`** — covered by the in-flight
|
||||
`complete-storage-s3` change which lands the plugin alongside the full
|
||||
S3 multipart / SSE / presign surface. Including it here would double-write
|
||||
the spec for the same surface.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This is a **capability-completion** change. Surface anchors:
|
||||
|
||||
- `openspec/archive/2026-05-16-add-storage-uri/tasks.md` §5 — original
|
||||
per-adapter task list; items 5.1–5.4 deferred.
|
||||
- `packages/storage/storage-uri/src/types/plugin.types.ts:29-34` —
|
||||
`UriPlugin` contract.
|
||||
- `packages/storage/storage-uri/src/conformance/conformance.ts:39-115` —
|
||||
`runUriPluginConformance` harness.
|
||||
- `packages/storage/storage-file/src/node-fs-storage/` — adapter surface
|
||||
consumed verbatim.
|
||||
- `packages/storage/storage-http/src/http-storage/http-storage.helpers.ts:52-58` —
|
||||
`composeUrl` helper consumed.
|
||||
- `packages/storage/storage-cdn-bunnycdn/src/token-auth/token-auth.ts` —
|
||||
`buildTokenAuthQuery` consumed for signed-URL paths.
|
||||
- `packages/storage/storage-cdn-rackspace/src/temp-url/temp-url.ts` —
|
||||
`buildTempUrlSignature` consumed for signed-URL paths.
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in every spec delta carries a `**Verified by:**` line
|
||||
referencing concrete `it(...)` titles in planned test files. The
|
||||
pre-archive gate (`§13` of `AGENTS.md`) re-runs the full conformance
|
||||
suite per adapter. No mocks (per CLAUDE.md rule 2) — fixture adapters
|
||||
are constructed against real `NodeFsStorage`, real `HttpStorage`
|
||||
(against `testing-integration` fixtures), and real `BunnyCdnAdapter` /
|
||||
`RackspaceCdnAdapter` instances wired to in-process fetch fixtures.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
For each of the four packages, run
|
||||
`bunx turbo ls --filter=...@suaveplan/<pkg>` to enumerate. All additions
|
||||
are purely additive named exports — no existing surface changes.
|
||||
|
||||
- `@suaveplan/storage-uri` — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
(consumes the four plugins via the existing `UriPlugin` interface; the
|
||||
cross-scheme integration tests under
|
||||
`packages/storage/storage-uri/tests/integration/` gain the four new
|
||||
schemes).
|
||||
- `@suaveplan/storage-file` consumers — [ ] tests run / [ ] docs reviewed
|
||||
/ [x] no action needed (purely additive `fileUriPlugin` export).
|
||||
- `@suaveplan/storage-http` consumers — [ ] tests run / [ ] docs reviewed
|
||||
/ [x] no action needed (purely additive `httpUriPlugin` /
|
||||
`httpsUriPlugin` exports).
|
||||
- `@suaveplan/storage-cdn-bunnycdn` consumers — [ ] tests run / [ ] docs
|
||||
reviewed / [x] no action needed.
|
||||
- `@suaveplan/storage-cdn-rackspace` consumers — [ ] tests run / [ ] docs
|
||||
reviewed / [x] no action needed.
|
||||
|
||||
None of the four adapter packages are foundation packages per §14a; the
|
||||
dependent sweep is informational only.
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
| Package | Action | Breaking? |
|
||||
|---|---|---|
|
||||
| `@suaveplan/storage-file` | ADD `fileUriPlugin` + `STORAGE_FILE_URI_CODES` | no |
|
||||
| `@suaveplan/storage-http` | ADD `httpUriPlugin` + `httpsUriPlugin` + `STORAGE_HTTP_URI_CODES` | no |
|
||||
| `@suaveplan/storage-cdn-bunnycdn` | ADD `bunnyUriPlugin` + `STORAGE_CDN_BUNNY_URI_CODES` | no |
|
||||
| `@suaveplan/storage-cdn-rackspace` | ADD `rackspaceUriPlugin` + `STORAGE_CDN_RACKSPACE_URI_CODES` | no |
|
||||
| `@suaveplan/storage-uri` | No surface change — gains test coverage via the cross-scheme integration suite | no |
|
||||
|
||||
## Migration
|
||||
|
||||
The change is purely additive — no behaviour change for existing
|
||||
consumers. After landing, consumers compose a multi-scheme resolver via:
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { fileUriPlugin } from "@suaveplan/storage-file/uri-plugin/uri-plugin";
|
||||
import { httpsUriPlugin } from "@suaveplan/storage-http/uri-plugin/uri-plugin";
|
||||
import { bunnyUriPlugin } from "@suaveplan/storage-cdn-bunnycdn/uri-plugin/uri-plugin";
|
||||
import { rackspaceUriPlugin } from "@suaveplan/storage-cdn-rackspace/uri-plugin/uri-plugin";
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
fileUriPlugin({ adapter: fileAdapter, rootDir: "/var/data" }),
|
||||
httpsUriPlugin({ adapter: httpAdapter }),
|
||||
bunnyUriPlugin({ adapter: bunnyAdapter, storageZone: "uploads" }),
|
||||
rackspaceUriPlugin({ adapter: rackspaceAdapter, container: "assets" }),
|
||||
]);
|
||||
```
|
||||
|
||||
No grep search needed — there is no prior public surface for these
|
||||
plugins.
|
||||
@@ -0,0 +1,241 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-cdn-bunnycdn (complete-storage-uri-adapters)
|
||||
|
||||
**Package**: `@suaveplan/storage-cdn-bunnycdn`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `bunnycdn://` and `bunnycdn+pull://` URI plugin factory
|
||||
|
||||
The package SHALL export `bunnyUriPlugin(options)` from
|
||||
`./uri-plugin/uri-plugin` returning an object
|
||||
`{ storage: UriPlugin; pull: UriPlugin }` whose `storage.scheme` is
|
||||
`"bunnycdn"` and `pull.scheme` is `"bunnycdn+pull"`. Both plugins
|
||||
SHALL share the bound `BunnyCdnAdapter`; only the URI surface differs.
|
||||
|
||||
#### Scenario: factory exposes two plugin instances
|
||||
|
||||
- **WHEN** `bunnyUriPlugin({ adapter, storageZone: "uploads" })` is
|
||||
called
|
||||
- **THEN** the returned object has `storage.scheme === "bunnycdn"` and
|
||||
`pull.scheme === "bunnycdn+pull"`, and both reference the same adapter
|
||||
|
||||
#### Scenario: factory requires storageZone
|
||||
|
||||
- **WHEN** `bunnyUriPlugin({ adapter, storageZone: "" })` is called
|
||||
- **THEN** the call throws `StorageCdnBunnycdnUriError` with
|
||||
`code === "STORAGE_CDN_BUNNY_URI.INVALID_URI"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"exposes both storage and pull plugins"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects empty storageZone"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Storage-zone parsing with mismatch detection
|
||||
|
||||
The `storage` plugin SHALL parse `bunnycdn://<zone>/<path>` URIs by
|
||||
taking the authority as the storage zone and the path (minus leading
|
||||
`/`) as the storage key. When the URI's zone does NOT match the
|
||||
factory's configured `storageZone`, the call SHALL raise
|
||||
`STORAGE_CDN_BUNNY_URI.ZONE_MISMATCH`.
|
||||
|
||||
#### Scenario: matching zone parses successfully
|
||||
|
||||
- **WHEN** `storage.parse("bunnycdn://uploads/img/a.jpg")` is called
|
||||
against a plugin configured with `storageZone: "uploads"`
|
||||
- **THEN** the returned `key === "img/a.jpg"`
|
||||
|
||||
#### Scenario: mismatched zone raises ZONE_MISMATCH
|
||||
|
||||
- **WHEN** `storage.parse("bunnycdn://wrong/img/a.jpg")` is called
|
||||
against the same plugin
|
||||
- **THEN** the call throws `StorageCdnBunnycdnUriError` with
|
||||
`code === "STORAGE_CDN_BUNNY_URI.ZONE_MISMATCH"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"parses matching storage zone"` AND `src/uri-plugin/uri-plugin.test.ts::"raises ZONE_MISMATCH for wrong zone"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Pull-zone read-only enforcement
|
||||
|
||||
The `pull` plugin SHALL parse `bunnycdn+pull://<host>/<path>` URIs and
|
||||
SHALL reject `set` / `delete` calls with
|
||||
`STORAGE_CDN_BUNNY_URI.UNSUPPORTED_OPERATION`. Pull zones are read-only
|
||||
caches; writes have no semantic target. When the factory was constructed
|
||||
without `options.pullZoneHostname`, parsing a pull-zone URI SHALL raise
|
||||
`STORAGE_CDN_BUNNY_URI.PULL_ZONE_NOT_CONFIGURED`.
|
||||
|
||||
#### Scenario: pull-zone read parses successfully
|
||||
|
||||
- **WHEN** `pull.parse("bunnycdn+pull://cdn.example.com/img/a.jpg")` is
|
||||
called against a plugin with `pullZoneHostname: "cdn.example.com"`
|
||||
- **THEN** the returned `key === "img/a.jpg"`
|
||||
|
||||
#### Scenario: pull-zone write is rejected
|
||||
|
||||
- **WHEN** the resolver routes `write("bunnycdn+pull://cdn.example.com/x", bytes)`
|
||||
- **THEN** the rejection has
|
||||
`code === "STORAGE_CDN_BUNNY_URI.UNSUPPORTED_OPERATION"`
|
||||
|
||||
#### Scenario: pull-zone URI without configured hostname is rejected
|
||||
|
||||
- **WHEN** `pull.parse("bunnycdn+pull://cdn.example.com/x")` is called
|
||||
against a plugin that was constructed without `pullZoneHostname`
|
||||
- **THEN** the call throws with
|
||||
`code === "STORAGE_CDN_BUNNY_URI.PULL_ZONE_NOT_CONFIGURED"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"parses pull-zone reads"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects pull-zone writes"` AND `src/uri-plugin/uri-plugin.test.ts::"raises PULL_ZONE_NOT_CONFIGURED when missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Signed-URL token passthrough
|
||||
|
||||
When a URI carries `?token=<value>&expires=<unix>`, the plugin SHALL
|
||||
forward those parameters to the existing `buildTokenAuthQuery` helper
|
||||
when invoking the adapter. The token bytes SHALL NEVER be persisted on
|
||||
`ParsedUri.meta`; they live only on the ephemeral fetch path.
|
||||
|
||||
#### Scenario: signed token reaches buildTokenAuthQuery
|
||||
|
||||
- **WHEN** the resolver routes
|
||||
`read("bunnycdn://uploads/x?token=abc&expires=99")` and the bound
|
||||
adapter has `tokenAuthKey` configured
|
||||
- **THEN** the adapter's outgoing request URL contains the
|
||||
token-authenticated query string produced by `buildTokenAuthQuery`
|
||||
|
||||
#### Scenario: token is absent from ParsedUri.meta
|
||||
|
||||
- **WHEN** `storage.parse("bunnycdn://uploads/x?token=abc&expires=99")` is
|
||||
called
|
||||
- **THEN** the returned `meta` does NOT include a `token` key
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"forwards signed token to buildTokenAuthQuery"` AND `src/uri-plugin/uri-plugin.test.ts::"does not persist token on ParsedUri.meta"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `build(key, meta?)` with surface discriminator
|
||||
|
||||
The plugin SHALL implement `build(key, meta?)` on both surfaces. When
|
||||
`meta?.surface === "pull"`, the result uses the `bunnycdn+pull://` form
|
||||
with `pullZoneHostname`; otherwise the result uses the `bunnycdn://`
|
||||
form with the configured `storageZone`.
|
||||
|
||||
#### Scenario: storage build defaults to storage zone
|
||||
|
||||
- **WHEN** `storage.build("img/a.jpg")` is called
|
||||
- **THEN** the result is `"bunnycdn://<storageZone>/img/a.jpg"`
|
||||
|
||||
#### Scenario: pull build uses pull-zone hostname
|
||||
|
||||
- **WHEN** `pull.build("img/a.jpg")` is called against a plugin with
|
||||
`pullZoneHostname: "cdn.example.com"`
|
||||
- **THEN** the result is `"bunnycdn+pull://cdn.example.com/img/a.jpg"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"storage build emits bunnycdn scheme"` AND `src/uri-plugin/uri-plugin.test.ts::"pull build emits bunnycdn+pull scheme"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Span-attribute redaction
|
||||
|
||||
The plugin SHALL redact `token`, `signature`, and `expires` query
|
||||
parameters from any URI surfaced on OTel span attributes or in raised
|
||||
error `.uri` discriminants. Redaction SHALL be exposed as
|
||||
`redactSignedUrl(uri): string`.
|
||||
|
||||
#### Scenario: token is redacted on span attribute
|
||||
|
||||
- **WHEN** the resolver routes
|
||||
`read("bunnycdn://uploads/x?token=abc&expires=99")` and emits a span
|
||||
- **THEN** the `cdn.uri` attribute is
|
||||
`"bunnycdn://uploads/x?token=<redacted>&expires=<redacted>"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/redact-signed-url.test.ts::"redacts token, signature, and expires"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `STORAGE_CDN_BUNNY_URI_CODES` registry
|
||||
|
||||
The package SHALL export `STORAGE_CDN_BUNNY_URI_CODES` produced by
|
||||
`defineCodes("STORAGE_CDN_BUNNY_URI", [...] as const)` covering exactly
|
||||
6 codes: `INVALID_URI`, `ZONE_MISMATCH`, `PULL_ZONE_NOT_CONFIGURED`,
|
||||
`TOKEN_INVALID`, `KEY_TOO_LONG`, `UNSUPPORTED_OPERATION`. Every code
|
||||
SHALL be reachable, and `StorageCdnBunnycdnUriError` SHALL extend
|
||||
`SuaveplanError<StorageCdnBunnycdnUriErrorCode>` with `uri`/`scheme`
|
||||
discriminants.
|
||||
|
||||
#### Scenario: every code is reachable
|
||||
|
||||
- **WHEN** the test suite exercises every documented failure path
|
||||
- **THEN** every member of `STORAGE_CDN_BUNNY_URI_CODES` appears on at
|
||||
least one raised error instance
|
||||
|
||||
**Verified by:** `src/uri-plugin/error-codes.test.ts::"exposes 6 codes all reachable"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal threading
|
||||
|
||||
The plugin SHALL forward `AbortSignal` unchanged to every adapter call.
|
||||
A pre-aborted signal SHALL short-circuit with the adapter's existing
|
||||
`STORAGE_CDN_BUNNYCDN.ABORTED` code without invoking the adapter.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits
|
||||
|
||||
- **WHEN** the resolver routes
|
||||
`read("bunnycdn://uploads/x", AbortSignal.abort())`
|
||||
- **THEN** the rejection has
|
||||
`code === "STORAGE_CDN_BUNNYCDN.ABORTED"` and the adapter 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, or React.
|
||||
|
||||
#### Scenario: tier-purity grep returns empty
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-cdn-bunnycdn/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-cdn-bunnycdn/uri-plugin/uri-plugin")`
|
||||
is executed
|
||||
- **THEN** the module resolves and re-exports `bunnyUriPlugin`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"is importable via the documented subpath"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance harness pass
|
||||
|
||||
Both `storage` and `pull` plugins SHALL pass
|
||||
`runUriPluginConformance` with fixtures covering zone-match cases,
|
||||
invalid zone forms, signed-URL passthrough, and non-empty
|
||||
`buildRoundTrip` sets per surface.
|
||||
|
||||
#### Scenario: both plugins pass conformance
|
||||
|
||||
- **WHEN** `runUriPluginConformance` is invoked against each plugin
|
||||
surface with the documented fixtures
|
||||
- **THEN** both calls return without throwing
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"storage plugin passes runUriPluginConformance"` AND `src/uri-plugin/uri-plugin.test.ts::"pull plugin passes runUriPluginConformance"`
|
||||
@@ -0,0 +1,254 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-cdn-rackspace (complete-storage-uri-adapters)
|
||||
|
||||
**Package**: `@suaveplan/storage-cdn-rackspace`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `rackspace://` URI plugin factory
|
||||
|
||||
The package SHALL export `rackspaceUriPlugin(options)` from
|
||||
`./uri-plugin/uri-plugin` returning a `UriPlugin` whose `scheme` is
|
||||
`"rackspace"` and whose `adapter` is the supplied
|
||||
`RackspaceCdnAdapter`. The factory SHALL validate `options.container`
|
||||
is a non-empty string; failure raises
|
||||
`StorageCdnRackspaceUriError(STORAGE_CDN_RACKSPACE_URI.INVALID_URI)`
|
||||
synchronously.
|
||||
|
||||
#### Scenario: factory returns a UriPlugin with scheme rackspace
|
||||
|
||||
- **WHEN** `rackspaceUriPlugin({ adapter, container: "assets" })` is
|
||||
called
|
||||
- **THEN** the returned plugin's `scheme === "rackspace"`, `adapter` is
|
||||
the same reference, and `runUriPluginConformance` passes
|
||||
|
||||
#### Scenario: empty container is rejected
|
||||
|
||||
- **WHEN** `rackspaceUriPlugin({ adapter, container: "" })` is called
|
||||
- **THEN** the call throws with
|
||||
`code === "STORAGE_CDN_RACKSPACE_URI.INVALID_URI"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"exports a UriPlugin with scheme rackspace"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects empty container"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Container + object-key parsing
|
||||
|
||||
The plugin SHALL parse `rackspace://<container>/<object-key>` URIs by
|
||||
taking the authority as the container and the path (minus leading `/`)
|
||||
as the object key. The literal authority `_` (or empty) SHALL rebind to
|
||||
the factory's configured `container`. A mismatched non-`_` container
|
||||
SHALL raise `STORAGE_CDN_RACKSPACE_URI.CONTAINER_MISMATCH`.
|
||||
|
||||
#### Scenario: matching container parses successfully
|
||||
|
||||
- **WHEN** `plugin.parse("rackspace://assets/img/a.jpg")` is called
|
||||
against a plugin with `container: "assets"`
|
||||
- **THEN** the returned `key === "img/a.jpg"` and `meta.container === "assets"`
|
||||
|
||||
#### Scenario: underscore authority rebinds to configured container
|
||||
|
||||
- **WHEN** `plugin.parse("rackspace://_/img/a.jpg")` is called against
|
||||
the same plugin
|
||||
- **THEN** the returned `key === "img/a.jpg"` and `meta.container === "assets"`
|
||||
|
||||
#### Scenario: mismatched container raises CONTAINER_MISMATCH
|
||||
|
||||
- **WHEN** `plugin.parse("rackspace://wrong/img/a.jpg")` is called
|
||||
- **THEN** the call throws with
|
||||
`code === "STORAGE_CDN_RACKSPACE_URI.CONTAINER_MISMATCH"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"parses matching container"` AND `src/uri-plugin/uri-plugin.test.ts::"underscore rebinds to configured container"` AND `src/uri-plugin/uri-plugin.test.ts::"raises CONTAINER_MISMATCH for wrong container"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Auth-token threading without URI exposure
|
||||
|
||||
The plugin SHALL NOT carry the Rackspace auth token in URIs.
|
||||
Authentication lives entirely on the bound `RackspaceAuthClient` /
|
||||
`RackspaceCdnAdapter`. When the adapter's token has expired and refresh
|
||||
fails, the plugin SHALL re-wrap the failure as
|
||||
`STORAGE_CDN_RACKSPACE_URI.AUTH_TOKEN_EXPIRED` preserving the original
|
||||
on `cause`.
|
||||
|
||||
#### Scenario: token expiry on adapter becomes AUTH_TOKEN_EXPIRED
|
||||
|
||||
- **WHEN** the bound adapter raises a token-expired error during a
|
||||
routed read
|
||||
- **THEN** the resolver's caller sees `StorageCdnRackspaceUriError`
|
||||
with `code === "STORAGE_CDN_RACKSPACE_URI.AUTH_TOKEN_EXPIRED"` and
|
||||
`cause` set
|
||||
|
||||
#### Scenario: no auth token appears in parsed URI
|
||||
|
||||
- **WHEN** `plugin.parse("rackspace://assets/img/a.jpg")` is called
|
||||
- **THEN** the returned `meta` does NOT include `X-Auth-Token` or any
|
||||
`auth` key
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"wraps token expiry as AUTH_TOKEN_EXPIRED"` AND `src/uri-plugin/uri-plugin.test.ts::"never exposes auth token on parsed meta"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TempURL signed-URL verification
|
||||
|
||||
The plugin SHALL verify `?temp_url_sig=<sig>&temp_url_expires=<unix>`
|
||||
query parameters when present, using the existing
|
||||
`buildTempUrlSignature` helper against the configured `tempUrlKey` on
|
||||
the bound adapter. Mismatched signatures SHALL raise
|
||||
`STORAGE_CDN_RACKSPACE_URI.TEMP_URL_INVALID`.
|
||||
|
||||
#### Scenario: valid temp-URL passes verification
|
||||
|
||||
- **WHEN** `plugin.parse("rackspace://assets/x?temp_url_sig=<valid>&temp_url_expires=99")`
|
||||
is called against a plugin whose adapter's `tempUrlKey` produces the
|
||||
same signature
|
||||
- **THEN** the parse succeeds and the resolver routes the read
|
||||
normally
|
||||
|
||||
#### Scenario: invalid temp-URL raises TEMP_URL_INVALID
|
||||
|
||||
- **WHEN** `plugin.parse("rackspace://assets/x?temp_url_sig=bogus&temp_url_expires=99")`
|
||||
is called
|
||||
- **THEN** the call throws with
|
||||
`code === "STORAGE_CDN_RACKSPACE_URI.TEMP_URL_INVALID"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"accepts a valid temp-URL signature"` AND `src/uri-plugin/uri-plugin.test.ts::"raises TEMP_URL_INVALID on bad signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `build(key, meta?)` with optional region echo
|
||||
|
||||
The plugin SHALL implement `build(key, meta?)` returning
|
||||
`rackspace://<container>/<key>`. When `meta?.region` is set, the result
|
||||
SHALL append `?region=<region>` so downstream resolvers can re-bind
|
||||
regions per URI. Unsupported region values SHALL raise
|
||||
`STORAGE_CDN_RACKSPACE_URI.REGION_UNSUPPORTED`.
|
||||
|
||||
#### Scenario: build without region omits query string
|
||||
|
||||
- **WHEN** `plugin.build("img/a.jpg")` is called against a plugin with
|
||||
`container: "assets"`
|
||||
- **THEN** the result is `"rackspace://assets/img/a.jpg"`
|
||||
|
||||
#### Scenario: build with valid region appends query
|
||||
|
||||
- **WHEN** `plugin.build("img/a.jpg", { region: "iad" })` is called
|
||||
- **THEN** the result is `"rackspace://assets/img/a.jpg?region=iad"`
|
||||
|
||||
#### Scenario: unsupported region raises REGION_UNSUPPORTED
|
||||
|
||||
- **WHEN** `plugin.build("x", { region: "mars" })` is called
|
||||
- **THEN** the call throws with
|
||||
`code === "STORAGE_CDN_RACKSPACE_URI.REGION_UNSUPPORTED"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"build omits region when absent"` AND `src/uri-plugin/uri-plugin.test.ts::"build appends valid region"` AND `src/uri-plugin/uri-plugin.test.ts::"raises REGION_UNSUPPORTED for invalid region"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Span-attribute redaction
|
||||
|
||||
The plugin SHALL redact `temp_url_sig`, `temp_url_expires`, and
|
||||
`X-Auth-Token` from any URI surfaced on OTel span attributes or in
|
||||
raised error `.uri` discriminants. Redaction SHALL be exposed as
|
||||
`redactSignedUrl(uri): string`.
|
||||
|
||||
#### Scenario: temp-URL parameters are redacted on span attribute
|
||||
|
||||
- **WHEN** the resolver routes
|
||||
`read("rackspace://assets/x?temp_url_sig=abc&temp_url_expires=99")`
|
||||
and emits a span
|
||||
- **THEN** the `cdn.uri` attribute is
|
||||
`"rackspace://assets/x?temp_url_sig=<redacted>&temp_url_expires=<redacted>"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/redact-signed-url.test.ts::"redacts temp_url and auth-token parameters"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `STORAGE_CDN_RACKSPACE_URI_CODES` registry
|
||||
|
||||
The package SHALL export `STORAGE_CDN_RACKSPACE_URI_CODES` produced by
|
||||
`defineCodes("STORAGE_CDN_RACKSPACE_URI", [...] as const)` covering
|
||||
exactly 6 codes: `INVALID_URI`, `CONTAINER_MISMATCH`,
|
||||
`REGION_UNSUPPORTED`, `TEMP_URL_INVALID`, `AUTH_TOKEN_EXPIRED`,
|
||||
`KEY_TOO_LONG`. Every code SHALL be reachable, and
|
||||
`StorageCdnRackspaceUriError` SHALL extend
|
||||
`SuaveplanError<StorageCdnRackspaceUriErrorCode>` with `uri`/`scheme`
|
||||
discriminants.
|
||||
|
||||
#### Scenario: every code is reachable
|
||||
|
||||
- **WHEN** the test suite exercises every documented failure path
|
||||
- **THEN** every member of `STORAGE_CDN_RACKSPACE_URI_CODES` appears on
|
||||
at least one raised error instance
|
||||
|
||||
**Verified by:** `src/uri-plugin/error-codes.test.ts::"exposes 6 codes all reachable"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal threading
|
||||
|
||||
The plugin SHALL forward `AbortSignal` unchanged to every adapter call.
|
||||
A pre-aborted signal SHALL short-circuit with the adapter's existing
|
||||
`STORAGE_CDN_RACKSPACE.ABORTED` code without invoking the adapter.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits
|
||||
|
||||
- **WHEN** the resolver routes
|
||||
`read("rackspace://assets/x", AbortSignal.abort())`
|
||||
- **THEN** the rejection has
|
||||
`code === "STORAGE_CDN_RACKSPACE.ABORTED"` and the adapter 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, or React.
|
||||
|
||||
#### Scenario: tier-purity grep returns empty
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-cdn-rackspace/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-cdn-rackspace/uri-plugin/uri-plugin")`
|
||||
is executed
|
||||
- **THEN** the module resolves and re-exports `rackspaceUriPlugin`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"is importable via the documented subpath"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance harness pass
|
||||
|
||||
`rackspaceUriPlugin` SHALL pass `runUriPluginConformance` with fixtures
|
||||
covering container-match cases, underscore-rebind cases,
|
||||
container-mismatch failures, temp-URL forms, and a non-empty
|
||||
`buildRoundTrip` set.
|
||||
|
||||
#### Scenario: conformance fixtures pass
|
||||
|
||||
- **WHEN** `runUriPluginConformance(rackspaceUriPlugin(opts), fixtures)`
|
||||
is invoked with the documented fixture set
|
||||
- **THEN** the call returns without throwing
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"passes runUriPluginConformance"`
|
||||
@@ -0,0 +1,231 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-file (complete-storage-uri-adapters)
|
||||
|
||||
**Package**: `@suaveplan/storage-file`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `file://` URI plugin factory
|
||||
|
||||
The package SHALL export `fileUriPlugin(options)` from
|
||||
`./uri-plugin/uri-plugin` returning a `UriPlugin` whose `scheme` is the
|
||||
lowercase string `"file"` and whose `adapter` is the caller-supplied
|
||||
`NodeFsStorage` instance. The factory MUST validate `options.rootDir` is
|
||||
a non-empty absolute path; failure raises
|
||||
`StorageFileUriError(STORAGE_FILE_URI.INVALID_URI)` synchronously.
|
||||
|
||||
#### Scenario: factory returns a UriPlugin with scheme "file"
|
||||
|
||||
- **WHEN** `fileUriPlugin({ adapter, rootDir: "/var/data" })` is called
|
||||
- **THEN** the returned plugin's `scheme === "file"`, `adapter` is the
|
||||
same reference, and `runUriPluginConformance(plugin, fixtures)` passes
|
||||
|
||||
#### Scenario: empty rootDir is rejected
|
||||
|
||||
- **WHEN** `fileUriPlugin({ adapter, rootDir: "" })` is called
|
||||
- **THEN** the call throws `StorageFileUriError` with
|
||||
`code === "STORAGE_FILE_URI.INVALID_URI"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"exports a UriPlugin with scheme file"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects empty rootDir"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RFC 8089 authority handling
|
||||
|
||||
The plugin SHALL accept `file:///...` and `file://localhost/...` as
|
||||
equivalent absolute-path forms. Any other non-empty authority SHALL
|
||||
raise `STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY` so network-file-system
|
||||
URIs (`file://server/share/...`) do not silently degrade to local lookups.
|
||||
|
||||
#### Scenario: empty authority resolves to local path
|
||||
|
||||
- **WHEN** `plugin.parse("file:///etc/hosts")` is called
|
||||
- **THEN** the result has `key` equal to `"etc/hosts"` (or platform
|
||||
equivalent relative to `rootDir`)
|
||||
|
||||
#### Scenario: localhost authority resolves identically to empty
|
||||
|
||||
- **WHEN** `plugin.parse("file://localhost/etc/hosts")` is called
|
||||
- **THEN** the result matches the empty-authority form exactly
|
||||
|
||||
#### Scenario: non-empty non-localhost authority is rejected
|
||||
|
||||
- **WHEN** `plugin.parse("file://nas.example.com/share/x")` is called
|
||||
- **THEN** the call throws `StorageFileUriError` with
|
||||
`code === "STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"resolves empty authority to local path"` AND `src/uri-plugin/uri-plugin.test.ts::"treats localhost authority as local"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects non-empty non-localhost authority"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Windows drive-letter preservation
|
||||
|
||||
The plugin SHALL detect Windows drive-letter form
|
||||
`file:///<drive>:/path` (the leading `/` followed by `<ALPHA>:`) and
|
||||
preserve the drive letter through `parse` and `build`. Malformed
|
||||
drive-letter forms (e.g. `file:///C/path` or `file:///CC:/path`) SHALL
|
||||
raise `STORAGE_FILE_URI.WINDOWS_DRIVE_INVALID`.
|
||||
|
||||
#### Scenario: well-formed drive-letter parses
|
||||
|
||||
- **WHEN** `plugin.parse("file:///C:/Users/x.txt")` is called
|
||||
- **THEN** the parsed key preserves `C:/Users/x.txt` (verbatim, no
|
||||
percent-encoding of the colon)
|
||||
|
||||
#### Scenario: malformed drive-letter is rejected
|
||||
|
||||
- **WHEN** `plugin.parse("file:///CC:/x")` is called
|
||||
- **THEN** the call throws `StorageFileUriError` with
|
||||
`code === "STORAGE_FILE_URI.WINDOWS_DRIVE_INVALID"`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"preserves Windows drive letters"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects malformed drive-letter forms"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `..` traversal rejection
|
||||
|
||||
The plugin SHALL reject any URI whose decoded path resolves outside
|
||||
`rootDir` after `..` normalisation. Detection runs BEFORE the call
|
||||
reaches the underlying adapter.
|
||||
|
||||
#### Scenario: parent-directory escape is rejected
|
||||
|
||||
- **WHEN** `plugin.parse("file:///../../etc/passwd")` is called against
|
||||
a plugin with `rootDir: "/var/data"`
|
||||
- **THEN** the call throws `StorageFileUriError` with
|
||||
`code === "STORAGE_FILE_URI.PATH_ESCAPE"`
|
||||
|
||||
#### Scenario: same-directory descent succeeds
|
||||
|
||||
- **WHEN** `plugin.parse("file:///a/b/../c.txt")` is called with the
|
||||
same rootDir
|
||||
- **THEN** the parse succeeds with the normalised key `a/c.txt`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"rejects path traversal escapes"` AND `src/uri-plugin/uri-plugin.test.ts::"normalises in-tree traversal"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Permission-denied passthrough
|
||||
|
||||
The plugin SHALL re-wrap permission-denied failures raised by the bound
|
||||
`NodeFsStorage` (`STORAGE_FILE.PERMISSION_DENIED` from `EACCES`/`EPERM`) as
|
||||
`StorageFileUriError(STORAGE_FILE_URI.PERMISSION_DENIED)` and MUST
|
||||
preserve the original on `cause`.
|
||||
|
||||
#### Scenario: adapter EACCES becomes STORAGE_FILE_URI.PERMISSION_DENIED
|
||||
|
||||
- **WHEN** the resolver routes `read("file:///protected/x")` and the
|
||||
underlying `adapter.get` throws `STORAGE_FILE.PERMISSION_DENIED`
|
||||
- **THEN** the rejection is `StorageFileUriError` with
|
||||
`code === "STORAGE_FILE_URI.PERMISSION_DENIED"` and `cause` set to the
|
||||
original `StorageFileError`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"wraps EACCES as STORAGE_FILE_URI.PERMISSION_DENIED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `build(key)` inverse with percent-encoding
|
||||
|
||||
The plugin SHALL implement `build(key)` returning a `file://`-scheme URI
|
||||
that round-trips through `parse` for every key produced by the bound
|
||||
adapter's `list` operation. Path segments SHALL be percent-encoded per
|
||||
RFC 3986 §3.3; Windows drive letters SHALL be preserved verbatim.
|
||||
|
||||
#### Scenario: POSIX key round-trips
|
||||
|
||||
- **WHEN** `plugin.build("a/b%file.txt")` is called
|
||||
- **THEN** the result `parse`s back to a `ParsedUri` whose `key === "a/b%file.txt"`
|
||||
|
||||
#### Scenario: Windows key round-trips with drive letter
|
||||
|
||||
- **WHEN** `plugin.build("C:/Users/x.txt")` is called
|
||||
- **THEN** the result is `"file:///C:/Users/x.txt"` and `parse`s back
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"build round-trips POSIX keys"` AND `src/uri-plugin/uri-plugin.test.ts::"build round-trips Windows keys"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `STORAGE_FILE_URI_CODES` registry
|
||||
|
||||
The package SHALL export `STORAGE_FILE_URI_CODES` produced by
|
||||
`defineCodes("STORAGE_FILE_URI", [...] as const)` covering exactly 6
|
||||
codes: `INVALID_URI`, `UNSUPPORTED_AUTHORITY`, `PATH_ESCAPE`,
|
||||
`PERMISSION_DENIED`, `WINDOWS_DRIVE_INVALID`, `KEY_TOO_LONG`. Every
|
||||
code SHALL be reachable from a real call site exercised by the test
|
||||
suite, and the structured error class `StorageFileUriError` SHALL extend
|
||||
`SuaveplanError<StorageFileUriErrorCode>` carrying optional
|
||||
`uri`/`scheme` discriminants.
|
||||
|
||||
#### Scenario: every code is reachable
|
||||
|
||||
- **WHEN** the test suite exercises every documented failure path
|
||||
- **THEN** every member of `STORAGE_FILE_URI_CODES` appears on at least
|
||||
one raised `StorageFileUriError` instance
|
||||
|
||||
#### Scenario: error preserves cause
|
||||
|
||||
- **WHEN** the plugin re-wraps an adapter `StorageFileError`
|
||||
- **THEN** the resulting `StorageFileUriError.cause` is the original
|
||||
adapter error instance
|
||||
|
||||
**Verified by:** `src/uri-plugin/error-codes.test.ts::"exposes 6 codes all reachable"` AND `src/uri-plugin/error-codes.test.ts::"preserves cause when re-wrapping"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal threading
|
||||
|
||||
The plugin SHALL forward an `AbortSignal` (received via the
|
||||
resolver's options bag) unchanged to every adapter call. A pre-aborted
|
||||
signal SHALL short-circuit with `STORAGE_FILE.ABORTED` (the adapter's
|
||||
existing code) without invoking the adapter's I/O methods.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits
|
||||
|
||||
- **WHEN** the resolver routes `read("file:///x", AbortSignal.abort())`
|
||||
- **THEN** the rejection has `code === "STORAGE_FILE.ABORTED"` and the
|
||||
adapter's `get` is NEVER invoked
|
||||
|
||||
#### Scenario: live signal is forwarded
|
||||
|
||||
- **WHEN** `read("file:///x", signal)` is called with a live signal
|
||||
- **THEN** the adapter receives the same signal reference
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"short-circuits on pre-aborted signal"` AND `src/uri-plugin/uri-plugin.test.ts::"forwards live signal to adapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL declare subpath exports for
|
||||
`"./uri-plugin/uri-plugin"` and `"./uri-plugin/error-codes"` in
|
||||
`package.json` so consumers can tree-shake the URI plugin independently.
|
||||
|
||||
#### Scenario: subpath imports resolve
|
||||
|
||||
- **WHEN** `import("@suaveplan/storage-file/uri-plugin/uri-plugin")` is
|
||||
executed
|
||||
- **THEN** the module resolves and re-exports `fileUriPlugin`
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"is importable via the documented subpath"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance harness pass
|
||||
|
||||
`fileUriPlugin` SHALL pass `runUriPluginConformance` from
|
||||
`@suaveplan/storage-uri/conformance/conformance` with fixtures covering
|
||||
POSIX paths, Windows drive-letter paths, `localhost` authority,
|
||||
malformed inputs (empty path, non-localhost authority, traversal
|
||||
escape), and a non-empty `buildRoundTrip` set.
|
||||
|
||||
#### Scenario: conformance fixtures pass
|
||||
|
||||
- **WHEN** `runUriPluginConformance(fileUriPlugin(opts), fixtures)` runs
|
||||
with the documented fixture set
|
||||
- **THEN** the call returns without throwing
|
||||
|
||||
**Verified by:** `src/uri-plugin/uri-plugin.test.ts::"passes runUriPluginConformance"`
|
||||
@@ -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"`
|
||||
199
openspec/changes/complete-storage-uri-adapters/tasks.md
Normal file
199
openspec/changes/complete-storage-uri-adapters/tasks.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Tasks — complete-storage-uri-adapters
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [ ] 0.1 Read `openspec/AGENTS.md` §0c, §13, §14
|
||||
- [ ] 0.2 Read `openspec/archive/2026-05-16-add-storage-uri/proposal.md`,
|
||||
`tasks.md`, and `specs/storage-uri/spec.md` — confirm the
|
||||
`UriPlugin` contract and the `runUriPluginConformance` harness
|
||||
requirements
|
||||
- [ ] 0.3 Read `packages/storage/storage-uri/src/types/plugin.types.ts`
|
||||
(`UriPlugin`, `ParsedUri`, `StreamingStorageExtensions`,
|
||||
`StatResult`) and
|
||||
`packages/storage/storage-uri/src/conformance/conformance.ts`
|
||||
(`runUriPluginConformance`, `UriPluginFixtures`) verbatim
|
||||
- [ ] 0.4 Read each of the four adapter packages' `index.ts` to map the
|
||||
exported surface the plugin must wrap
|
||||
- [ ] 0.5 Confirm no adapter already ships a `uri-plugin` (verified by
|
||||
`find packages/storage/storage-{file,http,cdn-bunnycdn,cdn-rackspace} -name 'uri-plugin*'`
|
||||
returning empty)
|
||||
- [ ] 0.6 Build the numbered Requirement/Scenario implementation
|
||||
checklist (per §0c step 6) — one per adapter, plus shared
|
||||
scaffolding row
|
||||
|
||||
## 1. Shared scaffolding
|
||||
|
||||
- [ ] 1.1 Confirm `@suaveplan/storage-uri` is on
|
||||
`workspace:^` in each of the four adapter packages' `devDependencies`
|
||||
(for the conformance test only — no runtime dep)
|
||||
- [ ] 1.2 Add a co-located `redact-signed-url/redact-signed-url.ts`
|
||||
helper to `@suaveplan/storage-http` (used by URI redaction policy)
|
||||
and re-use the same module from `bunnycdn` and `rackspace` via
|
||||
adapter-local mirrors (do NOT cross-import — each package keeps its
|
||||
own redaction list per scheme)
|
||||
|
||||
## 2. `@suaveplan/storage-file` — `fileUriPlugin`
|
||||
|
||||
- [ ] 2.1 `src/uri-plugin/uri-plugin.ts` — `fileUriPlugin(options)`
|
||||
factory; scheme `"file"`; `parse(uri)` with RFC 8089 authority
|
||||
handling, Windows drive-letter detection
|
||||
(`/^[A-Za-z]:/` after stripping leading `/`), and `..` traversal
|
||||
rejection against `rootDir`
|
||||
- [ ] 2.2 `src/uri-plugin/uri-plugin.ts` — `build(key)` inverse with
|
||||
RFC 3986 segment-percent-encoding and Windows drive-letter
|
||||
preservation
|
||||
- [ ] 2.3 `src/uri-plugin/error-codes.ts` —
|
||||
`defineCodes("STORAGE_FILE_URI", [...])` with the 6 codes;
|
||||
`StorageFileUriError extends SuaveplanError`
|
||||
- [ ] 2.4 `src/uri-plugin/uri-plugin.md` (≥200 words, basic + advanced)
|
||||
- [ ] 2.5 `src/uri-plugin/error-codes.md`
|
||||
- [ ] 2.6 `src/uri-plugin/uri-plugin.test.ts` — POSIX paths,
|
||||
Windows drive-letter, relative-vs-absolute, `..` escape,
|
||||
permission-denied wrap, conformance via `runUriPluginConformance`
|
||||
with `validUris` (POSIX + Windows + `file://localhost/...`),
|
||||
`invalidUris` (`file:`, `file://host/x`, `file:///../../etc/passwd`),
|
||||
and `buildRoundTrip` pairs
|
||||
- [ ] 2.7 Add `"./uri-plugin/uri-plugin"` subpath export +
|
||||
`"./uri-plugin/error-codes"` to `package.json`
|
||||
- [ ] 2.8 Add named re-exports in `src/index.ts` (explicit — no
|
||||
`export *`, per CLAUDE.md rule 10)
|
||||
- [ ] 2.9 Update `README.md` `## Modules` with the new entries
|
||||
|
||||
## 3. `@suaveplan/storage-http` — `httpUriPlugin` + `httpsUriPlugin`
|
||||
|
||||
- [ ] 3.1 `src/uri-plugin/uri-plugin.ts` — internal `buildHttpUriPlugin`
|
||||
builder; `httpUriPlugin(options)` and `httpsUriPlugin(options)`
|
||||
factories differing only in `scheme`
|
||||
- [ ] 3.2 `src/uri-plugin/uri-plugin.ts` — full RFC 3986 parse via
|
||||
`parseUri`, key = `path + (query ? "?" + query : "")` so presigned
|
||||
query strings survive
|
||||
- [ ] 3.3 `src/uri-plugin/uri-plugin.ts` — range parsing via
|
||||
`?range=bytes=<offset>-<end>` query parameter; on parse failure
|
||||
raise `STORAGE_HTTP_URI.RANGE_INVALID`; on success route to
|
||||
`adapter.getPartial(key, { offset, length })`
|
||||
- [ ] 3.4 `src/uri-plugin/uri-plugin.ts` — redirect-loop classification
|
||||
(re-wrap `STORAGE_HTTP.REDIRECT_LOOP` if/when adapter raises;
|
||||
fallback maps any `Response.status === 310` or fetch loop signal)
|
||||
- [ ] 3.5 `src/uri-plugin/uri-plugin.ts` — `build(key, meta?)` inverse
|
||||
via `new URL(key, adapter.options.baseUrl)` with `scheme`
|
||||
substitution for the `http`/`https` factory split
|
||||
- [ ] 3.6 `src/uri-plugin/redact-signed-url.ts` — case-insensitive
|
||||
deny-list strip; exported for reuse and unit-tested
|
||||
- [ ] 3.7 `src/uri-plugin/error-codes.ts` —
|
||||
`defineCodes("STORAGE_HTTP_URI", [...])` with 6 codes;
|
||||
`StorageHttpUriError`
|
||||
- [ ] 3.8 `src/uri-plugin/uri-plugin.md`, `redact-signed-url.md`,
|
||||
`error-codes.md`
|
||||
- [ ] 3.9 `src/uri-plugin/uri-plugin.test.ts` — happy `http://` +
|
||||
`https://` round-trips, query-string preservation, range parsing
|
||||
success + failure, redirect loop wrap, redaction unit tests, full
|
||||
`runUriPluginConformance` per factory
|
||||
- [ ] 3.10 Subpath exports in `package.json` for the three new files
|
||||
- [ ] 3.11 Named re-exports in `src/index.ts`
|
||||
- [ ] 3.12 README modules update
|
||||
|
||||
## 4. `@suaveplan/storage-cdn-bunnycdn` — `bunnyUriPlugin`
|
||||
|
||||
- [ ] 4.1 `src/uri-plugin/uri-plugin.ts` — `bunnyUriPlugin(options)`
|
||||
factory; ONE plugin registered against TWO schemes
|
||||
(`bunnycdn` and `bunnycdn+pull`) via the same factory returning
|
||||
one of two `UriPlugin` instances (the resolver registers each
|
||||
explicitly)
|
||||
- [ ] 4.2 `src/uri-plugin/uri-plugin.ts` — storage-zone parse:
|
||||
`bunnycdn://<zone>/<path>`, zone mismatch raises
|
||||
`STORAGE_CDN_BUNNY_URI.ZONE_MISMATCH`
|
||||
- [ ] 4.3 `src/uri-plugin/uri-plugin.ts` — pull-zone parse:
|
||||
`bunnycdn+pull://<pull-zone>/<path>`; `set`/`delete` reject with
|
||||
`UNSUPPORTED_OPERATION`
|
||||
- [ ] 4.4 `src/uri-plugin/uri-plugin.ts` — signed-URL handling: parse
|
||||
`?token=...&expires=...`, forward to existing
|
||||
`buildTokenAuthQuery` helper. Token bytes NEVER persisted on
|
||||
`meta`
|
||||
- [ ] 4.5 `src/uri-plugin/uri-plugin.ts` — `build(key, meta?)` per
|
||||
surface (`storage` vs `pull` based on `meta.surface`)
|
||||
- [ ] 4.6 `src/uri-plugin/redact-signed-url.ts` — strips
|
||||
`token`, `signature`, `expires`
|
||||
- [ ] 4.7 `src/uri-plugin/error-codes.ts` —
|
||||
`defineCodes("STORAGE_CDN_BUNNY_URI", [...])` with 6 codes
|
||||
- [ ] 4.8 Co-located `.md`s for every implementation file
|
||||
- [ ] 4.9 `src/uri-plugin/uri-plugin.test.ts` — zone match/mismatch,
|
||||
pull-zone read-only enforcement, signed-URL passthrough,
|
||||
redaction, full conformance harness
|
||||
- [ ] 4.10 Subpath exports + named re-exports + README
|
||||
|
||||
## 5. `@suaveplan/storage-cdn-rackspace` — `rackspaceUriPlugin`
|
||||
|
||||
- [ ] 5.1 `src/uri-plugin/uri-plugin.ts` — `rackspaceUriPlugin(options)`
|
||||
factory; scheme `"rackspace"`; authority = container,
|
||||
path = object key
|
||||
- [ ] 5.2 `src/uri-plugin/uri-plugin.ts` — authority `_` or empty
|
||||
rebinds to configured container; mismatched container raises
|
||||
`CONTAINER_MISMATCH`
|
||||
- [ ] 5.3 `src/uri-plugin/uri-plugin.ts` — temp-URL signed-URL
|
||||
verification via existing `buildTempUrlSignature`, on mismatch
|
||||
raise `TEMP_URL_INVALID`
|
||||
- [ ] 5.4 `src/uri-plugin/uri-plugin.ts` — `build(key, meta?)` with
|
||||
optional `?region=<region>` echo
|
||||
- [ ] 5.5 `src/uri-plugin/redact-signed-url.ts` — strips
|
||||
`temp_url_sig`, `temp_url_expires`, `X-Auth-Token`
|
||||
- [ ] 5.6 `src/uri-plugin/error-codes.ts` —
|
||||
`defineCodes("STORAGE_CDN_RACKSPACE_URI", [...])` with 6 codes
|
||||
- [ ] 5.7 Co-located `.md`s
|
||||
- [ ] 5.8 `src/uri-plugin/uri-plugin.test.ts` — container match/mismatch,
|
||||
auth-token threading, temp-URL valid/invalid, region echo,
|
||||
redaction, conformance
|
||||
- [ ] 5.9 Subpath exports + named re-exports + README
|
||||
|
||||
## 6. Cross-package conformance
|
||||
|
||||
- [ ] 6.1 Each adapter's `uri-plugin.test.ts` MUST import
|
||||
`runUriPluginConformance` from `@suaveplan/storage-uri/conformance/conformance`
|
||||
and call it with a fixture set covering valid/invalid/round-trip
|
||||
- [ ] 6.2 Add a cross-scheme integration test in
|
||||
`packages/storage/storage-uri/tests/integration/all-four-schemes.test.ts`
|
||||
composing all four plugins behind one resolver and asserting
|
||||
end-to-end dispatch
|
||||
- [ ] 6.3 Tier purity:
|
||||
`grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-cdn-*/src/uri-plugin/`
|
||||
returns empty (CDN plugins are universal-tier);
|
||||
`storage-file/src/uri-plugin/` MAY use `node:` (server-tier
|
||||
adapter); `storage-http/src/uri-plugin/` MUST be universal
|
||||
- [ ] 6.4 Test-runner imports — every new `.test.ts` imports from
|
||||
`@suaveplan/testing/runner` only (rule 11)
|
||||
|
||||
## 7. Gates (per adapter package)
|
||||
|
||||
For each of `storage-file`, `storage-http`, `storage-cdn-bunnycdn`,
|
||||
`storage-cdn-rackspace`:
|
||||
|
||||
- [ ] 7.1 `bun run check`
|
||||
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/<pkg>`
|
||||
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/<pkg>`
|
||||
(100/100/100/100 istanbul)
|
||||
- [ ] 7.4 Banned-pattern scan returns zero
|
||||
- [ ] 7.5 Missing-`.md` scan returns zero
|
||||
- [ ] 7.6 Subpath export count matches feature module count
|
||||
- [ ] 7.7 `bun scripts/openspec-conformance.ts @suaveplan/<pkg>`
|
||||
- [ ] 7.8 Dependent sweep —
|
||||
`bunx turbo test --filter=...@suaveplan/<pkg>` green
|
||||
- [ ] 7.9 Cross-package: full
|
||||
`bunx turbo test --filter=@suaveplan/storage-uri` green after the
|
||||
four plugins land
|
||||
|
||||
## 8. Manifest & archival
|
||||
|
||||
- [ ] 8.1 `bun scripts/folder-reorg/generate-manifest.ts` to refresh
|
||||
`MANIFEST.md` (export-graph change)
|
||||
- [ ] 8.2 `bunx markdownlint-cli2 --fix` on touched files
|
||||
- [ ] 8.3 Fold each `specs/<adapter>/spec.md` into
|
||||
`openspec/specs/<adapter>/spec.md` (one per adapter)
|
||||
- [ ] 8.4 Move `openspec/changes/complete-storage-uri-adapters/` →
|
||||
`openspec/archive/<yyyy-mm-dd>-complete-storage-uri-adapters/`
|
||||
Reference in New Issue
Block a user