Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
# Design — @suaveplan/storage-http
Non-normative companion to `proposal.md` and `specs/storage-http/spec.md`. Architecture, algorithms, and trade-offs only — no testable requirements live here.
## 1. Mental model
`HttpStorage` is an `AbstractStorage` view over a URL prefix. The mapping is:
```text
key "a/b/test.doc"
baseUrl "https://cdn.example.com/start/here/"
URL "https://cdn.example.com/start/here/a/b/test.doc"
op →
get GET <URL>
exists HEAD <URL>
getMetadata HEAD <URL> (or cached from previous GET)
getPartial GET <URL>, Range: bytes=<o>-<o+l-1>
set uploader.put(key, body, headers, signal) [if uploader wired]
delete uploader.delete(key, signal) [if uploader supports it]
list throw STORAGE_HTTP.READ_ONLY (always — see proposal §"Out of scope")
```
Read-side is always available; write-side is gated by the presence of an `HttpUploader`. The split is captured in `capabilities`:
```ts
{
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false, // request body is committed in one call; not torn, but not the POSIX rename guarantee either
contentAddressable: false,
}
```
## 2. File layout
```text
src/
index.ts # explicit named re-exports
error-codes.ts # defineCodes("STORAGE_HTTP", [...]) + StorageHttpError
types/
http-storage.types.ts # HttpStorageOptions, HttpUploader, FetchLike
http-storage.schemas.ts # HttpStorageOptionsSchema (Zod)
fetch-adapter/
fetch-adapter.ts # defaultFetch() wrapping globalThis.fetch
fetch-adapter.md
fetch-adapter.test.ts
key-sanitizer/
key-sanitizer.ts # sanitizeKey + per-segment encoder
key-sanitizer.md
key-sanitizer.test.ts
range/
range.ts # Range header builder + 206/200 disambiguation
range.md
range.test.ts
etag-cache/
etag-cache.ts # in-memory LRU; If-None-Match wiring
etag-cache.md
etag-cache.test.ts
uploader/
uploader.ts # HttpUploader contract, presignedPutUploader helper
uploader.md
uploader.test.ts
retry-helper/
retry-helper.ts # transient-status classifier + Retry-After honour
retry-helper.md
retry-helper.test.ts
otel/
otel.ts # meter/tracer wiring; recordOp(op, outcome, status_class, ms, bytes?)
otel.md
otel.test.ts
http-storage/
http-storage.ts # HttpStorage extends AbstractStorage
http-storage.md
http-storage.test.ts
http-storage.spec-conformance.test.ts
registry/
registry.ts # declare module "@suaveplan/storage" + registerHttpProvider
registry.md
registry.test.ts
```
Every implementation file ≤300 lines (soft 250); every `.test.ts` ≤500.
## 3. Algorithms / decisions
### 3a. Key → URL resolution
`new URL(encodedKey, baseUrl)` is the only safe primitive. Pitfalls:
- `baseUrl` MUST end with `/`; the Zod schema rejects non-`/` suffixes. Without the trailing slash, `new URL("a", "https://x/b")` resolves to `https://x/a` (the last path segment is treated as a file).
- `encodedKey` is built by splitting the sanitised key on `/`, applying `keyEncoder` to each segment, then joining. This preserves the hierarchy (`a/b/test.doc` → `a/b/test.doc`) while encoding spaces, `?`, `#`, etc. (`hello world.txt` → `hello%20world.txt`).
- Keys with a leading `/` are rejected by `sanitizeKey` because `new URL("/abs", "https://x/b/")` resolves to `https://x/abs` — losing the prefix.
### 3b. Status code → error code map
| HTTP | Error code | Retryable? |
|---|---|---|
| `200`, `206` | (success) | n/a |
| `304` | (success — cached body returned) | n/a |
| `401` | `UNAUTHORIZED` | no |
| `403` | `FORBIDDEN` | no |
| `404`, `410` | `NOT_FOUND` | no |
| `408`, `425`, `429` | `UPSTREAM_ERROR` (then `NETWORK_ERROR` after retry exhaustion) | yes |
| `5xx` | `UPSTREAM_ERROR` (then exhaustion → still `UPSTREAM_ERROR` with `retries_exhausted` attr) | yes |
| `4xx` (other) | `UPSTREAM_ERROR` | no |
| `fetch` reject (DNS, TLS, abort-without-signal) | `NETWORK_ERROR` | yes (network class) |
| Pre-aborted signal | `ABORTED` (no request issued) | n/a |
`Retry-After` (seconds or HTTP-date) is honoured when present; otherwise the `@suaveplan/retry` exponential backoff is used. Jitter is delegated to `@suaveplan/retry`.
### 3c. Range reads
`getPartial(offset, length)` issues `Range: bytes=<offset>-<offset+length-1>`. Three response shapes are handled:
1. **`206 Partial Content`** — happy path. `Content-Range: bytes <o>-<o+l-1>/<total>` is parsed for size accounting; body is returned verbatim.
2. **`200 OK`** — origin does not support Range. Full body is consumed and sliced; `storage_http.range_fallback_total` is incremented. Useful diagnostic for misconfigured origins.
3. **`416 Range Not Satisfiable`** — caller asked beyond EOF. Mapped to `STORAGE_HTTP.NOT_FOUND` when the resource itself exists with shorter length (HEAD probe distinguishes "object missing" from "range past EOF" only on first encounter).
### 3d. ETag cache
Optional in-memory `Map`-backed LRU (default capacity: 256 entries). Keyed by sanitised key, valued by `{ etag, body, lastModified, expiresAt }`. On `get`:
1. If `cache.has(key)`, attach `If-None-Match: <etag>`.
2. On `304`, return the cached body; refresh `lastModified` if present.
3. On `200`, replace the cache entry with the new body (subject to LRU eviction).
The cache is **per-instance**, never shared across `HttpStorage` instances. Cross-process caching is composed by the caller (typically `storage-file` underneath).
### 3e. Write strategy
`HttpUploader` is the entire write surface. Its contract:
```ts
interface HttpUploader {
put(key: string, body: Uint8Array | ReadableStream<Uint8Array>, headers?: Record<string, string>, signal?: AbortSignal): Promise<void>;
delete?(key: string, signal?: AbortSignal): Promise<void>; // optional
}
```
`HttpStorage.set` calls `uploader.put`; `delete` calls `uploader.delete` (or throws `READ_ONLY` if the uploader does not implement it). `createWriteStream` collects to memory and dispatches `uploader.put` on `close` — request-bodied origins do not support true chunked uploads without provider-specific multipart logic (deliberately out of scope).
The shipped `presignedPutUploader(presigner: (key) => Promise<{ url, headers? }>)` helper composes signing with `defaultFetch`. It is provider-agnostic — S3, R2, GCS, Azure Blob, or a custom endpoint all work by plugging in a presigner.
### 3f. Tier purity
Universal tier: `fetch`, `URL`, `AbortController`, `Uint8Array`, `ReadableStream` are all in the universal subset (Node 18+, modern browsers, Bun, Deno, Workers). No Node-only or DOM-only imports. The package is published with `"browser"` field omitted (universal) and no `engines` Node-version gate beyond the workspace baseline.
## 4. Trade-offs considered
- **Listing.** Rejected. WebDAV `PROPFIND` is the only HTTP-native directory protocol and is not what CDNs serve. Forcing a "best-effort" listing via index pages would lock callers into a specific origin's HTML layout. Shipping no `list` is honest.
- **Built-in disk cache.** Rejected for the v0. Cache invalidation is the consumer's policy choice (TTL vs. ETag vs. revalidate-always). Composing `storage-http` behind a `storage-file` `NamespacedStorage` covers the disk-cache case without baking opinions into the adapter.
- **Provider-specific helpers in this package.** Rejected. The moment we ship `s3PresignedPutUploader`, we drag `@aws-sdk/*` into the dep graph. The shipped `presignedPutUploader(presigner)` is the seam — provider packages live elsewhere.
- **Atomic writes.** HTTP `PUT` is idempotent but not POSIX-rename-atomic. We declare `capabilities.atomicWrites: false` honestly. Callers that need atomicity (cache-coherent reads during writes) should layer their own staging logic.
- **Conditional writes (`If-Match`).** Considered. Not v0 — would expand `HttpUploader` contract and the matching error code (`PRECONDITION_FAILED`). Easy follow-up if a consumer asks.
## 5. References
- `openspec/specs/storage/spec.md` — `AbstractStorage` contract this adapter conforms to.
- `openspec/archive/2026-05-12-storage-file/` — closest precedent for adapter shape (capabilities, error registry, retry wiring).
- `openspec/archive/2026-04-24-enterprise-storage-s3/` — network-backed adapter precedent (OTel attribute set, retry classification).

View File

@@ -0,0 +1,85 @@
# Add @suaveplan/storage-http — URL/CDN-backed AbstractStorage adapter
**Package**: `@suaveplan/storage-http`
**Status**: PROPOSED
**Type**: new-package (genesis-original)
- **Design**: ./design.md
- **Spec delta**: ./specs/storage-http/spec.md
## Why
Genesis ships filesystem (`storage-file`), S3 (`storage-s3`), browser (`storage-browser-{opfs,indexeddb,localstorage}`), and in-memory (`@suaveplan/storage`) adapters, but no adapter for the most common read-side surface on the public internet: **a base URL plus a key**. Consumers that need to read static assets served from a CDN (`https://cdn.example.com/start/here/` + `a/b/test.doc`) currently have to bypass the `AbstractStorage` contract and hand-roll `fetch` — losing OTel instrumentation, AbortSignal threading, structured error mapping, capability discovery, and the module-augmentation registry. The drive plugin (`drive-template` hydration), i18n HTTP backend, and any browser-tier consumer that pulls signed-URL assets all benefit from a real `AbstractStorage` over HTTPS.
The adapter is **read-first** with a pluggable write strategy. Plain CDNs (Cloudflare, Fastly, CloudFront read-paths, static origins) are read-only and the adapter SHALL declare that via `capabilities` and throw `STORAGE_HTTP.READ_ONLY` on writes. CDNs backed by an upload origin (S3 presigned PUT, R2 signed URLs, custom upload endpoints) plug in via an injectable `HttpUploader` interface; the adapter ships **no** baked-in uploader to avoid coupling to any specific provider.
## What changes
- ADDED: `HttpStorage` class extending `AbstractStorage` — full key-value read surface over `fetch`, with optional write via injected `HttpUploader`.
- ADDED: `createHttpStorage(options)` factory.
- ADDED: `HttpStorageOptions` interface + `HttpStorageOptionsSchema` Zod schema. Required `baseUrl` (must end with `/`); optional `headers` (static or per-request callback), `fetch` (injectable `FetchLike` for testing), `timeout`, `retry` (delegates to `@suaveplan/retry`), `uploader` (`HttpUploader` for writes), `keyEncoder` (default: `encodeURIComponent` per path segment, preserving `/`), `etagCache` (in-memory weak cache for conditional requests).
- ADDED: `FetchLike` structural interface — minimal `(input, init?) => Promise<Response>` shape; `defaultFetch()` adapter wrapping `globalThis.fetch` with `AbortSignal` plumbing.
- ADDED: `HttpUploader` injectable interface — `{ put(key, body, headers?, signal?), delete?(key, signal?) }`. Adapter MAY ship a thin `presignedPutUploader(presigner)` helper but no provider-specific uploaders.
- ADDED: `sanitizeKey(key)` strict key boundary — rejects empty, `..` segments, absolute paths (`/`-prefixed), null bytes, control chars (`0x00`–`0x1F`, `0x7F`), keys over 2048 chars (`KEY_TOO_LONG`); normalises Windows `\` to POSIX `/`; URL-encodes each path segment via the `keyEncoder` while preserving `/`.
- ADDED: `STORAGE_HTTP_CODES` registry via `defineCodes("STORAGE_HTTP", [...])` — 10 codes: `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `NETWORK_ERROR`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `READ_ONLY`, `ABORTED`, `UPSTREAM_ERROR`.
- ADDED: `StorageHttpError extends SuaveplanError<StorageHttpErrorCode>` with `key`, `httpStatus`, and `url` discriminants for diagnostics.
- ADDED: Module-augmentation registry — `declare module "@suaveplan/storage"` adds `http: HttpStorage` to `StorageProviderRegistry` and `http: HttpStorageOptions` to `StorageProviderConfig`; `registerHttpProvider()` registers the constructor with the factory.
- ADDED: Range-read support via HTTP `Range` header — `getPartial(offset, length)` issues `Range: bytes=<offset>-<offset+length-1>`; declared `capabilities.partialReads: true`. Servers that reply `200` instead of `206` are slow-pathed (full body sliced on the client) and counted under `storage_http.range_fallback_total`.
- ADDED: `exists(key)` via `HEAD` request; `getMetadata(key)` reads `Content-Length`, `Content-Type`, `Last-Modified`, and `ETag` from a `HEAD` response (or piggy-backed on the last `GET` via `etagCache`).
- ADDED: Conditional reads — when `etagCache` is enabled, `get` sends `If-None-Match` and treats `304 Not Modified` as a cache hit, returning the cached body without retransmission.
- ADDED: Native Web Streams via `createReadStream(key, signal)` returning the `Response.body` directly when present (declared `capabilities.nativeStreams: true`). `createWriteStream` is unavailable on read-only configurations and throws `STORAGE_HTTP.READ_ONLY`; when an `HttpUploader` is injected, the write stream collects to memory and calls `uploader.put` on `close` (the underlying surface is request-bodied, not chunked).
- ADDED: `@suaveplan/retry` integration on transient status codes (`408`, `425`, `429`, `5xx`) and network failures, with `Retry-After` honoured when present and exponential backoff otherwise. `4xx` other than the transient set SHALL NOT retry.
- ADDED: OTel surface — `storage_http.op_duration_ms{op,outcome,status_class}` and `storage_http.io_bytes{op,direction}` histograms; `storage_http.{reads,writes,deletes,errors,range_fallback}_total{outcome}` counters; per-op span on `@suaveplan/storage-http` tracer with `http.url`, `http.method`, `http.status_code`, `http.response_content_length`, `storage.key` attributes.
## Out of scope
- **Provider-specific uploaders.** S3 presigned PUTs, R2 signed URLs, Azure Blob SAS, GCS signed URLs — each ships in its own package (e.g. `storage-http-uploader-s3`) or is composed by the consumer via the `HttpUploader` slot. Reason: keeps the core adapter zero-coupling and dependency-light.
- **Listing (`list(prefix)`).** HTTP origins do not have a uniform directory protocol. `list` SHALL throw `STORAGE_HTTP.READ_ONLY` (capability `listing: false` declared on the adapter). Consumers that need listing should use `storage-s3` directly. Reason: any sensible implementation requires provider-specific knowledge (S3 `ListObjectsV2`, WebDAV `PROPFIND`, etc.); shipping an opinionated default would mislead callers.
- **Persistent disk cache.** The optional `etagCache` is an in-memory LRU; cross-process caches belong to the consumer (a `NamespacedStorage` over `storage-file` is the recommended composition for that). Reason: cache invalidation policy is application-specific.
- **Authentication state machine.** The `headers` callback runs on every request, so callers can plug in token rotation, but the adapter does not own the auth lifecycle. OAuth refresh, mTLS, AWS SigV4 — out of scope. Reason: orthogonal concern; standard composition pattern.
- **`setPartial` random-access writes.** HTTP does not expose `pwrite` semantics uniformly (S3 `multipart-upload`, R2 `multipart`, GCS `resumable` are all incompatible). `setPartial` SHALL fall through to `AbstractStorage.defaultSetPartial` only when an `HttpUploader` is wired AND the storage is read-write; otherwise it throws `STORAGE_HTTP.READ_ONLY`. `capabilities.partialWrites: false`.
- **WebDAV.** A genuine `PROPFIND`/`COPY`/`MOVE` adapter is a separate package.
## Source mapping
This is a **genesis-original** package; there is no upstream in `../suaveplan/`. All requirements derive from the genesis `AbstractStorage` contract (`openspec/specs/storage/spec.md`) and the `storage-file`/`storage-s3` adapter precedents. The package contract follows the same shape as `storage-file` (atomic writes are inapplicable; the analogue is "request body is committed in one call").
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Extend `AbstractStorage` | Every storage adapter in genesis extends `AbstractStorage` for `StorageProviderRegistry` compatibility. |
| `AbortSignal` on every public async method | Required by `AbstractStorage` base signatures; threaded straight into `fetch`'s `init.signal`. |
| `baseUrl` must end with `/` | Avoids the classic `URL` resolution footgun where `new URL("a", "https://x.com/b")` yields `https://x.com/a` instead of `https://x.com/b/a`. Documented; validated by Zod schema. |
| `keyEncoder` injectable | Some CDNs require unencoded `+` or specific casing for signed-URL signatures; per-segment encoding is the sane default but must be overridable. |
| `HttpUploader` slot rather than baked-in writes | HTTP write APIs are not standardised; coupling the adapter to one provider would force every consumer to depend on that provider. The slot keeps the core read-only-by-default while permitting any write strategy. |
| `READ_ONLY` capability + error code | Lets consumers test write capability without trying-and-catching, and surfaces the failure with a stable error code when they don't. |
| Range fallback metric | Quietly accepting `200` for a `Range` request is a correctness hazard for large objects; emitting a counter lets operators alert on misconfigured origins. |
| `etagCache` is in-memory only | Cross-process caching is the consumer's job (compose with `storage-file`). |
| `STORAGE_HTTP.UPSTREAM_ERROR` distinct from `NETWORK_ERROR` | `5xx` is a real server response; `NETWORK_ERROR` is `fetch` reject (DNS, TLS, abort-without-signal). Distinguishing them helps retry classifiers and operators. |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line referencing concrete `it(...)` titles in these files:
- `src/http-storage/http-storage.test.ts` — CRUD via injected `FetchLike`, range reads, conditional reads with ETag cache, AbortSignal threading, retry on 429/503 + `Retry-After`, `READ_ONLY` enforcement when no uploader, write path with injected uploader, OTel happy paths.
- `src/http-storage/http-storage.spec-conformance.test.ts` — type-level conformance against `AbstractStorage`, capabilities matrix, OTel instruments observed via real in-memory `MeterProvider`, idempotent registry registration, error-code registry exhaustion.
- `src/key-sanitizer/key-sanitizer.test.ts` — empty, traversal, absolute, null bytes, control chars, overlong (>2048), separator normalisation, default per-segment encoding (preserves `/`, encodes `?`, `#`, space).
- `src/fetch-adapter/fetch-adapter.test.ts` — `defaultFetch()` forwards to `globalThis.fetch`, propagates `AbortSignal`, maps network rejection to `STORAGE_HTTP.NETWORK_ERROR`.
- `src/uploader/uploader.test.ts` — `presignedPutUploader` helper round-trips a presigned URL signer; injected uploader is invoked exactly once on `set`.
- `src/range/range.test.ts` — `Range` header format, `206` happy path, `200` fallback path increments `range_fallback_total`, `416 Range Not Satisfiable` maps to `NOT_FOUND` when length unknown.
- `src/etag-cache/etag-cache.test.ts` — LRU eviction, `304 Not Modified` returns cached body without retransmission, cache miss falls through.
- `src/otel/otel.test.ts` — `recordOp` emits histogram + counter with `status_class` attribute against a real `InMemoryMetricExporter`.
- `src/registry/registry.test.ts` — registers `http` provider, `createStorage({ type: "http", config })` returns `HttpStorage`, idempotent re-registration.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/storage-http` to enumerate. The package is new — no dependents currently. No action needed.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-http` returning the package itself).
### Affected upstreams
- `@suaveplan/storage` — `StorageProviderRegistry` module-augmented to add `http: HttpStorage` slot. Additive only; no breaking change. [x] no action needed (purely additive type slot).

View File

@@ -0,0 +1,77 @@
# Tasks — storage-http
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/specs/storage/spec.md` and confirm capability boundary
- [ ] 1.2 Read `openspec/AGENTS.md` §0c, §13, §14
- [ ] 1.3 Read `openspec/archive/2026-05-12-storage-file/` end-to-end as the closest precedent
- [ ] 1.4 Read `openspec/archive/2026-04-24-enterprise-storage-s3/` for network-backed adapter precedent (OTel + retry classification)
- [ ] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.6 Draft `design.md` covering file layout, status→code map, range fallback, ETag cache, uploader contract, tier purity
- [ ] 1.7 Draft `specs/storage-http/spec.md` from `openspec/templates/spec-delta.md`
- [ ] 1.8 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete planned `it(...)` titles
- [ ] 1.9 Build the numbered Requirement/Scenario implementation checklist (per §0c step 6)
## 2. Scaffolding
- [ ] 2.1 Create `packages/storage/storage-http/` mirroring `packages/storage/storage-file/` conventions
- [ ] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, subpath exports for every feature module
- [ ] 2.3 Wire deps: `@suaveplan/error`, `@suaveplan/storage`, `@suaveplan/retry`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib` (all `workspace:^` / `catalog:`)
- [ ] 2.4 Wire devDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
- [ ] 2.5 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (via `createTestConfig`)
- [ ] 2.6 Write `tests/setup/polyfills.ts` and `README.md` skeleton
## 3. Implementation
- [ ] 3.1 `src/error-codes.ts` — `defineCodes("STORAGE_HTTP", [...])` with 10 codes, `StorageHttpError` class, `key`/`httpStatus`/`url` discriminants
- [ ] 3.2 `src/types/http-storage.types.ts` — `HttpStorageOptions`, `FetchLike`, `HttpUploader`, `EtagCacheOptions` interfaces
- [ ] 3.3 `src/types/http-storage.schemas.ts` — `HttpStorageOptionsSchema` (Zod) with `baseUrl` http(s) + trailing-slash validation
- [ ] 3.4 `src/fetch-adapter/fetch-adapter.ts` — `defaultFetch()` wrapping `globalThis.fetch`, `AbortSignal` propagation, network-reject → `NETWORK_ERROR`
- [ ] 3.5 `src/key-sanitizer/key-sanitizer.ts` — `sanitizeKey` strict boundary + default per-segment `encodeURIComponent`
- [ ] 3.6 `src/range/range.ts` — `Range` header builder + 206/200/416 disambiguation
- [ ] 3.7 `src/etag-cache/etag-cache.ts` — in-memory LRU keyed by sanitised key
- [ ] 3.8 `src/uploader/uploader.ts` — `HttpUploader` contract + `presignedPutUploader(presigner)` helper
- [ ] 3.9 `src/retry-helper/retry-helper.ts` — transient status classifier + `Retry-After` honour via `@suaveplan/retry`
- [ ] 3.10 `src/otel/otel.ts` — meter/tracer wiring, `recordOp(op, outcome, statusClass, ms, bytes?)`
- [ ] 3.11 `src/http-storage/http-storage.ts` — `HttpStorage extends AbstractStorage` with all abstract methods, capabilities matrix, `READ_ONLY` enforcement, AbortSignal
- [ ] 3.12 `src/registry/registry.ts` — `declare module "@suaveplan/storage"` + `registerHttpProvider()` (idempotent)
- [ ] 3.13 `src/index.ts` — explicit named re-exports per §6a (no `export *` in any non-root barrel)
## 4. Tests
- [ ] 4.1 `src/key-sanitizer/key-sanitizer.test.ts` — valid, traversal, absolute, null bytes, control chars, overlong (>2048), separator normalisation, per-segment encoding
- [ ] 4.2 `src/fetch-adapter/fetch-adapter.test.ts` — forwards to `globalThis.fetch`, `AbortSignal` propagation, network-reject → `NETWORK_ERROR`
- [ ] 4.3 `src/range/range.test.ts` — 206 verbatim, 200 fallback + counter increment, 416 → `NOT_FOUND`
- [ ] 4.4 `src/etag-cache/etag-cache.test.ts` — LRU eviction, 304 returns cached body, 200 replaces entry
- [ ] 4.5 `src/uploader/uploader.test.ts` — `presignedPutUploader` round-trip; injected uploader invoked exactly once on `set`
- [ ] 4.6 `src/retry-helper/retry-helper.test.ts` — transient classifier, `Retry-After` honour under `FakeClock`, non-retry on 401
- [ ] 4.7 `src/otel/otel.test.ts` — metrics via real in-memory `MeterProvider`; error span via in-memory `SpanExporter`
- [ ] 4.8 `src/http-storage/http-storage.test.ts` — GET round-trip, 404 → undefined, HEAD exists/metadata, AbortSignal pre/mid, READ_ONLY on `set`/`list`/`clear`, `set` with uploader invokes `put` once
- [ ] 4.9 `src/http-storage/http-storage.spec-conformance.test.ts` — type conformance, capabilities matrix, every `STORAGE_HTTP` code reachable, tier-purity grep
- [ ] 4.10 `src/registry/registry.test.ts` — `createStorage({ type: "http", … })` returns `HttpStorage`; idempotent re-registration
- [ ] 4.11 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
- [ ] 4.12 No imports from `vitest` or `bun:test` in test files — all via `@suaveplan/testing/runner` (§7)
## 5. Documentation
- [ ] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 5.2 `README.md` links every module under `## Modules`
- [ ] 5.3 README documents composition recipe: `storage-http` behind `storage-file` `NamespacedStorage` for cross-process disk cache
## 6. Gates
- [ ] 6.1 `bun run check`
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-http`
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/storage-http` (100/100/100/100 Istanbul)
- [ ] 6.4 `bun scripts/lint/check-test-runner-imports.ts` (rule 11)
- [ ] 6.5 Tier-purity grep — `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-http/src/` returns empty
- [ ] 6.6 `bun scripts/openspec-conformance.ts @suaveplan/storage-http`
## 7. Manifest & archival
- [ ] 7.1 Add package to `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 7.2 Update `STATUS.md` (genesis-original — add to "Completed" once shipped)
- [ ] 7.3 Fold `specs/storage-http/spec.md` into `openspec/specs/storage-http/spec.md`
- [ ] 7.4 Move `openspec/changes/add-storage-http/` → `openspec/archive/<yyyy-mm-dd>-add-storage-http/`