# Design — complete-storage-uri-adapters > Non-normative. The contracts are in > `./specs//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 `_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--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 `""` 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__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//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.