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

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:

  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)

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:

  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.