Files
suaveplan/openspec/changes/complete-storage-uri-adapters/proposal.md
2026-05-27 23:29:53 +01:00

14 KiB
Raw Blame History

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:

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.