# 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__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=-` 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:///` for the storage-zone (write) surface; pull-zone reads route through `bunnycdn+pull:///` (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=&expires=` 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:///` or `bunnycdn+pull:///` 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:///`. 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=&temp_url_expires=`, 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:///`; `meta.region` (when set) is encoded as `?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/` 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.