Files
2026-05-27 23:29:53 +01:00

274 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.