Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View 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.

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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"`

View 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/`