Initial Commit
This commit is contained in:
201
openspec/changes/complete-storage-uri-adapters/design.md
Normal file
201
openspec/changes/complete-storage-uri-adapters/design.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user