9.0 KiB
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:
parse(uri)— RFC 3986 scheme + per-scheme authority/path/query validation; raise<PKG>_URI.INVALID_URIon malformed inputs.- Dispatch to the bound adapter's existing
get/set/delete/list/exists/statops. build(key, meta?)— RFC 3986 inverse forlistre-serialisation.- Span-attribute redaction of sensitive query parameters.
AbortSignalthreading on every delegated call.- Per-adapter
defineCodes(...)registry with 6 codes minimum. - 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)
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)
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)
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)
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
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:
runUriPluginConformance(plugin, {
validUris: [...per-scheme happy paths],
invalidUris: [...per-scheme malformed inputs],
buildRoundTrip: [{ key, meta, uri }, ...],
});
The harness asserts:
plugin.schemematches everyvalidUris[i]scheme.plugin.parse(uri).keyis a non-empty string for every valid URI.- Every invalid URI throws (preferably
STORAGE_URI.INVALID_URI; the harness tolerates per-packageINVALID_URIre-throws viaStorageUriErrorinstance check). - When
buildRoundTripis non-empty,plugin.buildMUST be defined and must return the exact expected URI string for each entry.
Trade-offs
- Two schemes from one bunny factory —
bunnycdnandbunnycdn+pullshare parsing logic but register as twoUriPlugininstances 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 fromadd-storage-uri). - Range parsing via query string —
?range=bytes=0-1023is an awkward channel, but it keepsUriPlugin.parsereturning the un-changedParsedUrishape fromstorage-uriwithout extending the contract for a single optional dimension. Future revisions MAY hoist range intoParsedUri.metaformally. - 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.