Files
2026-05-27 23:29:53 +01:00

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 callable parse, a callable build, and an adapter that is structurally a StorageLike

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 BundleUriError with code === "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 BundleUriError with code === "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 BundleUriError with code === "BUNDLE_URI.INVALID_URI"

Scenario: empty rel-path rejected

  • WHEN parse("bundle://audio-ir-room-plugin-club/") or parse("bundle://audio-ir-room-plugin-club") is called
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.INVALID_URI"

Scenario: missing authority rejected

  • WHEN parse("bundle:///assets/club.wav") is called
  • THEN the call throws BundleUriError with code === "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 to parse("bundle://other-pkg/x")
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.PACKAGE_MISMATCH", packageSlug === "audio-ir-room-plugin-club", AND actual === "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 with packageSlug: "x"
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.PATH_TRAVERSAL"

Scenario: pct-encoded .. rejected after decode

  • WHEN parse("bundle://x/%2E%2E/etc/passwd") is called against a plugin with packageSlug: "x"
  • THEN the call throws BundleUriError with code === "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 BundleUriError with code === "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 BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "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 BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "setPartial"

Scenario: delete throws READ_ONLY

  • WHEN adapter.delete("assets/x.wav") is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "delete"

Scenario: clear throws READ_ONLY

  • WHEN adapter.clear() is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "clear"

Scenario: list throws READ_ONLY

  • WHEN adapter.list({ prefix: "assets/" }) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "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 FetchLike resolves to new Response(new Uint8Array([1,2,3]), { status: 200 }) for http://localhost:<port>/club.wav
  • AND the caller invokes await adapter.get("club.wav")
  • THEN the result is a Uint8Array byte-equal to [1,2,3]

Scenario: file:// root returns bytes verbatim

  • WHEN a real temp-dir file:// rootUrl is configured AND club.wav exists at <rootUrl>/club.wav with bytes [9,8,7]
  • AND the caller invokes await adapter.get("club.wav") with the runtime's native fetch
  • THEN the result is a Uint8Array byte-equal to [9,8,7]

Scenario: 404 → NOT_FOUND

  • WHEN the injected FetchLike resolves to new Response(null, { status: 404 })
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.NOT_FOUND"

Scenario: 500 → FETCH_FAILED with httpStatus

  • WHEN the injected FetchLike resolves to new Response(null, { status: 500 })
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.FETCH_FAILED" AND httpStatus === 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 FetchLike resolves a HEAD to new Response(null, { status: 200 })
  • THEN await adapter.exists("k") is true

Scenario: HEAD 404 yields exists=false

  • WHEN the injected FetchLike resolves a HEAD to new Response(null, { status: 404 })
  • THEN await adapter.exists("k") is false

Scenario: getMetadata parses headers

  • WHEN the injected FetchLike resolves a HEAD to new 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 FetchLike honours Range: bytes=10-19 with new 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 FetchLike ignores the Range header and returns new Response(<100 bytes>, { status: 200 })
  • AND the caller invokes adapter.getPartial("k", 10, 10)
  • THEN the returned bytes equal body.slice(10, 20) AND bundle_uri.range_fallback_total{root_kind:"http"} is incremented by 1

Scenario: 416 maps to RANGE_INVALID

  • WHEN the injected FetchLike resolves to new Response(null, { status: 416 })
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.RANGE_INVALID"

Scenario: invalid offset rejected before fetch

  • WHEN adapter.getPartial("k", -1, 10) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.RANGE_INVALID" AND the injected FetchLike is NEVER invoked

Scenario: data: root always slow-pathed

  • WHEN the rootUrl is a data: URL AND adapter.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 FetchLike returns a Response with a non-null body
  • 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 FetchLike returns a Response whose body is null AND whose arrayBuffer() resolves to Uint8Array([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 BundleUriError whose code === "BUNDLE_URI.ABORTED" AND the injected FetchLike is NEVER invoked

Scenario: mid-flight abort propagates on cause

  • WHEN adapter.get("k", controller.signal) is called AND controller.abort() fires before the response resolves AND fetch rejects with an AbortError
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.ABORTED" AND whose cause is the original AbortError

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.cache AND the caller invokes get("k") twice
  • THEN the injected FetchLike is invoked twice AND bundle_uri.cache_hits_total stays at 0

Scenario: cache hit returns cached bytes without fetch

  • WHEN the plugin is constructed with options.cache = { maxBytes: 1024 } AND the caller invokes get("k") twice
  • THEN the injected FetchLike is invoked exactly once AND bundle_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 three get calls insert entries of size 50, 50, 50 in order
  • THEN after the third insert, the first entry (50 bytes) is evicted AND a subsequent get for 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 of bundle_uri.reads_total{root_kind:"http", outcome:"success"}

Scenario: error increments errors_total with resolved code

  • WHEN get("k") rejects with NOT_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_path attribute 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 successful get records a span
  • THEN the span's bundle.root_kind attribute 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" calls build("assets/y.wav")
  • THEN the result is "bundle://x/assets/y.wav" AND parse("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 a file:// rootUrl pointing at a temp dir containing a.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 real node:http fixture 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"