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

202 lines
9.0 KiB
Markdown

# Design — complete-storage-uri-adapters
> Non-normative. The contracts are in
> `./specs/<adapter>/spec.md`; this file is rationale, grammar, and
> error-code matrices.
## Why one change instead of four
The four plugins share a verbatim integration playbook:
1. `parse(uri)` — RFC 3986 scheme + per-scheme authority/path/query
validation; raise `<PKG>_URI.INVALID_URI` on malformed inputs.
2. Dispatch to the bound adapter's existing `get`/`set`/`delete`/`list`/
`exists`/`stat` ops.
3. `build(key, meta?)` — RFC 3986 inverse for `list` re-serialisation.
4. Span-attribute redaction of sensitive query parameters.
5. `AbortSignal` threading on every delegated call.
6. Per-adapter `defineCodes(...)` registry with 6 codes minimum.
7. Conformance via `runUriPluginConformance`.
Four separate `complete-storage-<x>-uri` proposals would multiply
ceremony — four design docs, four parity tables, four archive cycles —
for a pattern that is identical at the contract level. A single change
keeps the four spec deltas isolated (one folder per adapter under
`./specs/`) so the canonical specs land independently at archive while
the proposal stays a single review unit.
S3 is excluded because the `complete-storage-s3` change already owns the
S3 surface end-to-end (multipart, SSE-C/KMS/S3, signed-DELETE,
checksums); its `s3UriPlugin` is wired alongside those features and
needs grammar (`s3://bucket/key`) plus bucket-mismatch error semantics
that have no analogue in the other four.
## URI grammar per scheme
ABNF excerpts (RFC 5234, RFC 3986). `scheme` is matched case-insensitively
per RFC 3986 §3.1; canonical form is lowercased.
### `file://` (RFC 8089)
```abnf
file-uri = "file:" "//" auth-path
auth-path = [ "localhost" ] "/" path-absolute
/ "/" drive-letter ":" path-absolute ; Windows
drive-letter = ALPHA
```
| Form | Behaviour |
|-----------------------------------|----------------------------------------|
| `file:///etc/passwd` | absolute POSIX; key relative to `rootDir` |
| `file://localhost/etc/passwd` | same as above (RFC 8089 §2) |
| `file:///C:/Users/x` | Windows; drive letter preserved |
| `file://example.com/x` | `STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY` |
| `file:///../escape` | `STORAGE_FILE_URI.PATH_ESCAPE` |
### `http://`, `https://` (RFC 3986 §3)
```abnf
http-uri = ( "http" / "https" ) "://" authority path-abempty [ "?" query ] [ "#" fragment ]
authority = [ userinfo "@" ] host [ ":" port ]
```
Query string is preserved on the key (presigned URLs depend on it).
Fragment is dropped (servers ignore it).
### `bunnycdn://` (genesis-original)
```abnf
bunny-storage = "bunnycdn://" zone "/" path [ "?" query ]
bunny-pull = "bunnycdn+pull://" pull-zone "/" path [ "?" query ]
zone = 1*( unreserved / pct-encoded )
```
| Form | Behaviour |
|-------------------------------------------------|----------------------------------------|
| `bunnycdn://uploads/img/a.jpg` | storage zone; rw against `BunnyCdnAdapter` |
| `bunnycdn+pull://cdn.example.com/img/a.jpg` | pull zone; read-only |
| `bunnycdn://wrong/img/a.jpg` (zone mismatch) | `ZONE_MISMATCH` |
| `bunnycdn+pull://...` + `set` | `UNSUPPORTED_OPERATION` |
### `rackspace://` (genesis-original)
```abnf
rackspace-uri = "rackspace://" container "/" path [ "?" query ]
container = "_" / 1*( unreserved / pct-encoded )
```
`_` rebinds to the plugin's configured container (handy for tests).
## Query-string semantics + redaction policy
Every plugin redacts sensitive query parameters from OTel span
attributes and error `.uri` discriminants. The deny-list is per-scheme:
| Plugin | Redacted parameter keys (case-insensitive) |
|--------------------|----------------------------------------------------------------------------|
| `fileUriPlugin` | (none — file URIs do not carry query strings) |
| `httpUriPlugin` | `X-Amz-Signature`, `X-Amz-Security-Token`, `token`, `signature`, `sig`, `key`, `auth`, `password` |
| `httpsUriPlugin` | (same as `httpUriPlugin`) |
| `bunnyUriPlugin` | `token`, `signature`, `expires` |
| `rackspaceUriPlugin` | `temp_url_sig`, `temp_url_expires`, `X-Auth-Token` |
Redaction is implemented per-plugin in
`src/uri-plugin/redact-signed-url.ts` (the file does not exist for
`fileUriPlugin` — file URIs are pass-through). The function replaces
each matched value with the literal string `"<redacted>"` and preserves
parameter ordering so the redacted URI remains diff-friendly in logs.
Range parameters (`?range=bytes=0-1023`) are NOT redacted — they
describe the request, not the secret.
## Error-code matrix per adapter
Each adapter ships its own registry via
`defineCodes("STORAGE_<NAME>_URI", [...] as const)`. Six codes minimum;
each reachable from a real call site exercised by tests.
| Code | file | http | bunny | rackspace |
|--------------------------------------|:----:|:----:|:-----:|:---------:|
| `INVALID_URI` | x | x | x | x |
| `KEY_TOO_LONG` | x | x | x | x |
| `UNSUPPORTED_AUTHORITY` | x | | | |
| `PATH_ESCAPE` | x | | | |
| `PERMISSION_DENIED` | x | | | |
| `WINDOWS_DRIVE_INVALID` | x | | | |
| `UNSUPPORTED_SCHEME` | | x | | |
| `RANGE_INVALID` | | x | | |
| `REDIRECT_LOOP` | | x | | |
| `HOST_MISMATCH` | | x | | |
| `ZONE_MISMATCH` | | | x | |
| `PULL_ZONE_NOT_CONFIGURED` | | | x | |
| `TOKEN_INVALID` | | | x | |
| `UNSUPPORTED_OPERATION` | | | x | |
| `CONTAINER_MISMATCH` | | | | x |
| `REGION_UNSUPPORTED` | | | | x |
| `TEMP_URL_INVALID` | | | | x |
| `AUTH_TOKEN_EXPIRED` | | | | x |
`ABORTED` is NOT a new code — each adapter already exposes one (e.g.
`STORAGE_FILE.ABORTED`); the plugin re-uses it for pre-aborted signals
rather than introducing a duplicate.
## File layout per adapter
```text
packages/storage/<adapter>/src/
uri-plugin/
index.ts # explicit named re-exports (rule 10)
uri-plugin.ts # factory + parse/build
uri-plugin.md
uri-plugin.test.ts # conformance + per-scheme cases
error-codes.ts # defineCodes + structured error class
error-codes.md
error-codes.test.ts
redact-signed-url.ts # (file-plugin omitted)
redact-signed-url.md
redact-signed-url.test.ts
```
## Conformance fixtures
Each adapter's test calls:
```ts
runUriPluginConformance(plugin, {
validUris: [...per-scheme happy paths],
invalidUris: [...per-scheme malformed inputs],
buildRoundTrip: [{ key, meta, uri }, ...],
});
```
The harness asserts:
1. `plugin.scheme` matches every `validUris[i]` scheme.
2. `plugin.parse(uri).key` is a non-empty string for every valid URI.
3. Every invalid URI throws (preferably `STORAGE_URI.INVALID_URI`; the
harness tolerates per-package `INVALID_URI` re-throws via
`StorageUriError` instance check).
4. When `buildRoundTrip` is non-empty, `plugin.build` MUST be defined and
must return the exact expected URI string for each entry.
## Trade-offs
- **Two schemes from one bunny factory** — `bunnycdn` and
`bunnycdn+pull` share parsing logic but register as two `UriPlugin`
instances because the resolver indexes one plugin per scheme. The
factory returns both via destructuring:
`const { storage, pull } = bunnyUriPlugin(opts)`.
- **Plugins live in the adapter packages, not in `storage-uri`** — keeps
the resolver dependency-free (per the foundation contract from
`add-storage-uri`).
- **Range parsing via query string** — `?range=bytes=0-1023` is an
awkward channel, but it keeps `UriPlugin.parse` returning the
un-changed `ParsedUri` shape from `storage-uri` without extending the
contract for a single optional dimension. Future revisions MAY hoist
range into `ParsedUri.meta` formally.
- **No `node:` imports in CDN plugins** — universal-tier so the same
code runs in workers and edge runtimes that consume CDN URIs.
- **`file://` plugin is server-tier** — already true of
`@suaveplan/storage-file`; the URI plugin inherits the same
constraint.