23 KiB
Capability Spec Delta: storage-uri-plugin-bundle (storage-uri-plugin-bundle)
Package: @suaveplan/storage-uri-plugin-bundle
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Plugin factory shape and registration
The package SHALL export bundleUriPlugin(options: BundleUriPluginOptions): UriPlugin returning a
UriPlugin (per @suaveplan/storage-uri) with scheme === "bundle". The factory SHALL validate
options through BundleUriPluginOptionsSchema (Zod) at construction; validation failures SHALL
throw BundleUriError with code === "BUNDLE_URI.INVALID_URI" AND a cause carrying the Zod
issue. options.rootUrl SHALL be normalised to a URL instance with a trailing-slash pathname;
inputs already ending in / SHALL be accepted verbatim, inputs without / SHALL be rejected with
INVALID_URI rather than silently amended (so caller mistakes surface). The factory SHALL default
options.fetch to globalThis.fetch.bind(globalThis) when unset.
Scenario: factory returns a UriPlugin with scheme "bundle"
- WHEN a caller invokes
bundleUriPlugin({ packageSlug: "audio-ir-room-plugin-club", rootUrl: new URL("file:///pkgs/club/assets/") }) - THEN the returned object has
scheme === "bundle", a callableparse, a callablebuild, and anadapterthat is structurally aStorageLike
Scenario: invalid slug rejected at construction
- WHEN a caller invokes
bundleUriPlugin({ packageSlug: "Invalid Slug With Spaces", rootUrl: new URL("file:///x/") }) - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"
Scenario: rootUrl missing trailing slash rejected
- WHEN a caller invokes
bundleUriPlugin({ packageSlug: "x", rootUrl: "file:///pkgs/x/assets" }) - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"and message naming the trailing-slash requirement
Verified by: src/factory/factory.test.ts::"returns a UriPlugin with scheme bundle" AND src/factory/factory.test.ts::"rejects invalid slug at construction" AND src/factory/factory.test.ts::"rejects rootUrl missing trailing slash"
Requirement: bundle:// URI grammar enforced by parse
The plugin's parse(uri) SHALL accept any URI of the form
bundle://<package-slug>/<rel-path>[?query][#fragment] where <package-slug> matches the
authority component and <rel-path> is the non-empty URL-decoded path within rootUrl. The
scheme SHALL be matched case-insensitively per RFC 3986 §3.1. parse SHALL return
{ key: relPath, meta: { packageSlug } } per the ParsedUri contract. Malformed URIs (missing
authority, empty rel-path, scheme other than bundle, malformed pct-encoding) SHALL throw
BundleUriError with code === "BUNDLE_URI.INVALID_URI".
Scenario: well-formed URI parses to key plus meta
- WHEN
parse("bundle://audio-ir-room-plugin-club/assets/club.wav")is called - THEN the result is
{ key: "assets/club.wav", meta: { packageSlug: "audio-ir-room-plugin-club" } }
Scenario: scheme mismatch rejected
- WHEN
parse("https://audio-ir-room-plugin-club/assets/club.wav")is called - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"
Scenario: empty rel-path rejected
- WHEN
parse("bundle://audio-ir-room-plugin-club/")orparse("bundle://audio-ir-room-plugin-club")is called - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"
Scenario: missing authority rejected
- WHEN
parse("bundle:///assets/club.wav")is called - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"
Verified by: src/parse/parse.test.ts::"parses well-formed URI to key plus meta" AND src/parse/parse.test.ts::"rejects scheme mismatch with INVALID_URI" AND src/parse/parse.test.ts::"rejects empty rel-path with INVALID_URI" AND src/parse/parse.test.ts::"rejects missing authority with INVALID_URI"
Requirement: Per-package slug enforcement
When the parsed authority differs from options.packageSlug, parse SHALL throw BundleUriError
with code === "BUNDLE_URI.PACKAGE_MISMATCH" carrying both the expected slug (on packageSlug)
and the offending slug (on actual). Slug comparison SHALL be case-sensitive (npm slug rules
forbid uppercase, so a case difference is a real mismatch).
Scenario: URI slug not equal to options.packageSlug
- WHEN a plugin configured with
packageSlug: "audio-ir-room-plugin-club"is asked toparse("bundle://other-pkg/x") - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.PACKAGE_MISMATCH",packageSlug === "audio-ir-room-plugin-club", ANDactual === "other-pkg"
Verified by: src/parse/parse.test.ts::"rejects slug mismatch with PACKAGE_MISMATCH carrying actual"
Requirement: Path-traversal prevention
parse SHALL reject any rel-path containing a .. segment, both in the raw input and after a
single decodeURIComponent pass, throwing BundleUriError with
code === "BUNDLE_URI.PATH_TRAVERSAL". As a defence-in-depth check, the resolved URL
(new URL(encodedRelPath, rootUrl)) SHALL have a pathname that starts with rootUrl.pathname;
any resolved URL that escapes rootUrl SHALL also throw PATH_TRAVERSAL.
Scenario: raw .. rejected
- WHEN
parse("bundle://x/../etc/passwd")is called against a plugin withpackageSlug: "x" - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.PATH_TRAVERSAL"
Scenario: pct-encoded .. rejected after decode
- WHEN
parse("bundle://x/%2E%2E/etc/passwd")is called against a plugin withpackageSlug: "x" - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.PATH_TRAVERSAL"
Scenario: URL-resolve escape check
- WHEN a hypothetical input slips the first two passes but resolves to a pathname outside
rootUrl.pathname - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.PATH_TRAVERSAL"
Verified by: src/parse/parse.test.ts::"rejects raw .. segment with PATH_TRAVERSAL" AND src/parse/parse.test.ts::"rejects pct-encoded .. after decode with PATH_TRAVERSAL" AND src/parse/parse.test.ts::"rejects URL-resolve escape outside rootUrl with PATH_TRAVERSAL"
Requirement: Read-only enforcement on every write surface
The plugin's adapter (a StorageLike) SHALL throw BundleUriError with
code === "BUNDLE_URI.READ_ONLY" for every method that would mutate state: set, setPartial,
delete, clear, and list. Each rejection SHALL carry the offending operation name on a
discriminant op so operators can correlate dashboards. No write surface SHALL be conditionally
enabled — the contract is immutable bundles.
Scenario: set throws READ_ONLY carrying op
- WHEN
adapter.set("assets/x.wav", new Uint8Array([1,2,3]))is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "set"
Scenario: setPartial throws READ_ONLY
- WHEN
adapter.setPartial("assets/x.wav", 0, new Uint8Array([1,2,3]))is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "setPartial"
Scenario: delete throws READ_ONLY
- WHEN
adapter.delete("assets/x.wav")is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "delete"
Scenario: clear throws READ_ONLY
- WHEN
adapter.clear()is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "clear"
Scenario: list throws READ_ONLY
- WHEN
adapter.list({ prefix: "assets/" })is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "list"
Verified by: src/storage-shim/storage-shim.test.ts::"set throws READ_ONLY with op set" AND src/storage-shim/storage-shim.test.ts::"setPartial throws READ_ONLY" AND src/storage-shim/storage-shim.test.ts::"delete throws READ_ONLY" AND src/storage-shim/storage-shim.test.ts::"clear throws READ_ONLY" AND src/storage-shim/storage-shim.test.ts::"list throws READ_ONLY"
Requirement: GET resolves through injected FetchLike
adapter.get(key, signal?) SHALL issue an HTTP GET to new URL(encodedRelPath, rootUrl)
through the injected FetchLike and SHALL return the response body as Uint8Array. On 200 OK
the body SHALL be returned. On 404 Not Found or 410 Gone the method SHALL throw
BundleUriError with code === "BUNDLE_URI.NOT_FOUND". On any other non-success status the
method SHALL throw BundleUriError with code === "BUNDLE_URI.FETCH_FAILED" carrying
httpStatus and the upstream Response summary on cause.
Scenario: HTTP root returns bytes verbatim
- WHEN the injected
FetchLikeresolves tonew Response(new Uint8Array([1,2,3]), { status: 200 })forhttp://localhost:<port>/club.wav - AND the caller invokes
await adapter.get("club.wav") - THEN the result is a
Uint8Arraybyte-equal to[1,2,3]
Scenario: file:// root returns bytes verbatim
- WHEN a real temp-dir
file://rootUrlis configured ANDclub.wavexists at<rootUrl>/club.wavwith bytes[9,8,7] - AND the caller invokes
await adapter.get("club.wav")with the runtime's nativefetch - THEN the result is a
Uint8Arraybyte-equal to[9,8,7]
Scenario: 404 → NOT_FOUND
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 404 }) - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.NOT_FOUND"
Scenario: 500 → FETCH_FAILED with httpStatus
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 500 }) - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.FETCH_FAILED"ANDhttpStatus === 500
Verified by: src/storage-shim/storage-shim.test.ts::"HTTP root returns bytes verbatim" AND src/storage-shim/storage-shim.test.ts::"file:// root returns bytes verbatim against real temp dir" AND src/storage-shim/storage-shim.test.ts::"404 maps to NOT_FOUND" AND src/storage-shim/storage-shim.test.ts::"500 maps to FETCH_FAILED with httpStatus"
Requirement: HEAD-backed exists and getMetadata
adapter.exists(key, signal?) SHALL issue HEAD <URL> and return true on 2xx, false on
404/410, and throw FETCH_FAILED on other non-success status. adapter.getMetadata(key, signal?) SHALL issue HEAD <URL> and parse content-length, content-type, last-modified,
and etag from the response headers into the StatResult shape declared by
@suaveplan/storage-uri. Absence of content-length SHALL yield size: undefined.
Scenario: HEAD 200 yields exists=true
- WHEN the injected
FetchLikeresolves aHEADtonew Response(null, { status: 200 }) - THEN
await adapter.exists("k")istrue
Scenario: HEAD 404 yields exists=false
- WHEN the injected
FetchLikeresolves aHEADtonew Response(null, { status: 404 }) - THEN
await adapter.exists("k")isfalse
Scenario: getMetadata parses headers
- WHEN the injected
FetchLikeresolves aHEADtonew Response(null, { status: 200, headers: { "content-length": "42", "content-type": "audio/wav", "etag": "\"abc\"", "last-modified": "Wed, 17 May 2026 00:00:00 GMT" } }) - THEN
await adapter.getMetadata("k")returns{ size: 42, contentType: "audio/wav", etag: '"abc"', lastModified: Date(2026-05-17T00:00:00Z) }
Verified by: src/storage-shim/storage-shim.test.ts::"HEAD 200 yields exists true" AND src/storage-shim/storage-shim.test.ts::"HEAD 404 yields exists false" AND src/storage-shim/storage-shim.test.ts::"getMetadata parses content-length content-type etag last-modified"
Requirement: Range reads with fallback
adapter.getPartial(key, offset, length, signal?) SHALL validate offset >= 0 and length > 0
(both integers) and throw BundleUriError with code === "BUNDLE_URI.RANGE_INVALID" for any
violation, without issuing a fetch. Valid ranges SHALL be requested via Range: bytes=<offset>-<offset+length-1>.
A 206 Partial Content response SHALL return its body verbatim. A 200 OK response SHALL be
slow-pathed (full body sliced client-side at [offset, offset+length)) AND SHALL increment
bundle_uri.range_fallback_total{root_kind}. A 416 Range Not Satisfiable response SHALL throw
BundleUriError with code === "BUNDLE_URI.RANGE_INVALID". For data: and blob: roots, every
range read SHALL be slow-pathed (Range header dropped or unsupported) AND SHALL increment
range_fallback_total.
Scenario: HTTP root 206 returns the slice
- WHEN the injected
FetchLikehonoursRange: bytes=10-19withnew Response(<10 bytes>, { status: 206 }) - THEN
await adapter.getPartial("k", 10, 10)returns the 10-byte body verbatim
Scenario: HTTP root 200 fallback increments counter
- WHEN the injected
FetchLikeignores theRangeheader and returnsnew Response(<100 bytes>, { status: 200 }) - AND the caller invokes
adapter.getPartial("k", 10, 10) - THEN the returned bytes equal
body.slice(10, 20)ANDbundle_uri.range_fallback_total{root_kind:"http"}is incremented by 1
Scenario: 416 maps to RANGE_INVALID
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 416 }) - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.RANGE_INVALID"
Scenario: invalid offset rejected before fetch
- WHEN
adapter.getPartial("k", -1, 10)is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.RANGE_INVALID"AND the injectedFetchLikeis NEVER invoked
Scenario: data: root always slow-pathed
- WHEN the rootUrl is a
data:URL ANDadapter.getPartial("ignored", 0, 4)is called - THEN the bytes are sliced client-side AND
bundle_uri.range_fallback_total{root_kind:"data"}is incremented by 1
Verified by: src/range/range.test.ts::"HTTP root 206 returns slice verbatim" AND src/range/range.test.ts::"HTTP root 200 fallback slices client-side and increments counter" AND src/range/range.test.ts::"416 maps to RANGE_INVALID" AND src/range/range.test.ts::"invalid offset rejected before fetch" AND src/range/range.test.ts::"data root always slow-pathed and counter incremented"
Requirement: Streaming reads
adapter.createReadStream(key, signal?) SHALL return a ReadableStream<Uint8Array>. When the
underlying Response.body is non-null, the method SHALL return that stream directly without
buffering. When Response.body is null (some adapter or polyfill paths), the method SHALL fall
back to a one-chunk ReadableStream wrapping the buffered bytes.
Scenario: createReadStream forwards Response.body
- WHEN the injected
FetchLikereturns aResponsewith a non-nullbody - THEN the returned stream is the same reference as
response.body(no re-wrap)
Scenario: one-chunk fallback when body is null
- WHEN the injected
FetchLikereturns aResponsewhosebodyisnullAND whosearrayBuffer()resolves toUint8Array([1,2,3]) - THEN the returned stream yields exactly one chunk equal to
Uint8Array([1,2,3])then closes
Verified by: src/streaming/streaming.test.ts::"forwards Response.body when present" AND src/streaming/streaming.test.ts::"one-chunk fallback when Response.body is null"
Requirement: AbortSignal propagation
Every read on the adapter (get, exists, getMetadata, getPartial, createReadStream) SHALL
accept an AbortSignal and SHALL thread it into the underlying fetch call. When the signal is
already aborted at call time, the method SHALL reject with BundleUriError whose
code === "BUNDLE_URI.ABORTED" WITHOUT invoking the injected FetchLike. Mid-flight aborts that
propagate from fetch as an AbortError SHALL be rewrapped as
BundleUriError(code: "BUNDLE_URI.ABORTED") carrying the original AbortError on cause.
Scenario: pre-aborted signal short-circuits
- WHEN
adapter.get("k", AbortSignal.abort())is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.ABORTED"AND the injectedFetchLikeis NEVER invoked
Scenario: mid-flight abort propagates on cause
- WHEN
adapter.get("k", controller.signal)is called ANDcontroller.abort()fires before the response resolves ANDfetchrejects with anAbortError - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.ABORTED"AND whosecauseis the originalAbortError
Verified by: src/abort/abort.test.ts::"pre-aborted signal short-circuits without fetch" AND src/abort/abort.test.ts::"mid-flight abort propagates AbortError on cause"
Requirement: Optional in-memory LRU cache
The plugin SHALL support an optional in-memory LRU cache keyed by full URI. The cache SHALL be
OFF by default; when options.cache = { maxBytes } is supplied, every successful get result
SHALL be inserted into the cache AND subsequent gets of the same URI SHALL return the cached
bytes WITHOUT invoking the injected FetchLike. The cache SHALL evict oldest entries first
until total cached bytes ≤ maxBytes. Cache hits SHALL increment
bundle_uri.cache_hits_total{root_kind}; misses SHALL increment
bundle_uri.cache_misses_total{root_kind}.
Scenario: cache off by default
- WHEN the plugin is constructed without
options.cacheAND the caller invokesget("k")twice - THEN the injected
FetchLikeis invoked twice ANDbundle_uri.cache_hits_totalstays at 0
Scenario: cache hit returns cached bytes without fetch
- WHEN the plugin is constructed with
options.cache = { maxBytes: 1024 }AND the caller invokesget("k")twice - THEN the injected
FetchLikeis invoked exactly once ANDbundle_uri.cache_hits_total{root_kind}is incremented by 1
Scenario: LRU evicts oldest when maxBytes exceeded
- WHEN the plugin is constructed with
options.cache = { maxBytes: 100 }AND threegetcalls insert entries of size 50, 50, 50 in order - THEN after the third insert, the first entry (50 bytes) is evicted AND a subsequent
getfor its URI misses the cache
Verified by: src/cache/cache.test.ts::"cache off by default does not hit" AND src/cache/cache.test.ts::"cache hit returns cached bytes without fetch" AND src/cache/cache.test.ts::"LRU evicts oldest when maxBytes exceeded"
Requirement: OpenTelemetry instrumentation
Every public operation on the adapter SHALL emit a span on the
@suaveplan/storage-uri-plugin-bundle tracer AND SHALL record
bundle_uri.op_duration_ms{op, root_kind, outcome} on the meter. Errors SHALL increment
bundle_uri.errors_total{root_kind, code} with the resolved BUNDLE_URI.* code. Spans SHALL
carry bundle.package, bundle.rel_path (truncated to 256 chars; query strings stripped), and
bundle.root_kind (file | http | data | blob | other) attributes. Cache and range
fallback paths SHALL increment the dedicated counters declared in design.md §6.
Scenario: successful read records duration histogram
- WHEN
get("k")succeeds against an HTTP root - THEN the meter records one observation on
bundle_uri.op_duration_ms{op:"get", root_kind:"http", outcome:"success"}AND one increment ofbundle_uri.reads_total{root_kind:"http", outcome:"success"}
Scenario: error increments errors_total with resolved code
- WHEN
get("k")rejects withNOT_FOUND - THEN
bundle_uri.errors_total{root_kind:"http", code:"BUNDLE_URI.NOT_FOUND"}is incremented by 1
Scenario: query string stripped from rel_path attribute
- WHEN
get("k?token=secret")is attempted (the caller-side rel-path includes a query) - THEN the emitted span's
bundle.rel_pathattribute is"k"(no?token=…)
Scenario: root_kind classified correctly per resolution matrix
- WHEN the plugin is constructed with
rootUrl: new URL("file:///pkgs/x/")AND a successfulgetrecords a span - THEN the span's
bundle.root_kindattribute is"file"
Verified by: src/otel/otel.test.ts::"records op_duration_ms on success" AND src/otel/otel.test.ts::"increments errors_total with resolved code" AND src/otel/otel.test.ts::"strips query string from rel_path attribute" AND src/otel/otel.test.ts::"classifies root_kind per resolution matrix"
Requirement: build() inverse for round-trip
The plugin SHALL expose a build(key, meta?) function that re-serialises a key plus optional
meta into a bundle:// URI such that parse(build(key, { packageSlug })) === { key, meta: { packageSlug } }
for every key the plugin's parse accepts. When meta?.packageSlug is absent, build SHALL
default to options.packageSlug.
Scenario: build round-trips through parse
- WHEN a plugin configured with
packageSlug: "x"callsbuild("assets/y.wav") - THEN the result is
"bundle://x/assets/y.wav"ANDparse("bundle://x/assets/y.wav")returns{ key: "assets/y.wav", meta: { packageSlug: "x" } }
Verified by: src/parse/parse.test.ts::"build round-trips through parse"
Requirement: Conformance harness pass
The package SHALL include a conformance test that invokes
runUriPluginConformance(bundleUriPlugin(options), fixtures) from @suaveplan/storage-uri and
SHALL pass without throwing against TWO real backing roots: (a) a temp-dir file:// root
populated with fixture bytes; (b) a http://localhost:<port>/ root served by a real node:http
fixture server stood up in beforeAll and torn down in afterAll. Both runs SHALL use the
runtime's native fetch (no injected FetchLike) to prove the universal-tier resolution
strategy works end-to-end.
Scenario: conformance passes against file:// root
- WHEN the harness is invoked with
validUris: ["bundle://x/a.bin"],invalidUris: ["bad"],buildRoundTrip: [{ key: "a.bin", uri: "bundle://x/a.bin" }]against afile://rootUrl pointing at a temp dir containinga.bin - THEN all assertions pass
Scenario: conformance passes against http:// root
- WHEN the harness is invoked with the same fixture shape against an
http://localhost:<port>/rootUrl served by a realnode:httpfixture server - THEN all assertions pass
Verified by: src/conformance/conformance.test.ts::"runUriPluginConformance passes against file:// root" AND src/conformance/conformance.test.ts::"runUriPluginConformance passes against http:// localhost fixture"
Requirement: Error-code registry exhaustion
The BUNDLE_URI_CODES registry exposed via defineCodes("BUNDLE_URI", [...]) SHALL contain
exactly the nine codes declared in design.md §6: PACKAGE_MISMATCH, PATH_TRAVERSAL,
READ_ONLY, NOT_FOUND, INVALID_URI, FETCH_FAILED, RANGE_INVALID, RANGE_UNSUPPORTED,
ABORTED. Every code SHALL be reachable from a real call site exercised by the test suite —
unused codes are banned per AGENTS.md §2c.
Scenario: every code is reachable
- WHEN the test suite enumerates
Object.values(BUNDLE_URI_CODES)AND maps each to a fixture call site - THEN every entry maps to at least one
it(...)assertion that exercises the code from a real call site
Verified by: src/error-codes/error-codes.test.ts::"every BUNDLE_URI code is reachable from a real call site"