Initial Commit
This commit is contained in:
273
openspec/changes/complete-storage-uri-adapters/proposal.md
Normal file
273
openspec/changes/complete-storage-uri-adapters/proposal.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Complete `uriPlugin` exports on four storage adapter packages
|
||||
|
||||
- **Change ID**: complete-storage-uri-adapters
|
||||
- **Packages**: `@suaveplan/storage-file`, `@suaveplan/storage-http`, `@suaveplan/storage-cdn-bunnycdn`, `@suaveplan/storage-cdn-rackspace` (modify — additive)
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-17
|
||||
- **Type**: capability-completion (multi-package, one change)
|
||||
- **Design**: ./design.md
|
||||
- **Spec deltas**:
|
||||
- ./specs/storage-file/spec.md
|
||||
- ./specs/storage-http/spec.md
|
||||
- ./specs/storage-cdn-bunnycdn/spec.md
|
||||
- ./specs/storage-cdn-rackspace/spec.md
|
||||
- **Depends on**: `add-storage-uri` (archived `openspec/archive/2026-05-16-add-storage-uri/`)
|
||||
- **Sibling in-flight**: `complete-storage-s3` ships the `s3UriPlugin` — explicitly out of scope here.
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/storage-uri` shipped on 2026-05-16 as a scheme-routed dispatch
|
||||
layer over `StorageLike` adapters. Its contract requires every adapter
|
||||
package to contribute a `UriPlugin` so the resolver stays scheme-agnostic.
|
||||
The original change's task list (`openspec/archive/2026-05-16-add-storage-uri/tasks.md`
|
||||
§5) enumerated the per-adapter plugin work; the file-, http-, bunnycdn-,
|
||||
and rackspace adapters never shipped theirs. Today consumers cannot
|
||||
register any of these four schemes with a `StorageUriResolver` without
|
||||
hand-rolling a plugin per adapter — defeating the routing layer's purpose.
|
||||
|
||||
A single consolidated change covers all four because the pattern is
|
||||
identical: parse the scheme's URI grammar into `{ key, meta? }`, bind to
|
||||
the adapter's existing read/write/delete/stat/list/exists ops, redact
|
||||
sensitive query parameters in span attributes, thread `AbortSignal`, raise
|
||||
a per-adapter `STORAGE_<NAME>_URI.*` code registry, and pass
|
||||
`runUriPluginConformance`. Four separate changes would multiply
|
||||
ceremony — four proposals, four design docs, four archive cycles — for
|
||||
work that shares a verbatim integration playbook. `storage-s3`'s plugin
|
||||
is *excluded* and lives in `complete-storage-s3` because the S3 surface
|
||||
needs presigned-URL, SSE, and multipart wiring that none of these four
|
||||
schemes share.
|
||||
|
||||
## What changes
|
||||
|
||||
### `@suaveplan/storage-file` — `file://` URI plugin
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `fileUriPlugin(options)`
|
||||
where `options: { adapter: NodeFsStorage; rootDir: string }`. Plugin
|
||||
parses `file:///abs/path`, `file://localhost/abs/path`,
|
||||
and `file:///C:/Windows/Path` (Windows drive-letter form) per RFC 8089.
|
||||
Empty / `localhost` authorities are normalised to the local filesystem.
|
||||
Non-empty non-`localhost` authorities raise
|
||||
`STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY` (no network mounts).
|
||||
- ADDED: `build(key)` inverse that re-emits `file:///${rootDir}/${key}`
|
||||
with proper percent-encoding (RFC 3986 path segments) and Windows
|
||||
drive-letter preservation.
|
||||
- ADDED: relative-vs-absolute resolution: the plugin REJECTS any URI
|
||||
whose decoded path escapes `rootDir` via `..` traversal — raised as
|
||||
`STORAGE_FILE_URI.PATH_ESCAPE`.
|
||||
- ADDED: permission-denied passthrough — `EACCES` / `EPERM` from
|
||||
`adapter` re-classified as `STORAGE_FILE_URI.PERMISSION_DENIED` so URI
|
||||
consumers can branch without depending on `STORAGE_FILE` directly.
|
||||
- ADDED: `STORAGE_FILE_URI_CODES` via `defineCodes("STORAGE_FILE_URI", ...)`
|
||||
— 6 codes: `INVALID_URI`, `UNSUPPORTED_AUTHORITY`, `PATH_ESCAPE`,
|
||||
`PERMISSION_DENIED`, `WINDOWS_DRIVE_INVALID`, `KEY_TOO_LONG`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts` (uses
|
||||
`runUriPluginConformance` plus cross-platform path fixtures).
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"` in `package.json`.
|
||||
|
||||
### `@suaveplan/storage-http` — `http://` + `https://` URI plugins
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `httpUriPlugin(options)`
|
||||
AND `httpsUriPlugin(options)` where `options: { adapter: HttpStorage }`.
|
||||
The two factories share an internal builder; the only difference is the
|
||||
registered `scheme` field.
|
||||
- ADDED: URI grammar: full RFC 3986 hierarchy
|
||||
`https://user:pass@host:port/path?query#fragment`. The plugin parses
|
||||
authority + path + query and forwards them to `HttpStorage` via the
|
||||
`composeUrl` helper. Query strings reach the adapter as part of the key
|
||||
to preserve presigned-URL `?X-Amz-Signature=...` semantics.
|
||||
- ADDED: range-request support — `getPartial(key, { offset, length })`
|
||||
reachable via the URI plugin when the URI bears an explicit
|
||||
`?range=bytes=<offset>-<end>` meta channel; otherwise the plugin
|
||||
passes through to `adapter.get`. Range parsing is REJECTED with
|
||||
`STORAGE_HTTP_URI.RANGE_INVALID` for malformed forms.
|
||||
- ADDED: header injection — the plugin reads
|
||||
`HttpStorage.options.headers` via dependency injection, but URI
|
||||
consumers MAY override per-request headers through the resolver's
|
||||
AbortSignal-bearing options bag (no new resolver surface; the plugin
|
||||
routes them through `adapter.options.headers`).
|
||||
- ADDED: redirect policy — the adapter's `FetchLike` is responsible for
|
||||
redirects; the plugin documents that opaque redirects expose the final
|
||||
URL via the `Response.url` returned to `HttpStorage` and surfaces a
|
||||
`STORAGE_HTTP_URI.REDIRECT_LOOP` when the adapter raises one.
|
||||
- ADDED: `build(key, meta?)` inverse — re-emits the canonical URL with
|
||||
the scheme set per factory. Authority/path encoding handled via
|
||||
`new URL(key, baseUrl)`.
|
||||
- ADDED: query-string redaction policy — span attributes drop any
|
||||
query parameter whose key matches the case-insensitive deny-list
|
||||
`X-Amz-Signature`, `X-Amz-Security-Token`, `token`, `signature`,
|
||||
`sig`, `key`, `auth`, `password`. Redaction implemented as
|
||||
`redactSignedUrl(uri)`; exported for reuse.
|
||||
- ADDED: `STORAGE_HTTP_URI_CODES` via
|
||||
`defineCodes("STORAGE_HTTP_URI", ...)` — 6 codes: `INVALID_URI`,
|
||||
`UNSUPPORTED_SCHEME`, `RANGE_INVALID`, `REDIRECT_LOOP`, `HOST_MISMATCH`,
|
||||
`KEY_TOO_LONG`.
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts`.
|
||||
|
||||
### `@suaveplan/storage-cdn-bunnycdn` — `bunnycdn://` URI plugin
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `bunnyUriPlugin(options)`
|
||||
where `options: { adapter: BunnyCdnAdapter; storageZone: string; pullZoneHostname?: string }`.
|
||||
- ADDED: URI grammar: `bunnycdn://<storage-zone>/<path>` for the
|
||||
storage-zone (write) surface; pull-zone reads route through
|
||||
`bunnycdn+pull://<pull-zone>/<path>` (a sibling sub-scheme registered
|
||||
under the same plugin). The plugin discriminates on the second sub-form
|
||||
via `scheme === "bunnycdn+pull"`; storage-zone authority is normalised
|
||||
against the configured `storageZone` and a mismatch raises
|
||||
`STORAGE_CDN_BUNNY_URI.ZONE_MISMATCH`.
|
||||
- ADDED: signed-URL support — when the URI carries
|
||||
`?token=<bunny-token>&expires=<unix>` query parameters, the plugin
|
||||
consumes them via the existing `buildTokenAuthQuery` helper. The token
|
||||
bytes are NEVER persisted on `ParsedUri.meta`; they live only on the
|
||||
ephemeral fetch path.
|
||||
- ADDED: `build(key, meta?)` inverse — emits
|
||||
`bunnycdn://<storage-zone>/<key>` or
|
||||
`bunnycdn+pull://<pull-zone>/<key>` based on `meta.surface ∈ {"storage", "pull"}`
|
||||
(defaults to `"storage"`).
|
||||
- ADDED: redaction policy — span `cdn.uri` attribute strips
|
||||
`token`, `signature`, `expires` query parameters; only path + zone
|
||||
attribute survive.
|
||||
- ADDED: `STORAGE_CDN_BUNNY_URI_CODES` via
|
||||
`defineCodes("STORAGE_CDN_BUNNY_URI", ...)` — 6 codes:
|
||||
`INVALID_URI`, `ZONE_MISMATCH`, `PULL_ZONE_NOT_CONFIGURED`,
|
||||
`TOKEN_INVALID`, `KEY_TOO_LONG`, `UNSUPPORTED_OPERATION`
|
||||
(raised when a pull-zone URI is `set`/`delete`'d — pull zones are
|
||||
read-only).
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts`.
|
||||
|
||||
### `@suaveplan/storage-cdn-rackspace` — `rackspace://` URI plugin
|
||||
|
||||
- ADDED: `src/uri-plugin/uri-plugin.ts` exporting `rackspaceUriPlugin(options)`
|
||||
where `options: { adapter: RackspaceCdnAdapter; container: string; region?: RackspaceRegion }`.
|
||||
- ADDED: URI grammar: `rackspace://<container>/<object-key>`. The
|
||||
container appears as the URI authority; the object key is the path.
|
||||
Authority `_` or empty re-binds to the plugin's configured `container`.
|
||||
- ADDED: auth-token threading — the plugin does NOT carry the auth
|
||||
token in URIs (auth lives on `RackspaceAuthClient`). It threads the
|
||||
ambient signal into `RackspaceCdnAdapter` calls and propagates
|
||||
`RackspaceCdnAdapter`'s token-expiry/refresh behaviour transparently.
|
||||
- ADDED: temp-URL signed-URL passthrough — when the URI carries
|
||||
`?temp_url_sig=<sig>&temp_url_expires=<unix>`, the plugin forwards them
|
||||
to `buildTempUrlSignature` for verification; on mismatch raises
|
||||
`STORAGE_CDN_RACKSPACE_URI.TEMP_URL_INVALID`.
|
||||
- ADDED: `build(key, meta?)` inverse — emits
|
||||
`rackspace://<container>/<key>`; `meta.region` (when set) is encoded as
|
||||
`?region=<region>` for downstream resolvers that re-bind regions per
|
||||
URI.
|
||||
- ADDED: redaction policy — span attributes strip
|
||||
`temp_url_sig`, `temp_url_expires`, `X-Auth-Token`.
|
||||
- ADDED: `STORAGE_CDN_RACKSPACE_URI_CODES` via
|
||||
`defineCodes("STORAGE_CDN_RACKSPACE_URI", ...)` — 6 codes:
|
||||
`INVALID_URI`, `CONTAINER_MISMATCH`, `REGION_UNSUPPORTED`,
|
||||
`TEMP_URL_INVALID`, `AUTH_TOKEN_EXPIRED`, `KEY_TOO_LONG`.
|
||||
- ADDED: subpath export `"./uri-plugin/uri-plugin"`.
|
||||
- ADDED: sibling `uri-plugin.md` + `uri-plugin.test.ts`.
|
||||
|
||||
### Shared scaffolding
|
||||
|
||||
- Each plugin uses the existing `parseUri` helper from `@suaveplan/storage-uri`
|
||||
for RFC 3986 split; per-scheme validation layers on top.
|
||||
- Each plugin THREADS `AbortSignal` through every delegated adapter
|
||||
call. Pre-aborted signals raise the per-package `*.ABORTED`-equivalent
|
||||
(each adapter already has an `ABORTED` code; the URI plugin re-uses it
|
||||
rather than adding a duplicate).
|
||||
- Each plugin passes `runUriPluginConformance` with valid + invalid +
|
||||
buildRoundTrip fixtures.
|
||||
- Each plugin gets sibling `.md` per CLAUDE.md §4 and an updated
|
||||
`README.md` `## Modules` link.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **`@suaveplan/storage-s3` `s3UriPlugin`** — covered by the in-flight
|
||||
`complete-storage-s3` change which lands the plugin alongside the full
|
||||
S3 multipart / SSE / presign surface. Including it here would double-write
|
||||
the spec for the same surface.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This is a **capability-completion** change. Surface anchors:
|
||||
|
||||
- `openspec/archive/2026-05-16-add-storage-uri/tasks.md` §5 — original
|
||||
per-adapter task list; items 5.1–5.4 deferred.
|
||||
- `packages/storage/storage-uri/src/types/plugin.types.ts:29-34` —
|
||||
`UriPlugin` contract.
|
||||
- `packages/storage/storage-uri/src/conformance/conformance.ts:39-115` —
|
||||
`runUriPluginConformance` harness.
|
||||
- `packages/storage/storage-file/src/node-fs-storage/` — adapter surface
|
||||
consumed verbatim.
|
||||
- `packages/storage/storage-http/src/http-storage/http-storage.helpers.ts:52-58` —
|
||||
`composeUrl` helper consumed.
|
||||
- `packages/storage/storage-cdn-bunnycdn/src/token-auth/token-auth.ts` —
|
||||
`buildTokenAuthQuery` consumed for signed-URL paths.
|
||||
- `packages/storage/storage-cdn-rackspace/src/temp-url/temp-url.ts` —
|
||||
`buildTempUrlSignature` consumed for signed-URL paths.
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in every spec delta carries a `**Verified by:**` line
|
||||
referencing concrete `it(...)` titles in planned test files. The
|
||||
pre-archive gate (`§13` of `AGENTS.md`) re-runs the full conformance
|
||||
suite per adapter. No mocks (per CLAUDE.md rule 2) — fixture adapters
|
||||
are constructed against real `NodeFsStorage`, real `HttpStorage`
|
||||
(against `testing-integration` fixtures), and real `BunnyCdnAdapter` /
|
||||
`RackspaceCdnAdapter` instances wired to in-process fetch fixtures.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
For each of the four packages, run
|
||||
`bunx turbo ls --filter=...@suaveplan/<pkg>` to enumerate. All additions
|
||||
are purely additive named exports — no existing surface changes.
|
||||
|
||||
- `@suaveplan/storage-uri` — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
(consumes the four plugins via the existing `UriPlugin` interface; the
|
||||
cross-scheme integration tests under
|
||||
`packages/storage/storage-uri/tests/integration/` gain the four new
|
||||
schemes).
|
||||
- `@suaveplan/storage-file` consumers — [ ] tests run / [ ] docs reviewed
|
||||
/ [x] no action needed (purely additive `fileUriPlugin` export).
|
||||
- `@suaveplan/storage-http` consumers — [ ] tests run / [ ] docs reviewed
|
||||
/ [x] no action needed (purely additive `httpUriPlugin` /
|
||||
`httpsUriPlugin` exports).
|
||||
- `@suaveplan/storage-cdn-bunnycdn` consumers — [ ] tests run / [ ] docs
|
||||
reviewed / [x] no action needed.
|
||||
- `@suaveplan/storage-cdn-rackspace` consumers — [ ] tests run / [ ] docs
|
||||
reviewed / [x] no action needed.
|
||||
|
||||
None of the four adapter packages are foundation packages per §14a; the
|
||||
dependent sweep is informational only.
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
| Package | Action | Breaking? |
|
||||
|---|---|---|
|
||||
| `@suaveplan/storage-file` | ADD `fileUriPlugin` + `STORAGE_FILE_URI_CODES` | no |
|
||||
| `@suaveplan/storage-http` | ADD `httpUriPlugin` + `httpsUriPlugin` + `STORAGE_HTTP_URI_CODES` | no |
|
||||
| `@suaveplan/storage-cdn-bunnycdn` | ADD `bunnyUriPlugin` + `STORAGE_CDN_BUNNY_URI_CODES` | no |
|
||||
| `@suaveplan/storage-cdn-rackspace` | ADD `rackspaceUriPlugin` + `STORAGE_CDN_RACKSPACE_URI_CODES` | no |
|
||||
| `@suaveplan/storage-uri` | No surface change — gains test coverage via the cross-scheme integration suite | no |
|
||||
|
||||
## Migration
|
||||
|
||||
The change is purely additive — no behaviour change for existing
|
||||
consumers. After landing, consumers compose a multi-scheme resolver via:
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { fileUriPlugin } from "@suaveplan/storage-file/uri-plugin/uri-plugin";
|
||||
import { httpsUriPlugin } from "@suaveplan/storage-http/uri-plugin/uri-plugin";
|
||||
import { bunnyUriPlugin } from "@suaveplan/storage-cdn-bunnycdn/uri-plugin/uri-plugin";
|
||||
import { rackspaceUriPlugin } from "@suaveplan/storage-cdn-rackspace/uri-plugin/uri-plugin";
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
fileUriPlugin({ adapter: fileAdapter, rootDir: "/var/data" }),
|
||||
httpsUriPlugin({ adapter: httpAdapter }),
|
||||
bunnyUriPlugin({ adapter: bunnyAdapter, storageZone: "uploads" }),
|
||||
rackspaceUriPlugin({ adapter: rackspaceAdapter, container: "assets" }),
|
||||
]);
|
||||
```
|
||||
|
||||
No grep search needed — there is no prior public surface for these
|
||||
plugins.
|
||||
Reference in New Issue
Block a user