14 KiB
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(archivedopenspec/archive/2026-05-16-add-storage-uri/) - Sibling in-flight:
complete-storage-s3ships thes3UriPlugin— 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.tsexportingfileUriPlugin(options)whereoptions: { adapter: NodeFsStorage; rootDir: string }. Plugin parsesfile:///abs/path,file://localhost/abs/path, andfile:///C:/Windows/Path(Windows drive-letter form) per RFC 8089. Empty /localhostauthorities are normalised to the local filesystem. Non-empty non-localhostauthorities raiseSTORAGE_FILE_URI.UNSUPPORTED_AUTHORITY(no network mounts). - ADDED:
build(key)inverse that re-emitsfile:///${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
rootDirvia..traversal — raised asSTORAGE_FILE_URI.PATH_ESCAPE. - ADDED: permission-denied passthrough —
EACCES/EPERMfromadapterre-classified asSTORAGE_FILE_URI.PERMISSION_DENIEDso URI consumers can branch without depending onSTORAGE_FILEdirectly. - ADDED:
STORAGE_FILE_URI_CODESviadefineCodes("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(usesrunUriPluginConformanceplus cross-platform path fixtures). - ADDED: subpath export
"./uri-plugin/uri-plugin"inpackage.json.
@suaveplan/storage-http — http:// + https:// URI plugins
- ADDED:
src/uri-plugin/uri-plugin.tsexportinghttpUriPlugin(options)ANDhttpsUriPlugin(options)whereoptions: { adapter: HttpStorage }. The two factories share an internal builder; the only difference is the registeredschemefield. - ADDED: URI grammar: full RFC 3986 hierarchy
https://user:pass@host:port/path?query#fragment. The plugin parses authority + path + query and forwards them toHttpStoragevia thecomposeUrlhelper. 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 toadapter.get. Range parsing is REJECTED withSTORAGE_HTTP_URI.RANGE_INVALIDfor malformed forms. - ADDED: header injection — the plugin reads
HttpStorage.options.headersvia 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 throughadapter.options.headers). - ADDED: redirect policy — the adapter's
FetchLikeis responsible for redirects; the plugin documents that opaque redirects expose the final URL via theResponse.urlreturned toHttpStorageand surfaces aSTORAGE_HTTP_URI.REDIRECT_LOOPwhen the adapter raises one. - ADDED:
build(key, meta?)inverse — re-emits the canonical URL with the scheme set per factory. Authority/path encoding handled vianew 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 asredactSignedUrl(uri); exported for reuse. - ADDED:
STORAGE_HTTP_URI_CODESviadefineCodes("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.tsexportingbunnyUriPlugin(options)whereoptions: { adapter: BunnyCdnAdapter; storageZone: string; pullZoneHostname?: string }. - ADDED: URI grammar:
bunnycdn://<storage-zone>/<path>for the storage-zone (write) surface; pull-zone reads route throughbunnycdn+pull://<pull-zone>/<path>(a sibling sub-scheme registered under the same plugin). The plugin discriminates on the second sub-form viascheme === "bunnycdn+pull"; storage-zone authority is normalised against the configuredstorageZoneand a mismatch raisesSTORAGE_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 existingbuildTokenAuthQueryhelper. The token bytes are NEVER persisted onParsedUri.meta; they live only on the ephemeral fetch path. - ADDED:
build(key, meta?)inverse — emitsbunnycdn://<storage-zone>/<key>orbunnycdn+pull://<pull-zone>/<key>based onmeta.surface ∈ {"storage", "pull"}(defaults to"storage"). - ADDED: redaction policy — span
cdn.uriattribute stripstoken,signature,expiresquery parameters; only path + zone attribute survive. - ADDED:
STORAGE_CDN_BUNNY_URI_CODESviadefineCodes("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 isset/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.tsexportingrackspaceUriPlugin(options)whereoptions: { 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 configuredcontainer. - ADDED: auth-token threading — the plugin does NOT carry the auth
token in URIs (auth lives on
RackspaceAuthClient). It threads the ambient signal intoRackspaceCdnAdaptercalls and propagatesRackspaceCdnAdapter'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 tobuildTempUrlSignaturefor verification; on mismatch raisesSTORAGE_CDN_RACKSPACE_URI.TEMP_URL_INVALID. - ADDED:
build(key, meta?)inverse — emitsrackspace://<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_CODESviadefineCodes("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
parseUrihelper from@suaveplan/storage-urifor RFC 3986 split; per-scheme validation layers on top. - Each plugin THREADS
AbortSignalthrough every delegated adapter call. Pre-aborted signals raise the per-package*.ABORTED-equivalent (each adapter already has anABORTEDcode; the URI plugin re-uses it rather than adding a duplicate). - Each plugin passes
runUriPluginConformancewith valid + invalid + buildRoundTrip fixtures. - Each plugin gets sibling
.mdper CLAUDE.md §4 and an updatedREADME.md## Moduleslink.
Out of scope
@suaveplan/storage-s3s3UriPlugin— covered by the in-flightcomplete-storage-s3change 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—UriPlugincontract.packages/storage/storage-uri/src/conformance/conformance.ts:39-115—runUriPluginConformanceharness.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—composeUrlhelper consumed.packages/storage/storage-cdn-bunnycdn/src/token-auth/token-auth.ts—buildTokenAuthQueryconsumed for signed-URL paths.packages/storage/storage-cdn-rackspace/src/temp-url/temp-url.ts—buildTempUrlSignatureconsumed 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 existingUriPlugininterface; the cross-scheme integration tests underpackages/storage/storage-uri/tests/integration/gain the four new schemes).@suaveplan/storage-fileconsumers — [ ] tests run / [ ] docs reviewed / [x] no action needed (purely additivefileUriPluginexport).@suaveplan/storage-httpconsumers — [ ] tests run / [ ] docs reviewed / [x] no action needed (purely additivehttpUriPlugin/httpsUriPluginexports).@suaveplan/storage-cdn-bunnycdnconsumers — [ ] tests run / [ ] docs reviewed / [x] no action needed.@suaveplan/storage-cdn-rackspaceconsumers — [ ] 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:
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.