202 lines
9.0 KiB
Markdown
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.
|