290 lines
20 KiB
Markdown
290 lines
20 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
||
|
||
# Capability Spec Delta: storage-http (storage-http)
|
||
|
||
**Package**: `@suaveplan/storage-http`
|
||
**Status**: PROPOSED
|
||
|
||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||
|
||
## Requirements
|
||
|
||
### Requirement: HttpStorage extends AbstractStorage
|
||
|
||
The package SHALL provide an `HttpStorage` class extending `AbstractStorage` from `@suaveplan/storage`. The class SHALL implement every abstract method on the base (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`, `getMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`) with the signatures declared on the base, including the trailing `signal?: AbortSignal` parameter. Methods unsupported in read-only mode SHALL throw `StorageHttpError` with code `STORAGE_HTTP.READ_ONLY`.
|
||
|
||
#### Scenario: instance is structurally an AbstractStorage
|
||
|
||
- **WHEN** a caller constructs `new HttpStorage({ baseUrl: "https://cdn.example.com/start/here/" })`
|
||
- **THEN** the instance is `instanceof AbstractStorage` and every abstract method is callable
|
||
|
||
#### Scenario: capabilities matrix declares partial reads and native streams
|
||
|
||
- **WHEN** a caller reads `storage.capabilities`
|
||
- **THEN** `partialReads === true`, `nativeStreams === true`, `atomicWrites === false`, `partialWrites === false`, `contentAddressable === false`
|
||
|
||
**Verified by:** `src/http-storage/http-storage.test.ts::"implements every AbstractStorage abstract method"` AND `src/http-storage/http-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"`
|
||
|
||
---
|
||
|
||
### Requirement: Options validated at the boundary via Zod
|
||
|
||
The package SHALL expose `HttpStorageOptions` (interface) and `HttpStorageOptionsSchema` (Zod). The constructor SHALL parse options through the schema and throw `StorageHttpError` with code `STORAGE_HTTP.INVALID_OPTIONS` on validation failure. `baseUrl` SHALL be required, MUST be an absolute `http://` or `https://` URL, AND MUST end with `/`. `headers` MAY be a static record or a function `(key, op) => Record<string, string> | Promise<…>` called per request. `fetch`, `uploader`, `keyEncoder`, `timeout`, `retry`, and `etagCache` SHALL be optional.
|
||
|
||
#### Scenario: baseUrl without trailing slash is rejected
|
||
|
||
- **WHEN** a caller constructs `new HttpStorage({ baseUrl: "https://cdn.example.com/start/here" })`
|
||
- **THEN** the constructor throws `StorageHttpError` with `code === "STORAGE_HTTP.INVALID_OPTIONS"`
|
||
|
||
#### Scenario: non-http(s) baseUrl is rejected
|
||
|
||
- **WHEN** a caller constructs `new HttpStorage({ baseUrl: "ftp://example.com/" })`
|
||
- **THEN** the constructor throws `StorageHttpError` with `code === "STORAGE_HTTP.INVALID_OPTIONS"`
|
||
|
||
**Verified by:** `src/http-storage/http-storage.test.ts::"rejects baseUrl missing trailing slash with INVALID_OPTIONS"` AND `src/http-storage/http-storage.test.ts::"rejects non-http(s) baseUrl with INVALID_OPTIONS"`
|
||
|
||
---
|
||
|
||
### Requirement: GET retrieves bytes from baseUrl + key
|
||
|
||
`get(key, signal?)` SHALL issue an HTTP `GET` to `new URL(encodedKey, baseUrl)` and return the response body as `Uint8Array`. The injected `headers` callback (if provided) SHALL be invoked with `(key, "get")` and its result merged into the request init. On `200 OK`, the body SHALL be returned. On `404 Not Found`, the method SHALL return `undefined` (not throw). On other non-success status, the method SHALL throw `StorageHttpError` per the status → code map in `design.md §3b`.
|
||
|
||
#### Scenario: round-trip GET returns the body bytes
|
||
|
||
- **WHEN** the injected `FetchLike` resolves to `new Response(new Uint8Array([1,2,3]), { status: 200 })` for `https://cdn.example.com/start/here/a/b/test.doc`
|
||
- **AND** the caller invokes `await storage.get("a/b/test.doc")`
|
||
- **THEN** the result is a `Uint8Array` byte-equal to `[1,2,3]`
|
||
|
||
#### Scenario: 404 Not Found yields undefined, not a throw
|
||
|
||
- **WHEN** the injected `FetchLike` resolves to `new Response(null, { status: 404 })`
|
||
- **AND** the caller invokes `await storage.get("missing")`
|
||
- **THEN** the result is `undefined` and no error is thrown
|
||
|
||
**Verified by:** `src/http-storage/http-storage.test.ts::"GET round-trips body bytes from baseUrl + key"` AND `src/http-storage/http-storage.test.ts::"GET returns undefined on 404 without throwing"`
|
||
|
||
---
|
||
|
||
### Requirement: Strict key sanitisation at the boundary
|
||
|
||
Every method that accepts a key SHALL pass it through `sanitizeKey` before issuing any fetch call. The sanitiser SHALL reject empty strings, keys containing `..` segments, keys starting with `/` or matching a drive-letter prefix (`C:\…`), keys containing null bytes (` |