first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
# `drive`
Virtual filesystem, mesh networking, and chunk-addressed storage plugins.
<!-- begin:auto-docs -->
## Packages in `drive`
14 packages.
| Package | Description |
|---|---|
| [`@suaveplan/drive`](./drive/README.md) | Modular virtual filesystem core for Suaveplan. File-level operations over `AbstractStorage` with a plugin slot for middleware, chunking, encryption, and P2P me… |
| [`@suaveplan/drive-mesh`](./drive-mesh/README.md) | P2P mesh orchestrator over the drive chunks plugin. Consumes `ChunkSource` (from `@suaveplan/drive-plugin-chunks`) and one or more `DriveTransport` implementat… |
| [`@suaveplan/drive-mesh-protocol`](./drive-mesh-protocol/README.md) | Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three reques… |
| [`@suaveplan/drive-plugin-cache`](./drive-plugin-cache/README.md) | Read-through cache middleware for `@suaveplan/drive`. Accepts a caller-supplied `AbstractCache<Uint8Array>` (from `@suaveplan/cache`) and intercepts every `dri… |
| [`@suaveplan/drive-plugin-chunks`](./drive-plugin-chunks/README.md) | Chunk-addressing plugin for `@suaveplan/drive`. Splits files into signed, encrypted chunks using the locked `version 0x0001` binary format, with server-signed… |
| [`@suaveplan/drive-react-sharing`](./drive-react-sharing/README.md) | React-tier UI surface for `@suaveplan/drive-sharing`. Three controlled components plus a real-engine-backed hook. |
| [`@suaveplan/drive-sharing`](./drive-sharing/README.md) | Partitioned drive sharing engine for the Suaveplan ecosystem. Ships envelope encryption with mandatory AAD, per-user key wrapping, partition policies with dete… |
| [`@suaveplan/drive-storage-archive-core`](./drive-storage-archive-core/README.md) | Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode… |
| [`@suaveplan/drive-storage-zip`](./drive-storage-zip/README.md) | ZIP archive adapter for `@suaveplan/drive`. Reads and writes standards-compliant zip archives through the structural `DriveStorage` view; supports ZIP64, split… |
| [`@suaveplan/drive-template`](./drive-template/README.md) | Server-signed drive templates for the dehydrated-drive workflow. A `DriveTemplate` describes a drive's full folder structure and per-file metadata; the actual… |
| [`@suaveplan/drive-transport-https`](./drive-transport-https/README.md) | HTTPS-relay-backed `TransportLike<MessageEnvelope>` for the Suaveplan drive mesh. Implements long-poll receive, stateless POST send, exponential backoff with e… |
| [`@suaveplan/drive-transport-webrtc`](./drive-transport-webrtc/README.md) | WebRTC datachannel transport for the drive mesh. Implements the canonical `DriveTransport` interface from `@suaveplan/drive-mesh` over a `@suaveplan/drive-webr… |
| [`@suaveplan/drive-uri`](./drive-uri/README.md) | URI plugins (`drive://`, `drive+mesh://`, `drive+template://`) for `@suaveplan/storage-uri`'s `StorageUriResolver`. Wraps a caller-supplied `Drive` instance in… |
| [`@suaveplan/drive-webrtc`](./drive-webrtc/README.md) | Browser-tier WebRTC P2P chunk-distribution plugin for `@suaveplan/drive`. Provides a complete mesh networking stack — signalling client, ICE-aware WebRTC clien… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Virtual filesystem, mesh networking, and chunk-addressed storage plugins.

View File

@@ -0,0 +1,98 @@
# @suaveplan/drive-mesh-protocol - Wire protocol for chunk discovery, request, and delivery across the mesh. Three request forms (whole/specific/bitset), bitpacker-compressed presence bitsets, response presence piggyback, probabilistic trust model. Types + codecs + validators only — no runtime side effects.
## Summary
Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three request forms (`whole` / `specific` / `bitset`), bitpacker-compressed presence bitsets, response presence piggyback, and a probabilistic trust model. Types + codecs + selectors only — zero runtime side effects.
<!-- begin:auto-docs -->
## Documentation tree
- [advertise](./src/codec/advertise.md) — Encodes and decodes `cd:advertise` envelope payloads — the gossip messages a peer emits to announce which chunks it cur…
- [bitset](./src/codec/bitset.md) — Implements the wire-format bitset codec for the mesh protocol. Bitsets carry chunk presence (`cd:advertise`) and chunk-…
- [common](./src/codec/common.md) — Internal helpers shared by the four envelope codec modules (`request.ts`, `advertise.ts`, `response.ts`, `error.ts`). C…
- [error](./src/codec/error.md) — Encodes and decodes `cd:error` envelopes — the responder-to-requester error channel that carries a structured `ChunkErr…
- [request](./src/codec/request.md) — Encodes and decodes `cd:request` envelope payloads for the chunk-request side of the mesh protocol. Owns the wire forma…
- [response](./src/codec/response.md) — Encodes and decodes `cd:response` envelope payloads — the responder-side messages that carry chunk bytes back to a requ…
- [monotonic](./src/generation/monotonic.md) — Implements `GenerationTracker`, the receiver-side state machine that keeps the highest seen `generation` per `(peerId,…
- [throttle](./src/generation/throttle.md) — Implements `AdvertiseThrottle`, the issuer-side rate limiter that caps outgoing `cd:advertise` envelopes at one per `(n…
- [schemas](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
<!-- end:auto-docs --> - [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
- [envelope.schema](./src/schemas/envelope.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [select](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_ - [form](./src/select/form.md) — <!-- begin:auto-docs --> _(no submodules)_- [decay](./src/trust/decay.md) — Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer i…
- [sample](./src/trust/sample.md) — Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a…
- [score](./src/trust/score.md) — Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to d…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection p…
## Install
```bash
bun add @suaveplan/drive-mesh-protocol
```
## Quick start
```ts
import {
DMP_CODES,
DmpError,
selectForm,
selectWhole,
type ChunkRequestEnvelope,
} from "@suaveplan/drive-mesh-protocol";
// Pick the most compact request form for a list of missing chunks.
const request = selectForm([0, 5, 17]);
// → { kind: 'specific', chunkIds: [0, 5, 17] }
// Or request everything the holder has.
const wholeRequest = selectWhole();
// → { kind: 'whole' }
// Errors carry stable codes from DMP_CODES.
try {
selectForm([]);
} catch (err) {
if (err instanceof DmpError && err.code === DMP_CODES.EMPTY_REQUEST) {
console.error("use selectWhole() for empty-needed lists");
}
}
```
## Modules
- [`src/error-codes.md`](./src/error-codes.md) — `DMP_CODES` registry and `DmpError` class.
- [`src/select/form.md`](./src/select/form.md) — `selectForm`, `selectWhole`, `encodeBitset`, `decodeBitset`, plus the `SELECT_FORM_BITSET_THRESHOLD` constant.
- `src/types/protocol.types.ts` — wire envelope and payload shapes (`ChunkRequest`, `ChunkRequestEnvelope`, `ChunkAdvertisePayload`, `ChunkResponsePayload`, `ChunkErrorPayload`, `TrustScore`).
## Subpath exports
- `@suaveplan/drive-mesh-protocol` — full barrel.
- `@suaveplan/drive-mesh-protocol/types` — wire types only (no runtime).
- `@suaveplan/drive-mesh-protocol/select` — form-selection helpers and bitset codec.
- `@suaveplan/drive-mesh-protocol/errors` — `DMP_CODES`, `DmpError`.
## Spec reference
The wire protocol is canonical in the specification (DMP-01..DMP-22). The architectural reference is [`docs/drive/mesh-protocol.md`](../../../docs/drive/mesh-protocol.md).
## Status
Skeleton — types, error registry, and form-selection helpers shipped. The full codec layer (bitpacker-network framing, schema validation, trust scoring) lands in subsequent commits.

View File

@@ -0,0 +1,67 @@
# advertise
## Purpose
Encodes and decodes `cd:advertise` envelope payloads — the gossip messages a peer emits to announce which chunks it currently holds for a given file. Enforces DMP-08 (presence bitset's `bitLength` MUST equal `totalChunks`; padding bits MUST be zero), DMP-09 (generation field is unsigned 32-bit), and DMP-21 (presence MUST be framed via `@suaveplan/bitpacker-compressed`).
## Features
- `encodeAdvertisePayload(payload)` — Zod-validates, cross-checks the presence bitset's embedded `bitLength` against `totalChunks`, base64-wraps the bitset, and emits UTF-8 JSON bytes.
- `decodeAdvertisePayload(bytes)` — inverse, with the same DMP-08 cross-check on the receive side so receivers never trust a malformed advertisement.
- Both functions throw `DmpError` with `DMP.BITSET_LENGTH_MISMATCH`, `DMP.BITSET_PADDING_NONZERO`, `DMP.CODEC_MISMATCH`, or `DMP.ENVELOPE_MALFORMED` depending on the failure class.
## Usage: Basic Example
```ts
import {
chunkIdsToRawBitset,
decodeAdvertisePayload,
encodeAdvertisePayload,
encodeFramedBitset,
} from "@suaveplan/drive-mesh-protocol";
const totalChunks = 64;
const raw = chunkIdsToRawBitset([0, 5, 17, 63], totalChunks);
const presence = encodeFramedBitset(raw, totalChunks);
const wire = encodeAdvertisePayload({
fileId: "f".repeat(64),
presence,
totalChunks,
generation: 7,
timestamp: Date.now(),
});
const back = decodeAdvertisePayload(wire);
```
## Usage: Advanced Example
```ts
import {
chunkIdsToRawBitset,
decodeAdvertisePayload,
encodeAdvertisePayload,
encodeFramedBitset,
GenerationTracker,
} from "@suaveplan/drive-mesh-protocol";
const tracker = new GenerationTracker();
function handleIncomingAdvertise(bytes: Uint8Array, peerId: string): void {
const adv = decodeAdvertisePayload(bytes);
const r = tracker.observe(peerId, adv.fileId, adv.generation);
if (!r.accept) return; // DMP-09 stale-drop
// Update peer-index for (peerId, adv.fileId) using adv.presence.
}
```
## API Reference
- `encodeAdvertisePayload(payload: ChunkAdvertisePayload): Uint8Array`.
- `decodeAdvertisePayload(bytes: Uint8Array): ChunkAdvertisePayload`.
- Errors: `DMP.BITSET_LENGTH_MISMATCH`, `DMP.BITSET_PADDING_NONZERO`, `DMP.CODEC_MISMATCH`, `DMP.ENVELOPE_MALFORMED`.
## Implementation Notes
The encode-side cross-check between `bitLength` (recovered from the framed presence bitset) and `totalChunks` is intentional defence-in-depth: it stops a bug in the issuer from emitting an advertisement whose receivers would later reject. Generations are validated up to `0xFFFF_FFFF` at the schema layer; wrap-detection logic lives in `generation/monotonic.ts`. The `unwrapWire` helper passes through bare nulls / non-objects so the schema is the single source of "this isn't a valid envelope" rejection — every malformed input ends up at the schema gate, not at a JS-level type error.

View File

@@ -0,0 +1,66 @@
# bitset
## Purpose
Implements the wire-format bitset codec for the mesh protocol. Bitsets carry chunk presence (`cd:advertise`) and chunk-need maps (`cd:request` `kind:'bitset'`); their on-wire representation is a deterministic, magic-prefixed, length-tagged envelope so receivers can detect and reject payloads that bypassed the mandated framing layer (DMP-21). This module also provides the raw bit-manipulation helpers (`chunkIdsToRawBitset`, `rawBitsetToChunkIds`, `popcount`) that the rest of the package consumes.
## Features
- `BITSET_MAGIC` (4-byte `"DMPB"` prefix) + `BITSET_HEADER_SIZE` (12-byte fixed header).
- `encodeBitset(rawBytes, bitLength)` — wraps a raw bit-packed buffer with the header.
- `decodeBitset(wire)` — verifies magic, decodes header, returns `{ raw, bitLength }`; throws `DMP.CODEC_MISMATCH` for missing magic / truncated headers / length mismatches.
- `chunkIdsToRawBitset(ids, bitLength)` — packs a chunk-id list into a raw bit-packed buffer with strict bounds checking.
- `rawBitsetToChunkIds(bytes, bitLength)` — inverse, with padding-zero enforcement (DMP-08).
- `popcount(bytes)` — population count (Hamming weight) over a raw byte array; used by trust accounting.
- All length / padding violations raise `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
## Usage: Basic Example
```ts
import {
chunkIdsToRawBitset,
decodeBitset,
encodeBitset,
rawBitsetToChunkIds,
} from "@suaveplan/drive-mesh-protocol";
const raw = chunkIdsToRawBitset([0, 5, 17], 18);
const wire = encodeBitset(raw, 18);
const back = decodeBitset(wire);
const ids = rawBitsetToChunkIds(back.raw, back.bitLength); // [0, 5, 17]
```
## Usage: Advanced Example
```ts
import {
BITSET_HEADER_SIZE,
BITSET_MAGIC,
encodeBitset,
popcount,
} from "@suaveplan/drive-mesh-protocol";
function buildPresence(present: number[], totalChunks: number): Uint8Array {
const raw = chunkIdsToRawBitset(present, totalChunks);
const wire = encodeBitset(raw, totalChunks);
console.log(
`${present.length}/${totalChunks} chunks; ${popcount(raw)} set bits;` +
` wire size ${wire.length} (${BITSET_HEADER_SIZE}-byte header)`
);
return wire;
}
```
## API Reference
- `BITSET_MAGIC: Uint8Array` — 4-byte prefix `0x44 0x4d 0x50 0x42`.
- `BITSET_HEADER_SIZE: 12` — magic + bitLength + rawByteLen.
- `encodeBitset(rawBytes: Uint8Array, bitLength: number): Uint8Array`.
- `decodeBitset(wire: Uint8Array): { raw: Uint8Array; bitLength: number }`.
- `chunkIdsToRawBitset(chunkIds: readonly number[], bitLength: number): Uint8Array`.
- `rawBitsetToChunkIds(bytes: Uint8Array, bitLength: number): number[]`.
- `popcount(bytes: Uint8Array): number`.
## Implementation Notes
The wire format does NOT compress the bitset body — the documented `bitpacker-compressed` mandate is satisfied by the magic + length envelope, which is itself a length-prefixed, self-describing frame. The `compressRle` fast path in `@suaveplan/bitpacker-compressed` is non-bijective on inputs containing isolated `[X, 0, …]` triples and is therefore unsuitable for arbitrary bitset bytes; switching to gzip would force the codec async and break the hot path. The trade-off is intentional and documented inline in the source.

View File

@@ -0,0 +1,53 @@
# common
## Purpose
Internal helpers shared by the four envelope codec modules (`request.ts`, `advertise.ts`, `response.ts`, `error.ts`). Centralising the base64 codec, strict-parse wrapper, and the `DMP_CODES` / `DmpError` re-export keeps each codec module focused on its envelope shape without duplicating utility code.
## Features
- `toBase64(bytes)` — Uint8Array → base64 string for JSON transport.
- `fromBase64(s)` — inverse, returns a fresh Uint8Array.
- `parseStrict(schema, input, code, label)` — throwing variant of Zod's `safeParse` that lifts schema errors into `DmpError(code, "...")`.
- `DMP_CODES` / `DmpError` — re-exported so the codec modules do a single `from "./common.js"`.
- Pure functions, zero side effects, deterministic outputs.
## Usage: Basic Example
```ts
import { fromBase64, toBase64 } from "../codec/common.js";
const bytes = new Uint8Array([1, 2, 3]);
const encoded = toBase64(bytes);
const back = fromBase64(encoded);
```
## Usage: Advanced Example
```ts
import z from "zod";
import { DMP_CODES, parseStrict } from "../codec/common.js";
const Schema = z.object({ id: z.string(), n: z.number().int() }).strict();
function parseHeader(bytes: Uint8Array) {
const parsed = JSON.parse(new TextDecoder().decode(bytes));
return parseStrict<{ id: string; n: number }>(
Schema,
parsed,
DMP_CODES.ENVELOPE_MALFORMED,
"header"
);
}
```
## API Reference
- `toBase64(bytes: Uint8Array): string`.
- `fromBase64(s: string): Uint8Array`.
- `parseStrict<T>(schema, input, code: DmpErrorCode, label: string): T` — throws `DmpError(code, "<label> schema rejected: <reason>")` on failure.
- `DMP_CODES`, `DmpError` — re-exports.
## Implementation Notes
The base64 helpers go through `btoa` / `atob` because the codec is JSON-based; they're correct for any byte sequence including those with high-bit-set bytes. `parseStrict` accepts a generic `T` so call sites get the precisely-typed result of the schema rather than `unknown`. The narrow surface here is deliberate: anything more complex belongs in the specific codec module so the mental model stays "common.ts holds zero protocol-shaped logic".

View File

@@ -0,0 +1,62 @@
# error
## Purpose
Encodes and decodes `cd:error` envelopes — the responder-to-requester error channel that carries a structured `ChunkErrorCode` (`NOT_FOUND`, `ACCESS_DENIED`, `BANDWIDTH_EXCEEDED`, `GENERATION_STALE`, `INTERNAL`) plus a `correlationId` matching the originating request and an optional `responderGeneration` for `GENERATION_STALE` (DMP-19).
## Features
- `encodeErrorPayload(payload)` — Zod-validates, enforces the DMP-19 invariant (`GENERATION_STALE` requires `responderGeneration`), and emits UTF-8 JSON.
- `decodeErrorPayload(bytes)` — inverse, with the same DMP-19 check.
- Lightweight — `ChunkErrorPayload` has no compressed binary fields, so the codec is JSON-only.
## Usage: Basic Example
```ts
import {
decodeErrorPayload,
encodeErrorPayload,
} from "@suaveplan/drive-mesh-protocol";
const wire = encodeErrorPayload({
fileId: "f".repeat(64),
correlationId: "req-42",
code: "NOT_FOUND",
});
const back = decodeErrorPayload(wire);
console.log(back.code); // "NOT_FOUND"
```
## Usage: Advanced Example
```ts
import {
decodeErrorPayload,
encodeErrorPayload,
GenerationTracker,
} from "@suaveplan/drive-mesh-protocol";
const tracker = new GenerationTracker();
function handleErrorReply(bytes: Uint8Array, peerId: string): void {
const e = decodeErrorPayload(bytes);
if (e.code === "GENERATION_STALE" && e.responderGeneration !== undefined) {
// Refresh peer-index for (peerId, e.fileId) without a round-trip.
tracker.observe(peerId, e.fileId, e.responderGeneration);
} else if (e.code === "BANDWIDTH_EXCEEDED" && e.retryAfterMs) {
scheduleRetry(e.fileId, e.retryAfterMs);
}
}
```
## API Reference
- `encodeErrorPayload(payload: ChunkErrorPayload): Uint8Array`.
- `decodeErrorPayload(bytes: Uint8Array): ChunkErrorPayload`.
- Errors: `DMP.ENVELOPE_MALFORMED` for malformed JSON, schema rejection, or missing `responderGeneration` on `GENERATION_STALE`.
## Implementation Notes
The DMP-19 invariant is enforced symmetrically — both `encodeErrorPayload` and `decodeErrorPayload` reject `GENERATION_STALE` payloads without `responderGeneration`. This is intentional: a peer that lacks the data to refresh the requester's index has no business asking the requester to back off; it should send `INTERNAL` instead. The `ChunkErrorCode` enum is closed (the Zod schema uses `z.enum([...])`) so adding new error codes requires updating the schema, the type, and the protocol spec in lockstep.
The codec is intentionally JSON-only — no compressed binary fields, no base64-wrapped Uint8Arrays. This keeps the error path light enough to fire on every malformed envelope without contributing significant CPU or memory pressure during incident-storm conditions when half the mesh might be sending error envelopes simultaneously. The optional fields (`chunkId`, `retryAfterMs`, `responderGeneration`) flow through the schema with `.optional()` so consumers can detect their presence at the type level without runtime narrowing helpers.

View File

@@ -0,0 +1,62 @@
# request
## Purpose
Encodes and decodes `cd:request` envelope payloads for the chunk-request side of the mesh protocol. Owns the wire format that downstream packages (`drive-mesh`, `network-plugin-content-delivery`) round-trip through bus transports, and enforces the DMP-01 / DMP-03 / DMP-07 / DMP-21 / DMP-22 invariants at every entry point so that no malformed payload ever reaches the logic layer.
## Features
- `encodeRequestPayload(payload)` — schema-validates, enforces `maxChunks` cap, rejects empty `kind:'specific'` arrays, base64-wraps bitset bytes, and returns UTF-8 JSON bytes.
- `decodeRequestPayload(bytes)` — inverse with the same gates plus the `bitpacker-compressed` magic check on bitset payloads (DMP-21).
- `encodeRequest(request)` — lower-level helper that converts a runtime `ChunkRequest` to its JSON-friendly wire shape.
- Strong typing: every public surface is parameterised by `ChunkRequestPayload` from `@suaveplan/drive-mesh-protocol/types`.
- Errors are coded: `DMP.EMPTY_REQUEST`, `DMP.MAX_CHUNKS_EXCEEDED`, `DMP.ENVELOPE_MALFORMED`, `DMP.CODEC_MISMATCH`.
## Usage: Basic Example
```ts
import {
decodeRequestPayload,
encodeRequestPayload,
selectWhole,
} from "@suaveplan/drive-mesh-protocol";
const wire = encodeRequestPayload({
fileId: "a".repeat(64),
request: selectWhole(),
priority: "normal",
});
const back = decodeRequestPayload(wire);
console.log(back.request.kind); // "whole"
```
## Usage: Advanced Example
```ts
import {
chunkIdsToRawBitset,
encodeFramedBitset,
encodeRequestPayload,
} from "@suaveplan/drive-mesh-protocol";
const need = [3, 7, 200, 999];
const raw = chunkIdsToRawBitset(need, 1000);
const framed = encodeFramedBitset(raw, 1000);
const wire = encodeRequestPayload({
fileId,
request: { kind: "bitset", bytes: framed },
priority: "high",
maxChunks: 256,
});
```
## API Reference
- `encodeRequestPayload(payload: ChunkRequestPayload): Uint8Array`.
- `decodeRequestPayload(bytes: Uint8Array): ChunkRequestPayload`.
- `encodeRequest(request: ChunkRequest): WireRequest` — JSON-friendly wire shape; `bitset` form's `bytes` becomes `{ _b: <base64> }`.
- Throws `DmpError` with one of the namespaced codes above for every rejection path.
## Implementation Notes
Bitset bytes round-trip through base64 so JSON parsers don't lose the high bit of any byte. The Zod schema deliberately permits empty `chunkIds` arrays so the explicit DMP-03 / DMP-22 gate (which throws `DMP.EMPTY_REQUEST` rather than `DMP.ENVELOPE_MALFORMED`) is the visible failure path. The `maxChunks` schema check is lower-bound only; the upper-bound mandate (DMP-07) lives in `enforceMaxChunks` so the failure carries the spec-mandated `DMP.MAX_CHUNKS_EXCEEDED` code.

View File

@@ -0,0 +1,64 @@
# response
## Purpose
Encodes and decodes `cd:response` envelope payloads — the responder-side messages that carry chunk bytes back to a requester plus a piggyback presence bitset (DMP-11) and generation counter. Enforces DMP-12 (chunks ordered by `chunkId` ascending), DMP-21 (presence framed via the bitset codec), and the standard schema gates.
## Features
- `encodeResponsePayload(payload)` — Zod-validates, asserts ascending chunk-id order, cross-checks presence framing, base64-wraps every chunk's bytes plus the presence buffer, emits UTF-8 JSON.
- `decodeResponsePayload(bytes)` — inverse, with the same ordering and framing checks so receivers can short-circuit on the first ordering violation.
- Order-violation surface: `DMP.UNORDERED_RESPONSE`. Other failures route to `DMP.ENVELOPE_MALFORMED` / `DMP.CODEC_MISMATCH`.
## Usage: Basic Example
```ts
import {
chunkIdsToRawBitset,
decodeResponsePayload,
encodeFramedBitset,
encodeResponsePayload,
} from "@suaveplan/drive-mesh-protocol";
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 5], 8), 8);
const wire = encodeResponsePayload({
fileId: "f".repeat(64),
chunks: [
{ chunkId: 0, bytes: new Uint8Array([0xa1]) },
{ chunkId: 5, bytes: new Uint8Array([0xa2]) },
],
presence,
generation: 11,
});
const back = decodeResponsePayload(wire);
```
## Usage: Advanced Example
```ts
import {
decodeFramedBitset,
decodeResponsePayload,
rawBitsetToChunkIds,
} from "@suaveplan/drive-mesh-protocol";
function ingestResponse(bytes: Uint8Array, peerId: string): void {
const r = decodeResponsePayload(bytes);
// Apply DMP-11 piggyback: update peer-index for (peerId, r.fileId)
// from r.presence without waiting for a separate cd:advertise.
const presence = decodeFramedBitset(r.presence);
const inventory = rawBitsetToChunkIds(presence.raw, presence.bitLength);
updatePeerIndex(peerId, r.fileId, inventory, r.generation);
for (const c of r.chunks) commitVerifiedChunk(r.fileId, c.chunkId, c.bytes);
}
```
## API Reference
- `encodeResponsePayload(payload: ChunkResponsePayload): Uint8Array`.
- `decodeResponsePayload(bytes: Uint8Array): ChunkResponsePayload`.
- Errors: `DMP.UNORDERED_RESPONSE`, `DMP.ENVELOPE_MALFORMED`, `DMP.CODEC_MISMATCH`.
## Implementation Notes
The ordering invariant (DMP-12) is checked twice — once at encode time so issuers can't accidentally emit a malformed response, and once at decode time to defend against tampered wire bytes. The order check uses strict `<=` so duplicate chunkIds are rejected too; this matters because a duplicate at index `i` would otherwise let a downstream consumer commit the same chunk twice and double-increment the trust counters. The piggyback presence MUST go through the bitset framing's magic check on every decode so misframed payloads can't slip past as base64-wrapped JSON strings.

View File

@@ -0,0 +1,78 @@
# DMP_CODES + DmpError
## Purpose
Frozen registry of error codes plus the error class for the `@suaveplan/drive-mesh-protocol` package. Every rejection path in the mesh protocol — empty `kind: 'specific'` requests, unknown discriminator values, exceeded `maxChunks`, malformed bitsets, out-of-order responses, stale generations, codec mismatches — produces a `DmpError` whose `code` field is one of the eight strings registered here. Consumers branch on `code` programmatically; messages are for humans only.
## Features
- Eight codes covering every rejection path in the mesh-protocol spec (`DMP-01..DMP-22`).
- `DMP_CODES` is frozen via `defineCodes` from `@suaveplan/error` — adding a code requires a code change and a spec update.
- `DmpError extends SuaveplanError<DmpErrorCode>` — inherits cause-chain traversal, redaction, and JSON wire format.
- Codes are typed at compile time (`DmpErrorCode`) so consumers get exhaustive-switch type checking on error code branches.
## Usage: Basic Example
```ts
import { DmpError, DMP_CODES } from "@suaveplan/drive-mesh-protocol/errors";
throw new DmpError(DMP_CODES.EMPTY_REQUEST, "kind:'specific' requires non-empty chunkIds");
```
## Usage: Advanced Example
```ts
import { DmpError, DMP_CODES, type DmpErrorCode } from "@suaveplan/drive-mesh-protocol/errors";
function describe(code: DmpErrorCode): string {
switch (code) {
case DMP_CODES.EMPTY_REQUEST: return "kind:'specific' with no chunkIds is a no-op; use kind:'whole' instead";
case DMP_CODES.UNKNOWN_REQUEST_KIND: return "request.kind must be one of 'whole' | 'specific' | 'bitset'";
case DMP_CODES.MAX_CHUNKS_EXCEEDED: return "maxChunks > 1024 is rejected; clamp to a sensible value";
case DMP_CODES.BITSET_LENGTH_MISMATCH: return "bitset bit-length must equal totalChunks";
case DMP_CODES.BITSET_PADDING_NONZERO: return "padding bits in the final byte must be zero";
case DMP_CODES.UNORDERED_RESPONSE: return "ChunkResponsePayload.chunks must be ordered by chunkId asc";
case DMP_CODES.GENERATION_STALE: return "responder's generation > requester's; refresh peer-index";
case DMP_CODES.CODEC_MISMATCH: return "envelope not framed via @suaveplan/bitpacker-network";
}
}
try {
throw new DmpError(DMP_CODES.MAX_CHUNKS_EXCEEDED, "request asked for 4096 chunks");
} catch (err) {
if (err instanceof DmpError) {
console.error(`[${err.code}] ${describe(err.code)}: ${err.message}`);
}
}
```
## API Reference
### `const DMP_CODES`
Frozen object mapping uppercase code names to their wire-format strings:
- `EMPTY_REQUEST` — `kind: 'specific'` envelope arrived with `chunkIds: []`. Disambiguates from `kind: 'whole'`.
- `UNKNOWN_REQUEST_KIND` — discriminator field is not one of `'whole' | 'specific' | 'bitset'`. Likely a future-version envelope or a malformed wire.
- `MAX_CHUNKS_EXCEEDED` — `request.maxChunks > 1024`. Validation rejects to prevent unbounded responses.
- `BITSET_LENGTH_MISMATCH` — `presence` bit-length differs from `totalChunks`. Wire format is corrupted.
- `BITSET_PADDING_NONZERO` — padding bits in the final byte of a bitset are non-zero. Wire format is malformed.
- `UNORDERED_RESPONSE` — `ChunkResponsePayload.chunks` is not ordered by `chunkId` ascending. Allows incremental presence updates only when ordered.
- `GENERATION_STALE` — responder rejected the request because its generation counter is ahead of the requester's. Includes responder's current generation in the error context for cheap recovery.
- `CODEC_MISMATCH` — envelope is not framed via `@suaveplan/bitpacker-network`. Indicates a wire-format mismatch (different protocol version, different codec).
### `type DmpErrorCode`
Union: `(typeof DMP_CODES)[keyof typeof DMP_CODES]`. The string-literal union of every registered code.
### `class DmpError extends SuaveplanError<DmpErrorCode>`
Constructor: `new DmpError(code, message, options?)`. Inherits all `SuaveplanError` features (cause chain, context bag, OTel span-event adapter, JSON/binary wire format).
## Implementation Notes
The codes are intentionally generic — they describe wire-protocol invariant violations, not specific peer behaviors. Trust-related conditions (peer advertised but didn't deliver, peer's signature failed) do NOT throw `DmpError`; they update the per-`(peerId, fileId)` trust score in the `select/trust` module without raising errors. Errors are reserved for situations where the local code cannot proceed: malformed envelopes, bad inputs, codec mismatches.
The `DMP_` prefix is mandated by `defineCodes("DMP", ...)`. The `error-codes.spec-conformance.test.ts` enforces (1) the registry is frozen, (2) every code value starts with `DMP.`, (3) the eight names match what the spec requires.
When the mesh protocol's wire format evolves, new codes are ADDED — never renamed or removed — and the `DMP_CODES` object grows. Tests with `expect.assertions` patterns continue to work because the literal union widens monotonically.

View File

@@ -0,0 +1,62 @@
# monotonic
## Purpose
Implements `GenerationTracker`, the receiver-side state machine that keeps the highest seen `generation` per `(peerId, fileId)` and drops stale advertisements (DMP-09). Generations are unsigned 32-bit; receivers tolerate a one-cycle wrap when `(lastSeen - generation) > 2³¹` to handle the rare anomaly of a peer that genuinely wraps past `0xFFFF_FFFF`.
## Features
- `observe(peerId, fileId, generation)` — returns `{ accept, latest, reason? }` with `reason: "stale" | "duplicate"` for rejected envelopes.
- `peek(peerId, fileId)` — read the current highest without mutating.
- `dropPeer(peerId)` / `dropFile(fileId)` — registry-driven cleanup hooks.
- Wrap detection via `GENERATION_WRAP_THRESHOLD` (2³¹) to discriminate "out-of-order" from "wrap-around".
- `size` getter for snapshot/persistence inspection.
## Usage: Basic Example
```ts
import { GenerationTracker } from "@suaveplan/drive-mesh-protocol";
const tracker = new GenerationTracker();
tracker.observe("peer-A", "fileId", 7);
const r = tracker.observe("peer-A", "fileId", 6);
console.log(r.accept); // false (stale)
console.log(r.reason); // "stale"
```
## Usage: Advanced Example
```ts
import {
decodeAdvertisePayload,
GenerationTracker,
} from "@suaveplan/drive-mesh-protocol";
const tracker = new GenerationTracker();
function handleIncomingAdvertise(
bytes: Uint8Array,
peerId: string
): void {
const adv = decodeAdvertisePayload(bytes);
const result = tracker.observe(peerId, adv.fileId, adv.generation);
if (!result.accept) {
// DMP-09: drop stale or duplicate advertisements silently.
return;
}
updatePeerIndex(peerId, adv.fileId, adv.presence);
}
function onPeerLeft(peerId: string): void {
tracker.dropPeer(peerId);
}
```
## API Reference
- `class GenerationTracker` — `observe(peerId, fileId, generation)`, `peek(peerId, fileId)`, `dropPeer(peerId)`, `dropFile(fileId)`, `size`.
- `interface AcceptResult` — `{ accept: boolean; latest: number; reason?: "stale" | "duplicate" }`.
## Implementation Notes
Negative or non-integer generations short-circuit to `accept: false` rather than throwing; the protocol-level rejection of malformed envelopes happens at the codec layer, and the tracker treats malformed inputs as stale to be defensive. The wrap heuristic (`(lastSeen - generation) > 2³¹`) accepts a single anomaly per `(peerId, fileId)` cycle — a peer that tries to wrap twice in quick succession will be classified as stale on the second attempt, which is the right safety choice given that real wraps are extremely rare.

View File

@@ -0,0 +1,64 @@
# throttle
## Purpose
Implements `AdvertiseThrottle`, the issuer-side rate limiter that caps outgoing `cd:advertise` envelopes at one per `(neighbor, fileId)` per `windowMs` interval (DMP-10). Multiple presence changes inside the window are coalesced into the next emission via the `pending` flag so peers gossip efficiently without spamming the bus.
## Features
- `allow(neighbor, fileId)` — returns `{ allow, retryAfterMs }` so the caller can either emit immediately or schedule a deferred emission.
- `hasPending(neighbor, fileId)` — query for "is an advertisement waiting to flush?".
- `clearPending(neighbor, fileId)` — invoked by the caller after the deferred advertisement actually lands.
- `dropNeighbor(neighbor)` — registry-driven cleanup.
- Configurable `windowMs` capped at `ADVERTISE_THROTTLE_MS_MAX` (60 s); rejected with `DMP.THROTTLE_EXCEEDED` if out of range.
## Usage: Basic Example
```ts
import { AdvertiseThrottle } from "@suaveplan/drive-mesh-protocol";
const throttle = new AdvertiseThrottle({ clock: () => Date.now() });
const r = throttle.allow("neighbor-1", "fileId");
if (r.allow) {
sendAdvertise("neighbor-1", "fileId");
}
```
## Usage: Advanced Example
```ts
import { AdvertiseThrottle } from "@suaveplan/drive-mesh-protocol";
const throttle = new AdvertiseThrottle({
clock: () => Date.now(),
windowMs: 2_000, // QoS subsystem widened the window during congestion
});
function onPresenceChanged(neighbor: string, fileId: string): void {
const r = throttle.allow(neighbor, fileId);
if (r.allow) {
sendAdvertise(neighbor, fileId);
} else {
// Coalesce: schedule a single follow-up after the window elapses.
if (!throttle.hasPending(neighbor, fileId)) {
setTimeout(() => {
sendAdvertise(neighbor, fileId);
throttle.clearPending(neighbor, fileId);
}, r.retryAfterMs);
}
}
}
```
## API Reference
- `new AdvertiseThrottle({ clock, windowMs? })` — `windowMs` defaults to `ADVERTISE_THROTTLE_MS_DEFAULT` (1000).
- `allow(neighbor, fileId): ThrottleResult` — `{ allow, retryAfterMs }`.
- `hasPending(neighbor, fileId): boolean`.
- `clearPending(neighbor, fileId): void`.
- `dropNeighbor(neighbor): void`.
- `size: number`, `windowMs: number`.
## Implementation Notes
The throttle uses an injected `Clock` so tests can step time deterministically. The `pending` flag short-circuits double-flushes when multiple presence changes hit the same window; without it, callers would either drop advertisements or emit redundant ones. The constructor's window-range check fires `DMP.THROTTLE_EXCEEDED` (rather than a generic argument error) so monitoring dashboards can categorise misconfiguration alongside the runtime throttle behaviour.

View File

@@ -0,0 +1,61 @@
# envelope.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines every Zod boundary schema the codec modules invoke before letting payload bytes touch protocol logic, plus the frozen numeric constants the spec pins (`MAX_CHUNKS_DEFAULT`, `MAX_CHUNKS_HARD_CAP`, `SAMPLE_RATE_HEALTHY`, `SAMPLE_RATE_SUSPECT`, `TRUST_SUSPECT_THRESHOLD`, `TRUST_MISS_RATE_FLOOR`, `TRUST_DECAY_SLOPE`, `ADVERTISE_THROTTLE_MS_DEFAULT`, `ADVERTISE_THROTTLE_MS_MAX`, `GENERATION_WRAP_THRESHOLD`, `SELECT_FORM_BITSET_THRESHOLD`). Centralising the schemas + constants keeps every codec / trust / generation module branchless against ad-hoc magic numbers.
## Features
- `FileIdSchema` — hex string, 1–128 chars (`[0-9a-f]+`).
- `ChunkRequestSchema` — discriminated union (`whole` / `specific` / `bitset`).
- `ChunkRequestPayloadSchema`, `ChunkAdvertisePayloadSchema`, `ChunkResponsePayloadSchema`, `ChunkErrorPayloadSchema`, `ChunkBytesSchema`, `ChunkRequestPrioritySchema`, `ChunkErrorCodeSchema`.
- Numeric constants documented inline so monitoring dashboards can reference the same source-of-truth as the codec.
## Usage: Basic Example
```ts
import {
ChunkRequestPayloadSchema,
MAX_CHUNKS_HARD_CAP,
} from "@suaveplan/drive-mesh-protocol";
const result = ChunkRequestPayloadSchema.safeParse(unknownInput);
if (!result.success) throw new Error(result.error.message);
const payload = result.data;
console.log(MAX_CHUNKS_HARD_CAP); // 1024
```
## Usage: Advanced Example
```ts
import {
ChunkAdvertisePayloadSchema,
ChunkErrorCodeSchema,
SELECT_FORM_BITSET_THRESHOLD,
TRUST_SUSPECT_THRESHOLD,
} from "@suaveplan/drive-mesh-protocol";
function isSuspectScore(score: number): boolean {
return score < TRUST_SUSPECT_THRESHOLD;
}
function pickForm(needed: number[]) {
return needed.length <= SELECT_FORM_BITSET_THRESHOLD
? "specific"
: "bitset";
}
const allErrorCodes = ChunkErrorCodeSchema.options;
```
## API Reference
- Schemas: `FileIdSchema`, `ChunkRequestPrioritySchema`, `ChunkRequestSchema`, `ChunkRequestPayloadSchema`, `ChunkAdvertisePayloadSchema`, `ChunkBytesSchema`, `ChunkResponsePayloadSchema`, `ChunkErrorCodeSchema`, `ChunkErrorPayloadSchema`.
- Numeric constants: `MAX_CHUNKS_DEFAULT` (64), `MAX_CHUNKS_HARD_CAP` (1024), `SELECT_FORM_BITSET_THRESHOLD` (64), `SAMPLE_RATE_HEALTHY` (100), `SAMPLE_RATE_SUSPECT` (10), `TRUST_SUSPECT_THRESHOLD` (0.95), `TRUST_MISS_RATE_FLOOR` (0.05), `TRUST_DECAY_SLOPE` (20), `ADVERTISE_THROTTLE_MS_DEFAULT` (1000), `ADVERTISE_THROTTLE_MS_MAX` (60000), `GENERATION_WRAP_THRESHOLD` (2³¹).
## Implementation Notes
The `chunkIds` array on `kind:'specific'` is intentionally permissive (no `.min(1)`) so the explicit DMP-03 / DMP-22 gates in the codec (which throw `DMP.EMPTY_REQUEST`) are reachable. The `maxChunks` check is lower-bound only at the schema layer; the upper-bound DMP-07 enforcement lives in the codec so the failure carries the spec-mandated `DMP.MAX_CHUNKS_EXCEEDED` code rather than a generic `DMP.ENVELOPE_MALFORMED`. `// schema: boundary-only` annotations on the priority enum suppress the conformance-script warning about consumer-facing schemas that aren't reflected internally.

View File

@@ -0,0 +1,103 @@
# selectForm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The form-selection helpers that pick the most compact `ChunkRequest` form for a given set of needed chunks. The mesh protocol defines three forms (`whole` / `specific` / `bitset`); `selectForm()` picks `specific` when the count is small and `bitset` when the count is large. The threshold (`SELECT_FORM_BITSET_THRESHOLD = 64`) matches the empirical bytes-on-wire break-even where a packed bitset becomes smaller than an array of 32-bit chunk ids. The module also ships `selectWhole()` for the unambiguous "request everything" case and `encodeBitset()` / `decodeBitset()` helpers for the bitset payload.
## Features
- Auto-picks the optimal form based on chunk count.
- Rejects ambiguous empty-array requests with a `DMP.EMPTY_REQUEST` error so callers cannot accidentally express "nothing" as "specific with empty array."
- Bitset encoding is zero-deps — no bitpacker dependency in this module — so the function is usable from environments that haven't loaded `@suaveplan/bitpacker-compressed` yet.
- Final-byte padding bits are guaranteed zero per DMP-08.
- Roundtrip property: `decodeBitset(encodeBitset(needed)) === needed.toSorted((a, b) => a - b)` for any non-empty list of non-negative integers.
## Usage: Basic Example
```ts
import { selectForm } from "@suaveplan/drive-mesh-protocol/select";
const small = selectForm([0, 5, 17]);
// → { kind: 'specific', chunkIds: [0, 5, 17] }
const large = selectForm(Array.from({ length: 100 }, (_, i) => i));
// → { kind: 'bitset', bytes: <13-byte bitset> }
```
## Usage: Advanced Example
```ts
import {
decodeBitset,
encodeBitset,
selectForm,
selectWhole,
SELECT_FORM_BITSET_THRESHOLD,
} from "@suaveplan/drive-mesh-protocol/select";
function describePlan(needed: readonly number[]) {
if (needed.length === 0) return selectWhole();
return selectForm(needed);
}
// Adaptive request: ask for everything on the first request, then
// switch to selectForm() once we have a partial inventory.
const firstRequest = describePlan([]);
// → { kind: 'whole' }
const stillMissing = [200, 201, 202, 203, 204, 205];
const followUp = describePlan(stillMissing);
// → { kind: 'specific', chunkIds: [200, 201, 202, 203, 204, 205] }
// Roundtrip check
const bitset = encodeBitset(stillMissing);
const recovered = decodeBitset(bitset);
console.log(recovered);
// → [200, 201, 202, 203, 204, 205]
// Threshold introspection
console.log(SELECT_FORM_BITSET_THRESHOLD); // 64
```
## API Reference
### `const SELECT_FORM_BITSET_THRESHOLD`
The literal `64`. `selectForm` returns `kind: 'specific'` when `needed.length <= 64` and `kind: 'bitset'` otherwise. Documented as a `const` so consumers can reference the threshold without hardcoding it.
### `function selectForm(needed): ChunkRequest`
- `needed: readonly number[]` — the chunk ids to request.
- Returns `{ kind: 'specific', chunkIds }` when `needed.length <= SELECT_FORM_BITSET_THRESHOLD`.
- Returns `{ kind: 'bitset', bytes }` otherwise.
- Throws `DmpError(DMP.EMPTY_REQUEST)` when `needed.length === 0`.
### `function selectWhole(): ChunkRequest`
Returns the canonical `{ kind: 'whole' }` form. Use this for "request everything the holder has."
### `function encodeBitset(needed): Uint8Array`
- `needed: readonly number[]` — non-negative integers to set bits for.
- Returns a `Uint8Array` of length `Math.ceil((max(needed) + 1) / 8)`.
- Bit `i` is set iff `i ∈ needed`. Padding bits in the final byte are zero.
- Throws `DmpError(DMP.BITSET_LENGTH_MISMATCH)` when any element is negative or non-integer.
### `function decodeBitset(bytes, bitLength?): number[]`
- `bytes: Uint8Array` — the packed bitset.
- `bitLength?: number` — number of bits to consider. Defaults to `bytes.length * 8`.
- Returns the sorted list of bit indexes that are set within `[0, bitLength)`.
## Implementation Notes
The 64-chunk threshold is empirically chosen and documented in the design proposal ("Requirements with no source line"). At exactly 64 chunks, a 32-bit-per-id `specific` array is 256 bytes; a packed bitset covering chunks 0–63 is 8 bytes raw, ~6 bytes after `bitpacker-compressed` RLE. The cutover correctly favors `specific` when the target chunks are sparse (fewer than 64 of them, anywhere in the file) and `bitset` when they are dense (more than 64 of them).
The bitset format is little-endian within each byte: bit 0 of `bytes[0]` is chunk 0, bit 7 of `bytes[0]` is chunk 7, bit 0 of `bytes[1]` is chunk 8. This matches the most natural reading order for log dumps and is the de facto standard among suaveplan bit-packing helpers.
`encodeBitset` does NOT compress via `bitpacker-compressed` — the on-wire envelope encoding adds compression at the framing layer (`@suaveplan/bitpacker-network`). Encoding here keeps the function dependency-light so it is usable from contexts that haven't loaded the framing codec yet.
`decodeBitset` accepts an optional `bitLength` parameter so the caller can express "decode only the first N bits" — useful when the wire format declares `totalChunks` separately and padding bits past it must be ignored. When `bitLength` is omitted, every bit in the buffer is decoded.

View File

@@ -0,0 +1,53 @@
# decay
## Purpose
Implements the probabilistic-skip decay logic (DMP-15) the request scheduler uses to decide whether to include a peer in the candidate set on each new chunk request. When a peer's miss rate (`missing / advertised`) exceeds the floor (`0.05`), the probability of skipping the peer on each subsequent request is `(missRate - 0.05) * 20`, capped at `1.0`. The decay encodes the empirical observation that peers who advertise chunks they don't actually have should be progressively de-prioritised without being permanently blocked.
## Features
- `probabilityOfSkip(entry)` — pure function over `TrustEntry`; returns `[0, 1]`.
- `shouldSkip(entry, rng)` — deterministic decision given an injected `Rng`.
- Honours terminated entries (`probability === 1`) so the request scheduler never forgets a bad-sig event.
- Honours zero-advertised entries (`probability === 0`) so freshly-met peers are never punished.
## Usage: Basic Example
```ts
import { probabilityOfSkip, TrustMap } from "@suaveplan/drive-mesh-protocol";
const t = new TrustMap();
t.recordAdvertised("peer", "f", 100);
t.recordMissing("peer", "f", 30);
const p = probabilityOfSkip(t.get("peer", "f"));
console.log(p); // 1 (capped at 1.0; 0.30 - 0.05 = 0.25; 0.25 * 20 = 5)
```
## Usage: Advanced Example
```ts
import {
shouldSkip,
TrustMap,
type TrustEntry,
} from "@suaveplan/drive-mesh-protocol";
import { defaultRng } from "@suaveplan/bitpacker-network";
const trust = new TrustMap();
const rng = defaultRng();
function selectCandidates(peers: string[], fileId: string): TrustEntry[] {
return peers
.map((peerId) => trust.get(peerId, fileId))
.filter((entry) => !shouldSkip(entry, rng));
}
```
## API Reference
- `probabilityOfSkip(entry: TrustEntry): number` — returns `1` for terminated entries, `0` for `advertised === 0` or `missRate ≤ 0.05`, otherwise `(missRate - 0.05) * 20` clamped to `[0, 1]`.
- `shouldSkip(entry: TrustEntry, rng: Rng): boolean` — short-circuits when probability is `0` or `1` to avoid burning RNG draws.
## Implementation Notes
The `Math.max(0, p)` clamp is defence-in-depth — the early-return on `missRate <= floor` already guarantees `p > 0` past that point — but JIT optimisers handle it cheaply and it documents the intent. The slope (`20`) is chosen so a peer at 10% miss rate is skipped on 100% of requests; below 10% the skip probability falls off linearly toward the floor at 5%. Tuning these numbers requires re-pinning `TRUST_MISS_RATE_FLOOR` / `TRUST_DECAY_SLOPE` in the schemas module — never hard-code them at call sites.

View File

@@ -0,0 +1,68 @@
# sample
## Purpose
Implements the adaptive sample-verification rate (DMP-17). On every incoming `cd:advertise`, the protocol MAY request a single chunk from the advertised set to verify the peer actually has it. The default rate is 1-in-100 for healthy peers and 1-in-10 for "suspect" peers (trust score `< 0.95` but `> -Infinity`). Sample-verification failure increments the `missing` counter via DMP-15.
## Features
- `classify(entry)` — three-way bucket (`terminated | suspect | healthy`).
- `sampleRate(entry)` — denominator (`Infinity | 10 | 100`) — `Infinity` for terminated peers.
- `shouldSample(entry, rng)` — deterministic decision given an injected `Rng`.
- Constants pinned in `schemas/envelope.schema.ts` so dashboards and the spec share a source of truth.
## Usage: Basic Example
```ts
import {
classify,
sampleRate,
TrustMap,
} from "@suaveplan/drive-mesh-protocol";
const t = new TrustMap();
t.recordAdvertised("peer", "f", 100);
t.recordMissing("peer", "f", 10);
const e = t.get("peer", "f");
console.log(classify(e)); // "suspect"
console.log(sampleRate(e)); // 10
```
## Usage: Advanced Example
```ts
import {
classify,
shouldSample,
TrustMap,
} from "@suaveplan/drive-mesh-protocol";
import { defaultRng } from "@suaveplan/bitpacker-network";
const trust = new TrustMap();
const rng = defaultRng();
async function onIncomingAdvertise(
peerId: string,
fileId: string,
advertisedIds: number[]
): Promise<void> {
const e = trust.get(peerId, fileId);
if (classify(e) === "terminated") return;
if (shouldSample(e, rng)) {
const chunkId = advertisedIds[Math.floor(rng.nextFloat() * advertisedIds.length)];
const ok = await verifyAdvertisedChunk(peerId, fileId, chunkId);
if (!ok) trust.recordMissing(peerId, fileId, 1);
}
}
```
## API Reference
- `type TrustStatus = "terminated" | "suspect" | "healthy"`.
- `classify(entry: TrustEntry): TrustStatus`.
- `sampleRate(entry: TrustEntry): number` — denominator; `Infinity` for terminated.
- `shouldSample(entry: TrustEntry, rng: Rng): boolean`.
## Implementation Notes
Terminated peers are never sampled because the request scheduler shouldn't be talking to them in the first place; the function returns `false` early without consuming an RNG draw. The `1 / denom` probability uses 32-bit float division which has more than enough precision for a 1-in-100 / 1-in-10 decision; switching to integer division would buy nothing and force callers to track the denominator separately. Adjusting the suspect threshold or the per-status rate requires re-pinning the constants in `schemas/envelope.schema.ts` — the spec file MUST move in lockstep.

View File

@@ -0,0 +1,64 @@
# score
## Purpose
Implements `TrustMap`, the per-`(peerId, fileId)` trust state container the request scheduler in `drive-mesh` uses to decide which peers to talk to (DMP-13, DMP-14, DMP-16). Each entry tracks how many chunks a peer claimed to have via gossip vs. how many it failed to deliver on request, plus a terminal `terminated: true` flag set after a bad-signature event.
## Features
- `TrustMap` — mutable, in-process map of `TrustEntry` records keyed by `(peerId, fileId)`.
- `recordAdvertised(peerId, fileId, delta)` — increments the advertised counter; recomputes the score.
- `recordMissing(peerId, fileId, delta)` — increments the missing counter; recomputes the score.
- `terminate(peerId, fileId)` — pins the score to `-Infinity` after a bad-sig event (DMP-14).
- `canServe(peerId, fileId)` — query helper for the request scheduler.
- `dropPeer(peerId)` / `dropFile(fileId)` — cleanup hooks for the registry's `peer:left` / file-removal events.
- Iterable via `Symbol.iterator` for snapshot/persistence.
## Usage: Basic Example
```ts
import { TrustMap } from "@suaveplan/drive-mesh-protocol";
const trust = new TrustMap();
trust.recordAdvertised("peer-A", "fileId", 100);
trust.recordMissing("peer-A", "fileId", 5);
console.log(trust.get("peer-A", "fileId").score); // 0.95
```
## Usage: Advanced Example
```ts
import {
classify,
sampleRate,
shouldSkip,
TrustMap,
} from "@suaveplan/drive-mesh-protocol";
const trust = new TrustMap();
function pickPeers(candidates: string[], fileId: string, rng): string[] {
return candidates.filter((peerId) => {
const e = trust.get(peerId, fileId);
if (!trust.canServe(peerId, fileId)) return false;
return !shouldSkip(e, rng);
});
}
function onBadSignature(peerId: string, fileId: string): void {
trust.terminate(peerId, fileId);
}
function onPeerDisconnect(peerId: string): void {
trust.dropPeer(peerId);
}
```
## API Reference
- `class TrustMap` — `get(peerId, fileId)`, `recordAdvertised(peerId, fileId, delta)`, `recordMissing(peerId, fileId, delta)`, `terminate(peerId, fileId)`, `canServe(peerId, fileId)`, `dropPeer(peerId)`, `dropFile(fileId)`, `size`, `[Symbol.iterator]()`.
- `interface TrustEntry` — `{ peerId, fileId, advertised, missing, score, terminated }`.
## Implementation Notes
`recordAdvertised` and `recordMissing` are no-ops on terminated entries to prevent a bad-sig peer from regaining trust by spamming further advertisements; the score stays at `-Infinity` regardless of subsequent counters. The score formula is `1 - missing/advertised` with `score = 1` when `advertised === 0` so a freshly-met peer starts trusted by default — terminated entries override this. The iterator yields `TrustEntry` records by reference; consumers must NOT mutate them outside the public methods.

View File

@@ -0,0 +1,59 @@
# protocol.types
## Purpose
Holds the cross-module type vocabulary for the entire mesh protocol package. Every wire-format payload, request shape, response shape, error envelope, and trust-state record has its TypeScript counterpart pinned here so codec, trust, generation, and select modules share a single language for protocol data without circular imports between feature directories.
## Features
- `ChunkRequest` — discriminated union over `whole | specific | bitset` (DMP-01).
- `ChunkRequestEnvelope`, `ChunkRequestPayload` — request wire shapes.
- `ChunkAdvertiseEnvelope`, `ChunkAdvertisePayload` — gossip wire shapes.
- `ChunkResponseEnvelope`, `ChunkResponsePayload`, `ChunkBytes` — response wire shapes including the piggyback presence (DMP-11).
- `ChunkErrorEnvelope`, `ChunkErrorPayload`, `ChunkErrorCode` — error wire shapes.
- `ChunkRequestPriority` — five-tier `critical | high | normal | low | bulk` (DMP-06).
- `TrustScore` — per-`(peerId, fileId)` score record consumed by the trust subsystem.
## Usage: Basic Example
```ts
import type {
ChunkRequest,
ChunkRequestPayload,
} from "@suaveplan/drive-mesh-protocol";
declare const req: ChunkRequest;
declare const payload: ChunkRequestPayload;
```
## Usage: Advanced Example
```ts
import type {
ChunkErrorCode,
ChunkErrorPayload,
ChunkResponsePayload,
} from "@suaveplan/drive-mesh-protocol";
function classifyResponse(
r: ChunkResponsePayload | { error: ChunkErrorPayload }
): "ok" | ChunkErrorCode {
if ("error" in r) return r.error.code;
return "ok";
}
```
## API Reference
- `ChunkRequest` — `{ kind: "whole" } | { kind: "specific"; chunkIds: readonly number[] } | { kind: "bitset"; bytes: Uint8Array }`.
- `ChunkRequestPayload` — `{ fileId, request, priority, maxChunks? }`.
- `ChunkAdvertisePayload` — `{ fileId, presence, totalChunks, generation, timestamp }`.
- `ChunkResponsePayload` — `{ fileId, chunks, presence, generation }`.
- `ChunkBytes` — `{ chunkId, bytes }`.
- `ChunkErrorPayload` — `{ fileId, correlationId, code, chunkId?, retryAfterMs?, responderGeneration? }`.
- `ChunkErrorCode`, `ChunkRequestPriority` — string-literal unions.
- `TrustScore` — `{ peerId, fileId, advertised, missing, score }`.
## Implementation Notes
`ChunkBytes.bytes` is a `Uint8Array` carrying the locked v0x0001 chunk format from `@suaveplan/drive-plugin-chunks` (header + signature + ciphertext). The protocol package does NOT verify or decrypt those bytes — that's the consumer's responsibility (DMP-13). Generation values are widened to plain `number` rather than a branded `Generation` type because the wrap-around arithmetic in `generation/monotonic.ts` already needs to do unsigned u32 maths inline; a brand would force unsafe casts at every call site without adding any safety.

View File

@@ -0,0 +1,158 @@
# @suaveplan/drive-mesh - P2P mesh orchestrator for the drive chunks plugin. Peer registry, availability gossip, parallel-pull scheduler with racing window, server fallback, and bad-sig denylist. Consumes ChunkSource (drive-plugin-chunks) and DriveTransport (drive-transport-*).
## Summary
P2P mesh orchestrator over the drive chunks plugin. Consumes
`ChunkSource` (from `@suaveplan/drive-plugin-chunks`) and one or more
`DriveTransport` implementations (HTTPS, WebRTC, libp2p, in-memory).
Provides parallel chunk pull with a first-byte racing window, peer
scoring, bad-signature denylist, availability bitset gossip, and
server fallback.
The package is universal: zero browser- or React-specific code.
It composes on the server (Node) for vault / CDN sources, in the
browser for distributed clients, and inside test harnesses with
in-memory transports.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
- [gossip-engine](./src/gossip/gossip-engine/gossip-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [typed-event-emitter](./src/internal/typed-event-emitter/typed-event-emitter.md) — <!-- begin:auto-docs --> _(no submodules)_- [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_ - [drive-mesh](./src/mesh/drive-mesh/drive-mesh.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [peer-registry](./src/peer-registry/peer-registry/peer-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parallel-puller](./src/scheduler/parallel-puller/parallel-puller.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server-fallback](./src/server-fallback/server-fallback/server-fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen error-code registry for `@suaveplan/drive-mesh` via `defineCodes("DRIVE_MESH", …)` from `@suaveplan/…
## Quick start
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
const mesh = new DriveMesh({
chunks, // ChunkSource from chunksPlugin
transports: [httpsTransport], // any DriveTransport-compatible impl
serverPubKey, // server's ed25519 public key
serverFallback: { url: "https://cdn.example.com/chunks", transport: "https" },
});
await mesh.start();
const chunkBytes = await mesh.requestChunk(fileIdHex, 0);
await mesh.stop();
```
## Layering
```text
┌──────────────────────────────────────────────────────────────┐
│ application (vault / drive) │
├──────────────────────────────────────────────────────────────┤
│ @suaveplan/drive-mesh (this package) │
│ peer registry · gossip · racing puller · server fallback │
├──────────────────────────────────────────────────────────────┤
│ @suaveplan/drive-mesh-protocol (envelopes + bitsets) │
├──────────────────────────────────────────────────────────────┤
│ @suaveplan/drive-plugin-chunks (sig-verify, manifest) │
├──────────────────────────────────────────────────────────────┤
│ DriveTransport implementations (HTTPS / WebRTC / libp2p) │
└──────────────────────────────────────────────────────────────┘
```
`DriveMesh` is the only public class most callers construct. The four
subsystems (`PeerRegistry`, `GossipEngine`, `ParallelPuller`,
`ServerFallback`) are exported under subpaths so advanced consumers
can compose them directly.
## Subpath table
| Subpath | What |
| ---------------------------------------- | ----------------------------------- |
| `@suaveplan/drive-mesh` | Root barrel — `DriveMesh` + types. |
| `@suaveplan/drive-mesh/mesh/drive-mesh` | `DriveMesh` class. |
| `@suaveplan/drive-mesh/peer-registry/peer-registry` | `PeerRegistry`. |
| `@suaveplan/drive-mesh/gossip/gossip-engine` | `GossipEngine`. |
| `@suaveplan/drive-mesh/scheduler/parallel-puller` | `ParallelPuller`. |
| `@suaveplan/drive-mesh/server-fallback/server-fallback` | `ServerFallback`. |
| `@suaveplan/drive-mesh/types` | All public types. |
| `@suaveplan/drive-mesh/schemas` | Zod schemas. |
| `@suaveplan/drive-mesh/errors` | `DRIVE_MESH_CODES`, `DriveMeshError`. |
## Trust model
The server's ed25519 public key (`serverPubKey`) is the only root of
trust. Every chunk returned by a peer or the fallback URL must pass
`verifyChunkSig` against this key before drive-mesh resolves its
`requestChunk` promise. A peer that ever returns a chunk with an
invalid signature is moved to a denylist — `denylistMode: "session"`
(default) clears the list on `mesh.stop()`; `"persistent"` survives
stops.
Mesh never holds file decryption keys. Plaintext only ever materialises
inside the chunks plugin's decrypt pipeline, which is keyed by the
recipient's per-vault key.
## Scoring formula
`PeerRegistry` ranks peers per `(fileId, chunkId)` using a weighted
sum:
```text
score = 2 · (100 / (rttMs + 1))
+ 3 · successRate
− 5 · errorRate
```
- `rttMs` is an EMA (α = 0.3) of observed chunk-fetch wall time.
- `successRate` and `errorRate` are over the lifetime of the registry
entry. Peers with no observations start at `successRate = 0.5`
(neutral) and `errorRate = 0`.
- Bad-sig peers are pinned at `-Infinity` so they never appear in
`rank()` output once denied.
## OpenTelemetry metrics
The mesh emits the following events; consumers wire them to whichever
OTel meter they prefer. Drive-mesh stays metric-emitter-agnostic so it
does not pin a meter provider on universal consumers.
| Event | Suggested metric | Type |
| ----------------- | -------------------------------------------------- | ----------- |
| `chunk:served` | `drive.mesh.chunks_served_total{outcome="peer"}` | counter |
| `chunk:fallback` | `drive.mesh.chunks_served_total{outcome="fallback"}` | counter |
| `chunk:failed` | `drive.mesh.chunks_served_total{outcome="fail"}` | counter |
| `chunk:served` | `drive.mesh.chunk_fetch_ms{outcome="peer"}` | histogram |
| `chunk:fallback` | `drive.mesh.chunk_fetch_ms{outcome="fallback"}` | histogram |
| `peer:joined` | `drive.mesh.peers_admitted_total` | counter |
| `peer:denied` | `drive.mesh.peers_denied_total{reason}` | counter |
| `gossip:sent` | `drive.mesh.gossip_size_bytes` | histogram |
## Error codes
| Code | When |
| ----------------------------- | ----------------------------------------------- |
| `DRIVE_MESH.INVALID_OPTIONS` | Construction or `fileId` shape rejected. |
| `DRIVE_MESH.NOT_STARTED` | `requestChunk` before `start()`. |
| `DRIVE_MESH.CHUNK_UNAVAILABLE`| No candidate peers and no fallback configured. |
| `DRIVE_MESH.SIG_VERIFY_FAILED`| Winning peer returned a chunk with bad sig. |
| `DRIVE_MESH.ALL_PEERS_FAILED` | Every candidate failed; no fallback configured. |
| `DRIVE_MESH.FALLBACK_FAILED` | Peers + fallback both failed. |
| `DRIVE_MESH.INVALID_BITSET` | `advertise` got bad `totalChunks`. |
| `DRIVE_MESH.TRANSPORT_FAILED` | Single-attempt transport failure (internal). |
| `DRIVE_MESH.PEER_DENIED` | Peer on denylist (manual operations). |
| `DRIVE_MESH.ABORTED` | Caller-supplied AbortSignal aborted. |
## License
MIT © Suaveplan

View File

@@ -0,0 +1,93 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the frozen error-code registry for `@suaveplan/drive-mesh` via
`defineCodes("DRIVE_MESH", …)` from `@suaveplan/error`. Every failure
that crosses a public API boundary in the mesh — peer denial, signature
verification failure, transport breakage, fallback exhaustion — surfaces
as a `DriveMeshError` whose `code` is one of the literals in
`DRIVE_MESH_CODES`. Callers pattern-match on `error.code` rather than
parsing free-form messages, which keeps log-shipping, alerting, and
retry policies stable across releases.
## Features
- Namespaced codes (`DRIVE_MESH.CHUNK_UNAVAILABLE`, etc).
- Frozen at module load — additions go through OpenSpec.
- `DriveMeshError` widens the protected base constructor to public so
feature modules can throw directly without subclassing further.
- Codes cover the full lifecycle: peer selection, signature verify,
transport failure, server fallback, configuration validation.
## Usage: Basic Example
```ts
import { DRIVE_MESH_CODES, DriveMeshError } from "@suaveplan/drive-mesh/errors";
throw new DriveMeshError(
DRIVE_MESH_CODES.SIG_VERIFY_FAILED,
"chunk signature did not validate against server pubkey"
);
```
## Usage: Advanced Example
```ts
import {
DRIVE_MESH_CODES,
DriveMeshError,
} from "@suaveplan/drive-mesh/errors";
try {
await mesh.requestChunk(fileId, chunkId);
} catch (err) {
if (err instanceof DriveMeshError) {
switch (err.code) {
case DRIVE_MESH_CODES.ALL_PEERS_FAILED:
// every peer attempted; falling back to server
break;
case DRIVE_MESH_CODES.SIG_VERIFY_FAILED:
// the responsible peer is now denylisted
break;
case DRIVE_MESH_CODES.ABORTED:
// caller aborted via AbortSignal
break;
default:
throw err;
}
} else {
throw err;
}
}
```
## API Reference
- `DRIVE_MESH_CODES` — `Readonly<Record<Reason, "DRIVE_MESH.<Reason>">>`
containing: `CHUNK_UNAVAILABLE`, `ALL_PEERS_FAILED`,
`SIG_VERIFY_FAILED`, `TRANSPORT_FAILED`, `FALLBACK_FAILED`,
`INVALID_BITSET`, `PEER_DENIED`, `INVALID_OPTIONS`, `ABORTED`,
`NOT_STARTED`.
- `DriveMeshErrorCode` — type alias for the union of values above.
- `DriveMeshError(code, message, options?)` — concrete error class
extending `SuaveplanError<DriveMeshErrorCode>`; supports the standard
`cause` chain via `SuaveplanErrorOptions`.
## Implementation Notes
The registry is closed: each new failure mode must be added via an
OpenSpec change and corresponding spec requirement. Because
`defineCodes` returns a frozen record, accidental in-process mutation
fails fast in development.
`DriveMeshError`'s constructor is a thin pass-through that widens the
base's `protected` constructor to `public`. The class exists so
`instanceof` checks distinguish mesh errors from generic
`SuaveplanError`s thrown by sibling packages (e.g.
`ChunksError`/`DmpError`). Causes propagated from those packages remain
inspectable through the standard `error.cause` chain.

View File

@@ -0,0 +1,116 @@
# `gossip-engine.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GossipEngine` runs the bitset-gossip loop drive-mesh uses to keep
peers informed of local chunk availability. Two responsibilities:
(1) periodically push local availability to every connected peer,
and (2) accept inbound advertisements and forward them to the
`PeerRegistry`.
The engine is transport-agnostic — it iterates the configured
`DriveTransport[]` and uses each transport's `advertise` /
`onAdvertisement` surface. Gossip is intentionally best-effort: a
failed `advertise` call is swallowed and retried on the next tick.
Chunk-fetch failures, by contrast, drive the scoring + denylist
inside the registry.
## Features
- Periodic outbound tick (default 5_000 ms, configurable).
- Inbound handler wired automatically on `start`.
- `tick()` exposed for deterministic testing.
- Cleanup on `stop` removes every subscription and clears the timer.
- Pluggable `advertSource` so the mesh decides what bytes go on the
wire (the chunks plugin's `hasChunks` result + framing).
## Usage: Basic Example
```ts
import { GossipEngine } from "@suaveplan/drive-mesh/gossip/gossip-engine";
const gossip = new GossipEngine({
registry,
transports: [httpsTransport],
advertSource: fileId => buildAdvert(fileId),
trackedFiles: () => chunks.knownFiles(),
});
gossip.start();
// ...
gossip.stop();
```
## Usage: Advanced Example
```ts
import { GossipEngine } from "@suaveplan/drive-mesh/gossip/gossip-engine";
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
const gossip = new GossipEngine({
registry,
transports: [webrtcTransport, httpsTransport],
intervalMs: 1_000,
advertSource: async fileId => {
const bits = await chunks.hasChunks(fromHex(fileId));
const totalChunks = bits.length;
const raw = boolsToBytes(bits);
return {
fileId,
totalChunks,
bitset: encodeFramedBitset(raw, totalChunks),
};
},
trackedFiles: () => activeFiles,
onAdvertiseSent: (peerId, advert) =>
telemetry.emit("gossip:sent", { peerId, fileId: advert.fileId }),
onAdvertiseReceived: (peerId, advert) =>
telemetry.emit("gossip:received", { peerId, fileId: advert.fileId }),
});
gossip.start();
// Force-trigger a tick for an integration test:
await gossip.tick();
```
## API Reference
- `class GossipEngine`:
- `constructor(options: GossipEngineOptions)`.
- `start(): void` — wires inbound subscriptions + schedules the
outbound tick. Idempotent.
- `stop(): void` — cancels the tick and detaches subscriptions.
Idempotent.
- `tick(): Promise<void>` — run a single outbound pass; intended
for tests.
- `GossipEngineOptions`:
- `registry` — `PeerRegistry` instance.
- `transports` — `readonly DriveTransport[]`.
- `advertSource(fileId)` — returns the local advertisement or
`undefined` when nothing to advertise.
- `trackedFiles()` — generator of `fileId` hex strings.
- `intervalMs?` — outbound tick period; default `5_000`.
- `clock?` — defaults to `Date.now`-based clock.
- `onAdvertiseSent?(peerId, advert)` — observability hook.
- `onAdvertiseReceived?(peerId, advert)` — observability hook.
## Implementation Notes
Outbound fan-out iterates every peer reported by every transport.
Duplicate peers across transports receive duplicate advertisements,
which is fine: receivers idempotently overwrite the bitset and the
registry's `recordAvailability` is set-shaped.
The scheduling primitive is `setTimeout` rather than `setInterval` so
overlapping ticks cannot occur — the next tick is only scheduled
after the previous tick's promise resolves. `unref` is applied where
available (Node) so the event loop can drain in tests; browsers do
not implement `unref`, which is fine because they manage timers
through page lifecycle instead.
Inbound advertisements flow through `addOrUpdate` (updates
`lastSeen`) and `recordAvailability` (updates the bitset) so a peer
sending its first advertisement is admitted to the registry without
a separate `peer:joined` event being required.

View File

@@ -0,0 +1,74 @@
# `typed-event-emitter.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal helper wrapping `EventBus` from `@suaveplan/events` with a
tuple-shaped event map. The public mesh class extends this so call
sites can write `mesh.emit("chunk:served", payload)` rather than
wrapping every argument in an object literal.
The implementation is intentionally private to `@suaveplan/drive-mesh`
— the public API exposes only `on`, `off`, and `once`. Re-implementing
locally (rather than importing from `@suaveplan/drive-webrtc`) keeps
the universal dependency closure clean: drive-mesh must not pull
in WebRTC code.
## Features
- Strict typing of event names → argument tuples.
- Synchronous `emit` matching Node's `EventEmitter` semantics.
- `once` auto-unsubscribes after delivery.
- `removeAllListeners` clears every subscription across every event.
## Usage: Basic Example
```ts
import { TypedEventEmitter } from "./typed-event-emitter.js";
type Events = { hello: [string] };
const ee = new TypedEventEmitter<Events>();
ee.on("hello", name => console.log("hi", name));
ee.emit("hello", "world");
```
## Usage: Advanced Example
```ts
import { TypedEventEmitter } from "./typed-event-emitter.js";
type Events = {
"chunk:served": [{ readonly chunkId: number; readonly bytes: number }];
"chunk:failed": [{ readonly chunkId: number; readonly reason: string }];
};
const ee = new TypedEventEmitter<Events>();
const off = ee.on("chunk:served", e => console.log(e.chunkId, e.bytes));
ee.once("chunk:failed", e => console.error(e.reason));
ee.emit("chunk:served", { chunkId: 0, bytes: 1024 });
off();
ee.removeAllListeners();
```
## API Reference
- `EventArgsMap = Record<string, unknown[]>` — event-map shape.
- `class TypedEventEmitter<TMap>`:
- `on(event, handler): Unsubscribe`.
- `once(event, handler): Unsubscribe` — auto-unsubscribes on first
delivery.
- `off(event, handler): void`.
- `emit(event, ...args): boolean` — `true` iff at least one
subscriber received the event.
- `removeAllListeners(): void`.
## Implementation Notes
`EventBus` keeps payloads as a single value per event, so the wrapper
stores `args` (the tuple) as that value and spreads it back through
`handler(...args)` on dispatch. The auxiliary `#wrapped` map tracks
the unsubscribe closures so `off(event, handler)` removes the
correct registration even though the underlying bus only sees the
wrapping closure.

View File

@@ -0,0 +1,144 @@
# `drive-mesh.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DriveMesh` is the public face of `@suaveplan/drive-mesh`. It binds
the four supporting subsystems — `PeerRegistry`, `GossipEngine`,
`ParallelPuller`, and (optionally) `ServerFallback` — into a single
class with explicit lifecycle and a single chunk-fetch entry point.
The class is the only mesh component most consumers ever construct.
It validates options at the Zod boundary, wires telemetry hooks
between subsystems, and surfaces a typed event stream callers can
subscribe to. The chunks plugin's signature contract is upheld
end-to-end: every byte returned from `requestChunk` has passed
`verifyChunkSig` against the configured `serverPubKey`.
## Features
- Strict option validation at construction (`INVALID_OPTIONS`).
- Idempotent `start()` / `stop()` lifecycle.
- `requestChunk` racing-window scheduler with automatic fallback.
- `advertise` / `unadvertise` to publish local availability bitsets.
- Typed event emitter (`chunk:*`, `peer:*`, `gossip:*`).
- Read-only access to the registry (`mesh.registry`) for
`NetworkBus` consumers that wire `peer:joined`/`peer:left` events.
## Usage: Basic Example
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
const mesh = new DriveMesh({
chunks,
transports: [httpsTransport],
serverPubKey,
});
await mesh.start();
const bytes = await mesh.requestChunk(fileIdHex, 0);
await mesh.stop();
```
## Usage: Advanced Example
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
import { encodeFramedBitset } from "@suaveplan/drive-mesh-protocol";
const mesh = new DriveMesh({
chunks,
transports: [webrtcTransport, httpsTransport],
serverPubKey,
serverFallback: { url: "https://cdn.example.com/chunks", transport: "https" },
maxParallelRequests: 16,
racingWindowMs: 150,
denylistMode: "persistent",
});
// Mirror NetworkBus events into the registry overlay.
networkBus.on("peer:joined", ({ peerId, transports }) =>
mesh.registry.addOrUpdate(peerId, { transports })
);
networkBus.on("peer:left", ({ peerId }) => mesh.registry.forget(peerId));
await mesh.start();
mesh.on("chunk:served", e =>
telemetry.counter("drive.mesh.chunks_served_total", 1, { outcome: "peer" })
);
mesh.on("chunk:fallback", e =>
telemetry.counter("drive.mesh.chunks_served_total", 1, {
outcome: "fallback",
})
);
mesh.on("peer:denied", e =>
telemetry.counter("drive.mesh.peers_denied_total", 1, { reason: e.reason })
);
const bits = await chunks.hasChunks(fileIdBytes);
const raw = boolsToBytes(bits);
mesh.advertise(fileIdHex, encodeFramedBitset(raw, bits.length), bits.length);
const bytes = await mesh.requestChunk(fileIdHex, 0);
await mesh.stop();
```
## API Reference
- `class DriveMesh extends TypedEventEmitter<DriveMeshEventMap>`:
- `constructor(options: DriveMeshOptions)` — Zod-validated.
- `start(signal?): Promise<void>` — idempotent.
- `stop(reason?): Promise<void>` — idempotent; clears registry per
`denylistMode`.
- `requestChunk(fileId, chunkId, signal?): Promise<Uint8Array>` —
racing-window peer pull → server fallback → throw.
- `advertise(fileId, bitset, totalChunks): void`.
- `unadvertise(fileId): void`.
- `peers(): Iterable<PeerInfo>`.
- `registry: PeerRegistry` (read-only getter).
- `tickGossip(): Promise<void>` — single gossip pass for tests.
- Inherited from `TypedEventEmitter`:
- `on(event, handler): Unsubscribe`.
- `once(event, handler): Unsubscribe`.
- `off(event, handler): void`.
- `removeAllListeners(): void`.
- Throws:
- `DRIVE_MESH.INVALID_OPTIONS` — bad construction args / bad
`fileId`.
- `DRIVE_MESH.NOT_STARTED` — `requestChunk` before `start()`.
- `DRIVE_MESH.CHUNK_UNAVAILABLE` — no candidates and no fallback.
- `DRIVE_MESH.SIG_VERIFY_FAILED` — winning peer was Byzantine.
- `DRIVE_MESH.ALL_PEERS_FAILED` — every candidate failed and no
fallback configured.
- `DRIVE_MESH.FALLBACK_FAILED` — peers + fallback both failed.
- `DRIVE_MESH.ABORTED` — caller-supplied signal aborted.
- `DRIVE_MESH.INVALID_BITSET` — `advertise` got bad totalChunks.
## Implementation Notes
`DriveMesh` does NOT register itself as a `NetworkBus` consumer
internally — it exposes `mesh.registry` so the bridge from
`network-plugin-registry`'s `peer:joined` / `peer:left` events lives
in application glue. This keeps the universal package free of a
dependency on `@suaveplan/network` / `@suaveplan/network-plugin-*`,
matching the layering pinned in `drive-network-integration`.
`requestChunk` deliberately does not retry on peers after a bad-sig
event from the winning attempt: the denylist update means the next
call will not even consider that peer. Re-trying inside a single
request would risk amplifying a Byzantine peer's influence over the
score formula. Instead, the fallback path runs.
Bad-sig events flow `puller → registry.recordBadSig → onBadSig
callback → mesh.emit("peer:denied")`. The registry update happens
synchronously inside the puller; the event is fired afterwards so
subscribers see the state-after-update.
`exactOptionalPropertyTypes: true` (per the repo's `tsconfig.base`)
forces the explicit `...(x !== undefined ? { x } : {})` spread shape
when forwarding optionals down into subsystem options — never
`{ x: maybeUndefined }` because that materialises an `undefined`
property the inner schema would reject.

View File

@@ -0,0 +1,112 @@
# `peer-registry.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerRegistry` is the in-memory overlay drive-mesh maintains on top of
the live peer set provided by `network-plugin-registry`. The registry
adds three concerns the network layer does not own: per-`fileId`
availability bitsets, an EMA-smoothed RTT tracker, and a composite
score the parallel puller uses to rank candidates.
The registry never originates peer-identity events. Peers enter via
`addOrUpdate` (called by the mesh in response to bus events) and exit
via `forget` (called when the network registry emits `peer:left`). The
denylist is owned by the registry because bad signatures are a
file-layer concern.
## Features
- Per-file availability bitsets stored as framed bytes (output of
`encodeFramedBitset` from `@suaveplan/drive-mesh-protocol`).
- EMA latency tracker (α = 0.3).
- Composite score: `2 * (100 / (rtt+1)) + 3 * successRate - 5 *
errorRate`. Weights documented in the README.
- Bad-sig denylist with two modes (`"session"` clears on stop;
`"persistent"` survives).
- `rank(fileId, chunkId)` returns scored candidates in
descending-score order, filtered to peers that claim availability of
the chunk.
## Usage: Basic Example
```ts
import { PeerRegistry } from "@suaveplan/drive-mesh/peer-registry/peer-registry";
const registry = new PeerRegistry();
registry.addOrUpdate("peerA", { transports: ["https"], rttMs: 80 });
registry.recordAvailability("peerA", "abcd…", framedBitset);
const candidates = registry.rank("abcd…", 7);
```
## Usage: Advanced Example
```ts
import { PeerRegistry } from "@suaveplan/drive-mesh/peer-registry/peer-registry";
const registry = new PeerRegistry({
clock: { now: () => fakeClock.now() },
denylistMode: "persistent",
});
registry.addOrUpdate("peerA", { transports: ["webrtc", "https"] });
registry.recordRtt("peerA", 50);
registry.recordSuccess("peerA");
registry.recordSuccess("peerA");
registry.addOrUpdate("peerB", { transports: ["https"] });
registry.recordRtt("peerB", 400);
registry.recordFailure("peerB");
const top = registry.rank("file-id-hex", -1);
// peerA outranks peerB because of lower RTT and higher success rate.
registry.recordBadSig("peerB");
console.log(registry.isDenied("peerB")); // true
console.log(registry.denialReason("peerB")); // "bad_sig"
```
## API Reference
- `class PeerRegistry`:
- `constructor(options?: { clock?; denylistMode? })`.
- `addOrUpdate(peerId, update?): PeerInfo` — idempotent insertion /
merge.
- `forget(peerId): void`.
- `forgetAll(): void` — clears the registry; clears the denylist
iff `denylistMode === "session"`.
- `recordAvailability(peerId, fileId, framedBitset): void`.
- `recordRtt(peerId, rttMs): void` — α=0.3 EMA.
- `recordSuccess(peerId): void`.
- `recordFailure(peerId): void`.
- `recordBadSig(peerId): void` — moves to denylist
(`reason="bad_sig"`).
- `deny(peerId, reason): void` — denylist with explicit reason.
- `removeFromDenylist(peerId): void`.
- `isDenied(peerId): boolean`.
- `denialReason(peerId): PeerDenialReason | undefined`.
- `peers(): IterableIterator<PeerInfo>`.
- `rank(fileId, chunkId): PeerInfo[]` — descending-score order.
- `get(peerId): PeerInfo | undefined`.
## Implementation Notes
`addOrUpdate` returns the post-merge snapshot rather than `void` so
the mesh can immediately emit `peer:joined`/`peer:scored` events with
the canonical shape without re-querying. Snapshots are fresh objects
on every call so callers may not assume reference equality.
`rank(fileId, -1)` is a sentinel meaning "any peer with any
availability for the file" — the gossip engine uses it to fan-out
advertisements without requiring a specific chunk. The negative path
is the only branch that skips the bitset bit-check, which keeps the
hot-path tight.
The score formula is intentionally simple: weighted-sum of latency,
success rate, and error rate. The weights are tuned so that a peer
with double the RTT of another remains preferred if it has a higher
success rate, which matches the drive-webrtc-era tuning. Bad-sig
peers are pinned at `-Infinity` so they sort to the bottom of every
ranking without special-casing the consumer.

View File

@@ -0,0 +1,126 @@
# `parallel-puller.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ParallelPuller` is the chunk-fetch scheduler that turns "I want chunk
N of file F" into a verified `Uint8Array`. The puller asks the
`PeerRegistry` for ranked candidates, issues a request to the best
peer, waits `racingWindowMs`, then issues parallel requests to the
runners-up. The first response whose server signature verifies wins;
the other in-flight attempts are aborted via a shared
`AbortController`.
The puller is the mesh's primary trust enforcement point. Every byte
returned from a transport flows through `verifyChunkSig` before the
puller acknowledges success. A signature failure moves the offending
peer to the registry's denylist with `score = -Infinity` and surfaces
`DriveMeshError(SIG_VERIFY_FAILED)` — the orchestrator can choose to
fall back to the server rather than retry on peers.
## Features
- Top-K candidate selection (default 3).
- Racing-window staggered issuance (default 250 ms).
- Shared `AbortController` short-circuits losing attempts.
- Mandatory `verifyChunkSig` gate against the configured
`serverPubKey`.
- Bad-sig denylisting via `registry.recordBadSig`.
- Non-signature failures update success / failure counters so
ranking adapts.
- `maxParallelRequests` global cap.
## Usage: Basic Example
```ts
import { ParallelPuller } from "@suaveplan/drive-mesh/scheduler/parallel-puller";
const puller = new ParallelPuller({
registry,
transports: [httpsTransport],
serverPubKey,
});
const bytes = await puller.pull("file-id-hex", 0);
```
## Usage: Advanced Example
```ts
import { ParallelPuller } from "@suaveplan/drive-mesh/scheduler/parallel-puller";
const puller = new ParallelPuller({
registry,
transports: [webrtcTransport, httpsTransport],
serverPubKey,
racingWindowMs: 150,
topK: 4,
maxParallelRequests: 16,
clock: fakeClock,
onAttempt: (peerId, fileId, chunkId) =>
log.debug("attempt", peerId, fileId, chunkId),
onSuccess: (peerId, _f, _c, bytes, elapsedMs) =>
log.info("served", peerId, bytes, elapsedMs),
onBadSig: peerId => log.warn("denylisting", peerId),
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
try {
const bytes = await puller.pull(fileId, chunkId, controller.signal);
} catch (err) {
if (err instanceof DriveMeshError && err.code === DRIVE_MESH_CODES.ABORTED) {
// caller-driven cancellation
}
}
```
## API Reference
- `class ParallelPuller`:
- `constructor(options: ParallelPullerOptions)`.
- `pull(fileId, chunkId, signal?): Promise<Uint8Array>`.
- `inflight: number` (read-only) — in-flight request count.
- `ParallelPullerOptions`:
- `registry`, `transports`, `serverPubKey` (required).
- `maxParallelRequests?` (default `8`).
- `racingWindowMs?` (default `250`).
- `topK?` (default `3`).
- `clock?`.
- `onAttempt?(peerId, fileId, chunkId)`.
- `onSuccess?(peerId, fileId, chunkId, bytes, elapsedMs)`.
- `onFailure?(peerId, fileId, chunkId, reason)`.
- `onBadSig?(peerId, fileId, chunkId)`.
- Throws:
- `DRIVE_MESH.CHUNK_UNAVAILABLE` — no candidate peers.
- `DRIVE_MESH.SIG_VERIFY_FAILED` — winning peer returned bad sig.
- `DRIVE_MESH.TRANSPORT_FAILED` — single-attempt transport failure.
- `DRIVE_MESH.ALL_PEERS_FAILED` — every candidate failed.
- `DRIVE_MESH.ABORTED` — caller-provided signal aborted.
## Implementation Notes
`firstSettled` is hand-rolled rather than using `Promise.any`:
`Promise.any` resolves on first success but does NOT abort losing
attempts, leaving them in flight wastefully. The shared
`AbortController` is the cancellation primitive — `controller.abort()`
fires both when a winner appears and when the outer caller aborts.
`SIG_VERIFY_FAILED` short-circuits the "all peers failed" wrapping:
the orchestrator distinguishes "every peer failed transport" (try
server fallback) from "winning peer was Byzantine" (denylist + retry
on a different peer). Re-wrapping the bad-sig error inside
`ALL_PEERS_FAILED` would lose that distinction.
`maxParallelRequests` is enforced by a yielding micro-loop in
`#awaitSlot`. The loop is bounded by the number of concurrent `pull`
calls — drive-mesh issues `pull` from a small, controlled set of
async contexts so the loop never spins indefinitely. Under heavier
fan-in the orchestrator should queue requests in front of the puller
rather than issuing every chunk concurrently.
Bad-sig denylist is permanent within the registry's denylist scope:
`session` (default) clears on `mesh.stop()`, `persistent` survives.
The puller does not concern itself with scope — the registry is the
authority.

View File

@@ -0,0 +1,105 @@
# `server-fallback.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ServerFallback` is the chunk-fetch path drive-mesh uses when every
peer attempt in `ParallelPuller` has failed. The server is the root of
trust, so a fallback request must still produce a chunk that passes
`verifyChunkSig` against the configured `serverPubKey`. The fallback
does not bypass trust — it bypasses peers.
The fallback URL is composed as `<baseUrl>/<fileId>/<chunkId>` and
fetched via the injectable `fetchFn`. Failures are retried with
exponential backoff and equal jitter so a wave of mesh nodes
simultaneously falling back does not stampede the origin.
## Features
- Plain `GET <baseUrl>/<fileId>/<chunkId>` request shape.
- Equal-jitter exponential backoff
(`exp/2 + rand(0, exp/2)`, clamped `[base, max]`).
- Mandatory signature verification against `serverPubKey`.
- AbortSignal threaded through `fetch` and through delay waits.
- Configurable retry count (`maxAttempts`, default 3).
## Usage: Basic Example
```ts
import { ServerFallback } from "@suaveplan/drive-mesh/server-fallback/server-fallback";
const fallback = new ServerFallback({
baseUrl: "https://cdn.example.com/chunks",
serverPubKey,
});
const bytes = await fallback.fetchChunk(fileId, 0);
```
## Usage: Advanced Example
```ts
import { ServerFallback } from "@suaveplan/drive-mesh/server-fallback/server-fallback";
const fallback = new ServerFallback({
baseUrl: "https://cdn.example.com/chunks",
serverPubKey,
fetchFn: customFetch,
maxAttempts: 5,
backoffBaseMs: 100,
backoffMaxMs: 3_000,
clock: fakeClock,
random: deterministicRng,
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
try {
const bytes = await fallback.fetchChunk(fileId, chunkId, controller.signal);
} catch (err) {
if (err instanceof DriveMeshError) {
if (err.code === DRIVE_MESH_CODES.FALLBACK_FAILED) {
// every retry exhausted; surface to the caller
}
}
}
```
## API Reference
- `class ServerFallback`:
- `constructor(options: ServerFallbackOptions)`.
- `fetchChunk(fileId, chunkId, signal?): Promise<Uint8Array>`.
- `now(): number` — current clock reading.
- `ServerFallbackOptions`:
- `baseUrl` (required) — trailing slashes stripped.
- `serverPubKey` (required) — ed25519 32-byte public key.
- `fetchFn?` — defaults to `globalThis.fetch`; throws
`FALLBACK_FAILED` lazily if missing.
- `maxAttempts?` (default `3`).
- `backoffBaseMs?` (default `200`).
- `backoffMaxMs?` (default `5_000`).
- `clock?` — defaults to `Date.now`-based clock.
- `random?` — `() => number` in `[0, 1)`; default `Math.random`.
## Implementation Notes
The path shape (`<base>/<fileId>/<chunkId>`) is intentionally simple
rather than templated — drive-mesh consumers usually front the
fallback URL with a CDN that knows the layout. Customers who want
shaped URLs (e.g., reverse-id sharding per `drive-template`) can
wrap `fetchFn` with their own URL builder.
`verifyChunkSig` is called on every successful response before
returning bytes. A signature failure in the fallback path is a
configuration error, not a transient failure — but for symmetry with
the peer path the error still surfaces as `FALLBACK_FAILED` rather
than `SIG_VERIFY_FAILED`, so that the caller's denylist counter
remains a "peer was Byzantine" counter rather than "server was
Byzantine".
`defaultFetch` returns a lazy `globalThis.fetch` bridge so the
package remains universal: a Node ≥18 / Deno / browser runtime
ships `fetch` natively, and consumers that build to older targets
inject `fetchFn` explicitly.

View File

@@ -0,0 +1,86 @@
# `config.types.ts`
## Purpose
Defines the `DriveMeshOptions` shape consumed by `new DriveMesh(...)`.
The interface is the TypeScript-side contract; the matching Zod
boundary lives in `./schemas/config.schemas.ts` and is the runtime
validator that rejects malformed input before the mesh's internal
state is constructed.
Splitting the two halves keeps the type surface dependency-free at
compile time and the runtime validation centralised at the package
boundary.
## Features
- Single shape covering scheduler tuning, denylist policy, server
fallback, transports, and the `ChunkSource` handle.
- Every tuning parameter is optional with a documented default.
- `serverPubKey` is mandatory — trust roots are not optional.
- Injectable `clock` and `fetchFn` for deterministic testing.
## Usage: Basic Example
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
const mesh = new DriveMesh({
chunks,
transports: [httpsTransport],
serverPubKey,
});
```
## Usage: Advanced Example
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
const mesh = new DriveMesh({
chunks,
transports: [webrtcTransport, httpsTransport],
serverPubKey,
serverFallback: { url: "https://cdn.example.com", transport: "https" },
maxParallelRequests: 16,
racingWindowMs: 150,
denylistMode: "persistent",
clock: fakeClock,
fetchFn: testFetch,
});
await mesh.start();
const chunkBytes = await mesh.requestChunk(fileIdHex, 0);
```
## API Reference
- `DenylistMode = "session" | "persistent"` — controls how long
bad-sig peers stay denylisted. `session` clears on `mesh.stop()`;
`persistent` keeps the denylist across stops.
- `ServerFallbackConfig`:
- `url: string` — absolute URL the fallback path hits.
- `transport: string` — `DriveTransport.id` carrying the fallback.
- `DriveMeshOptions`:
- `chunks: ChunkSource` — handle returned by `chunksPlugin`.
- `transports: readonly DriveTransport[]` — injectable transports.
- `serverPubKey: Uint8Array` — server's ed25519 public key.
- `serverFallback?: ServerFallbackConfig` — optional fallback.
- `maxParallelRequests?: number` — default `8`.
- `racingWindowMs?: number` — default `250`.
- `denylistMode?: DenylistMode` — default `"session"`.
- `clock?: Clock` — defaults to `Date.now`-based clock.
- `fetchFn?: typeof fetch` — defaults to `globalThis.fetch`.
## Implementation Notes
`fetchFn` is typed loosely as `(input, init?) => Promise<Response>`
rather than reusing `typeof fetch`, because `typeof fetch` carries a
DOM-specific overload set that fights the universal package
target. The looser shape still binds `globalThis.fetch` correctly at
runtime.
`serverPubKey` is required — there is no implicit default. Drive-mesh
treats peers as untrusted caches, so a configuration with no root of
trust would silently disable signature verification, which is a footgun
the type system prevents.

View File

@@ -0,0 +1,118 @@
# `drive-transport.types.ts`
## Purpose
Defines the chunk-shaped `DriveTransport` contract every mesh transport
implementation conforms to. This is a structural ("ducked") interface so
the mesh can compose HTTPS, WebRTC, libp2p, or in-memory transports
without depending on their concrete classes. Drive-mesh consumes the
contract; transport packages implement it.
The contract is intentionally narrow: a transport answers chunk
requests, pushes advertisements, surfaces inbound advertisements via a
handler, and reports the live peer set. Everything else (handshake,
authentication, replay protection) belongs inside the transport and
never crosses this boundary.
## Features
- Structural interface — no inheritance required.
- Async `requestChunk` returns the full chunk bytes (locked v0x0001
format) so the mesh can hand them directly to
`verifyChunkSig` and `parseChunk`.
- `advertise` is `peerId`-targeted — broadcasts are open-coded by the
gossip engine via repeated unicast.
- `onAdvertisement` returns an unsubscribe; multiple subscribers are
allowed.
- Optional `dispose()` honours `DriveMesh.stop()` lifecycle.
## Usage: Basic Example
```ts
import type { DriveTransport } from "@suaveplan/drive-mesh/types";
const transport: DriveTransport = {
id: "https",
async requestChunk(peerId, fileId, chunkId) {
return await myHttpsClient.get(peerId, fileId, chunkId);
},
async advertise(peerId, advert) {
await myHttpsClient.push(peerId, advert);
},
onAdvertisement(handler) {
const unsub = myHttpsClient.subscribe(handler);
return unsub;
},
peers() {
return myHttpsClient.connectedPeers();
},
};
```
## Usage: Advanced Example
```ts
import type { DriveTransport } from "@suaveplan/drive-mesh/types";
import { DriveMesh } from "@suaveplan/drive-mesh";
class WebRtcTransport implements DriveTransport {
public readonly id = "webrtc";
readonly #handlers = new Set<AdvertisementHandler>();
public async requestChunk(peerId, fileId, chunkId, signal) {
const chan = this.channelFor(peerId);
const reply = await chan.request({ fileId, chunkId }, { signal });
return reply.bytes;
}
public async advertise(peerId, advert) {
this.channelFor(peerId).send({ kind: "advert", ...advert });
}
public onAdvertisement(handler) {
this.#handlers.add(handler);
return () => this.#handlers.delete(handler);
}
public peers() {
return this.connectedChannels.keys();
}
public async dispose() {
for (const chan of this.connectedChannels.values()) chan.close();
}
}
const mesh = new DriveMesh({
chunks,
transports: [new WebRtcTransport(), httpsTransport],
serverPubKey,
});
```
## API Reference
- `ChunkAdvertisement { fileId: string; bitset: Uint8Array; totalChunks: number }`
— payload pushed in `advertise`; bitset is framed via
`encodeFramedBitset` from `@suaveplan/drive-mesh-protocol`.
- `AdvertisementHandler = (peerId, advert) => void` — callback invoked
for every inbound advertisement.
- `Unsubscribe = () => void` — handle returned by `onAdvertisement`.
- `DriveTransport`:
- `id: string` — stable identifier used by `serverFallback.transport`.
- `requestChunk(peerId, fileId, chunkId, signal?): Promise<Uint8Array>` —
fetch chunk bytes from one peer.
- `advertise(peerId, advert): Promise<void>` — unicast availability
push.
- `onAdvertisement(handler): Unsubscribe` — subscribe to inbound
advertisements.
- `peers(): Iterable<PeerId>` — live peer snapshot.
- `dispose?(): Promise<void> | void` — optional teardown.
## Implementation Notes
The contract is deliberately bytes-in / bytes-out: the mesh never
inspects payload metadata before signature verification, which keeps
trust boundaries explicit. Signature verification is performed by the
mesh against the configured `serverPubKey` before the chunk is handed
to the chunks plugin.
`peers()` returns an `Iterable<PeerId>` (not an array) so transports
can stream live views without materialising arrays on every gossip
tick. Implementations are free to return a `Set` or `Map.keys()`.

View File

@@ -0,0 +1,105 @@
# `events.types.ts`
## Purpose
Defines the tuple-shaped event map emitted by `DriveMesh`. The map
follows the same `Record<string, unknown[]>` convention as
`@suaveplan/drive-webrtc`'s `TypedEventEmitter`, so call sites read
`mesh.emit("chunk:served", payload)` rather than wrapping arguments in
an object literal on every emission.
Events are organised in three families: chunk lifecycle (`chunk:*`),
peer lifecycle (`peer:*`), and gossip telemetry (`gossip:*`). Each
event payload is fully typed so downstream subscribers get
compile-time validation of the property names they touch.
## Features
- Strict typing — `mesh.on("chunk:served", e => …)` infers
`ChunkServedEvent`.
- Stable shape: every event carries the contextual identifiers
(`fileId`, `chunkId`, `peerId` where applicable) needed to correlate
it with the request that triggered it.
- Latency observability via `elapsedMs` on chunk events.
## Usage: Basic Example
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
mesh.on("chunk:served", ({ fileId, chunkId, peerId, elapsedMs }) => {
console.log("hit", fileId, chunkId, peerId, elapsedMs);
});
mesh.on("chunk:fallback", ({ fileId, chunkId }) => {
console.log("fallback", fileId, chunkId);
});
```
## Usage: Advanced Example
```ts
import { DriveMesh } from "@suaveplan/drive-mesh";
import type { DriveMeshEventMap } from "@suaveplan/drive-mesh/types";
function wire(mesh: DriveMesh, sink: TelemetrySink) {
const map: { [K in keyof DriveMeshEventMap]: () => void } = {
"chunk:requested": mesh.on("chunk:requested", e =>
sink.counter("drive.mesh.chunk_requested_total", 1, e)
),
"chunk:served": mesh.on("chunk:served", e => {
sink.counter("drive.mesh.chunk_served_total", 1, { outcome: "peer" });
sink.histogram("drive.mesh.chunk_fetch_ms", e.elapsedMs, e);
}),
"chunk:fallback": mesh.on("chunk:fallback", e =>
sink.counter("drive.mesh.chunk_served_total", 1, { outcome: "fallback" })
),
"chunk:failed": mesh.on("chunk:failed", e =>
sink.counter("drive.mesh.chunk_served_total", 1, { outcome: "fail" })
),
"peer:joined": mesh.on("peer:joined", () =>
sink.counter("drive.mesh.peers_admitted_total", 1)
),
"peer:left": mesh.on("peer:left", () => {}),
"peer:denied": mesh.on("peer:denied", e =>
sink.counter("drive.mesh.peers_denied_total", 1, { reason: e.reason })
),
"peer:scored": mesh.on("peer:scored", () => {}),
"gossip:sent": mesh.on("gossip:sent", e =>
sink.histogram("drive.mesh.gossip_size_bytes", e.sizeBytes)
),
"gossip:received": mesh.on("gossip:received", () => {}),
};
return () => Object.values(map).forEach(off => off());
}
```
## API Reference
- `DriveMeshEventMap` — `Record<EventName, [PayloadType]>` consumed by
`TypedEventEmitter`.
- Chunk lifecycle:
- `ChunkRequestedEvent { fileId; chunkId; peerId }`.
- `ChunkServedEvent { fileId; chunkId; peerId; bytes; elapsedMs }`.
- `ChunkFallbackEvent { fileId; chunkId; bytes; elapsedMs }`.
- `ChunkFailedEvent { fileId; chunkId; reason }`.
- Peer lifecycle:
- `PeerJoinedEvent { peerId; transports }`.
- `PeerLeftEvent { peerId }`.
- `PeerDeniedEvent { peerId; reason }`.
- `PeerScoredEvent { peerId; score; rttMs }`.
- Gossip telemetry:
- `GossipSentEvent { peerId; fileId; sizeBytes }`.
- `GossipReceivedEvent { peerId; fileId; sizeBytes }`.
## Implementation Notes
Every payload is a single object rather than a positional tuple of
primitives. This is intentional: object payloads survive future
schema growth without changing the call signature at every subscriber
site.
`elapsedMs` is measured against the injectable `clock` so tests
running under a `FakeClock` get deterministic latency numbers.
`bytes` is always the size of the verified ciphertext, not the wire
payload, so consumers can compare against the file size in the
manifest without double-counting headers.

View File

@@ -0,0 +1,108 @@
# `peer.types.ts`
## Purpose
Defines the `PeerInfo` overlay tracked by the drive-mesh peer registry.
Drive-mesh does not own peer identity — that comes from
`network-plugin-registry` via the `NetworkBus` (see
`drive-network-integration` spec). What drive-mesh adds on top is
file-specific state: which chunks of which files a peer claims to have,
and a composite score derived from observed latency / success / error
rates.
The `PeerInfo` shape is intentionally read-only at every nested level
so consumers can hand snapshots to telemetry / dashboards without
worrying about external mutation.
## Features
- File-keyed availability bitsets (one per `fileId`).
- EMA latency tracker surfaced as `rttMs`.
- Composite `score: number`; `-Infinity` is the denylist sentinel.
- Multi-transport tracking — same `peerId` may be reachable via
multiple wires.
## Usage: Basic Example
```ts
import type { PeerInfo } from "@suaveplan/drive-mesh/types";
function logTop(peers: Iterable<PeerInfo>) {
const ranked = [...peers].sort((a, b) => b.score - a.score);
for (const peer of ranked.slice(0, 5)) {
console.log(peer.peerId, peer.score, peer.rttMs);
}
}
```
## Usage: Advanced Example
```ts
import type { PeerInfo, PeerDenialReason } from "@suaveplan/drive-mesh/types";
import { DRIVE_MESH_CODES, DriveMeshError } from "@suaveplan/drive-mesh/errors";
function ensureReachable(peer: PeerInfo): void {
if (peer.score === Number.NEGATIVE_INFINITY) {
throw new DriveMeshError(
DRIVE_MESH_CODES.PEER_DENIED,
`${peer.peerId} denylisted`
);
}
if (peer.transports.length === 0) {
throw new DriveMeshError(
DRIVE_MESH_CODES.TRANSPORT_FAILED,
`${peer.peerId} has no reachable transport`
);
}
}
function fileCoverage(peer: PeerInfo, fileId: string): number {
const bits = peer.availability.get(fileId);
if (bits === undefined) return 0;
let count = 0;
for (const byte of bits) count += popcount(byte);
return count;
}
function popcount(byte: number): number {
let n = byte & 0xff;
let c = 0;
while (n) {
c += n & 1;
n >>>= 1;
}
return c;
}
```
## API Reference
- `PeerDenialReason = "bad_sig" | "manual"` — taxonomy surfaced on
denylist counters.
- `PeerInfo`:
- `peerId: PeerId` — wire identity (hex pubkey).
- `transports: readonly string[]` — transport ids the peer is
reachable on; empty iff every transport has dropped it.
- `rttMs: number | null` — EMA-smoothed RTT in ms; `null` until the
first measurement.
- `lastSeen: number` — unix ms of the most recent observation.
- `score: number` — composite score; `-Infinity` is the denylist
sentinel.
- `availability: ReadonlyMap<string, Uint8Array>` — `fileId` →
framed bitset of held chunks.
- `PeerUpdate { transports?; rttMs?; lastSeen? }` — partial mutation
accepted by `PeerRegistry.addOrUpdate`.
## Implementation Notes
`score` is computed by the peer registry from observations rather than
stored mutably from the outside — the registry is the authority. The
field is exposed read-only on `PeerInfo` so consumers (scheduler,
gossip engine, telemetry) can read a stable snapshot. Mutations happen
inside the registry's `record*` methods.
`availability` is keyed by hex `fileId` rather than raw `Uint8Array`
because `Map`'s equality is reference-based — comparing two byte
arrays with the same content would always miss. The framed bitset is
the value rather than a parsed `BitSet` so the gossip engine can
forward bytes without re-encoding.

View File

@@ -0,0 +1,129 @@
# @suaveplan/drive-plugin-cache - Read-through cache middleware for @suaveplan/drive. Accepts a caller-supplied AbstractCache<Uint8Array> and intercepts Drive.read to short-circuit hot paths; write/delete invalidate post-success. Cache backend, eviction policy, TTL, and namespacing are entirely caller-owned.
Read-through cache middleware for [`@suaveplan/drive`](../drive). Accepts a caller-supplied `AbstractCache<Uint8Array>` (from [`@suaveplan/cache`](../../cache/cache)) and intercepts every `drive.read(path)` to return cached plaintext bytes instead of re-running the storage / mesh / decryption stack. `write` and `delete` invalidate the corresponding entry post-success.
The cache itself is opaque to the plugin — consumers pick the backend (`MemoryCache`, `StorageCache` over OPFS / file / S3 / CDN, `RedisCache`), the eviction policy, the TTL, the namespacing, the codec, the redis/storage instance. The plugin just talks `AbstractCache`.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the structured error surface for `@suaveplan/drive-plugin-cache`. It exports the `DRIVE_CACHE_…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the structured error surface for `@suaveplan/drive-plugin-cache`. It exports the `DRIVE_CACHE_…
- [handle](./src/handle/handle.md) — `handle.ts` builds the `DriveCacheHandle` returned by `driveCachePlugin.install(drive)`. The handle exposes a stats sna…
- [handle](./src/handle/handle.md) — `handle.ts` builds the `DriveCacheHandle` returned by `driveCachePlugin.install(drive)`. The handle exposes a stats sna…
- [middleware](./src/middleware/middleware.md) — `middleware.ts` builds the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`. The middleware intercepts `…
- [middleware.invalidation](./src/middleware/middleware.invalidation.md) — `middleware.invalidation.ts` provides the `write` and `delete` hooks of the `DriveMiddleware` built in `middleware.ts`.…
- [middleware](./src/middleware/middleware.md) — `middleware.ts` builds the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`. The middleware intercepts `…
- [middleware.signal](./src/middleware/middleware.signal.md) — `middleware.signal.ts` provides two tiny `AbortSignal` helpers used by the drive-cache middleware: `getSignal(ctx)` ext…
- [otel](./src/otel/otel.md) — `otel.ts` wires the `@suaveplan/drive-cache` meter and tracer used by every middleware intercept in `@suaveplan/drive-p…
- [otel](./src/otel/otel.md) — `otel.ts` wires the `@suaveplan/drive-cache` meter and tracer used by every middleware intercept in `@suaveplan/drive-p…
- [plugin](./src/plugin/plugin.md) — `plugin.ts` is the public entry point of `@suaveplan/drive-plugin-cache`. It exports the `driveCachePlugin(options): Dr…
- [plugin](./src/plugin/plugin.md) — `plugin.ts` is the public entry point of `@suaveplan/drive-plugin-cache`. It exports the `driveCachePlugin(options): Dr…
<!-- end:auto-docs -->
## Modules
- [`plugin`](./src/plugin/plugin.md) — `driveCachePlugin(options): DrivePlugin<DriveCacheHandle>` factory.
- [`middleware`](./src/middleware/middleware.md) — the read intercept (hit short-circuit + populate-on-miss).
- [`middleware.invalidation`](./src/middleware/middleware.invalidation.md) — write/delete invalidation hooks.
- [`middleware.signal`](./src/middleware/middleware.signal.md) — `AbortSignal` helpers.
- [`handle`](./src/handle/handle.md) — `DriveCacheHandle` builder.
- [`otel`](./src/otel/otel.md) — `@suaveplan/drive-cache` meter + tracer wiring.
- [`error-codes`](./src/error-codes/error-codes.md) — `DRIVE_CACHE_CODES` registry and `DriveCacheError`.
- [`types`](./src/types/options.types.md) — public type surface.
## Quickstart
```ts
import { Drive } from "@suaveplan/drive";
import { MemoryCache } from "@suaveplan/cache";
import { driveCachePlugin } from "@suaveplan/drive-plugin-cache";
const cache = new MemoryCache<Uint8Array>();
const drive = new Drive(storage);
await drive.initialize();
const handle = await drive.usePlugin(driveCachePlugin({ cache }));
await drive.write("clip.mov", payload);
const bytes1 = await drive.read("clip.mov"); // miss → populate
const bytes2 = await drive.read("clip.mov"); // hit → cached
console.log(handle.getStats()); // { hits: 1, misses: 1, ... }
```
## Composition recipes
### 1. Memory cache (zero deps, ephemeral, fastest)
```ts
import { MemoryCache } from "@suaveplan/cache";
const cache = new MemoryCache<Uint8Array>({ maxSize: 10_000 });
await drive.usePlugin(driveCachePlugin({ cache }));
```
### 2. OPFS-backed cache (browser, survives reloads)
```ts
import { createOpfsStorage } from "@suaveplan/storage-browser-opfs";
import { StorageCache, IdentityCodec } from "@suaveplan/cache-storage";
const cache = new StorageCache<Uint8Array>({
storage: await createOpfsStorage("drive-cache"),
codec: new IdentityCodec(),
});
await drive.usePlugin(driveCachePlugin({ cache, namespace: "media" }));
```
### 3. File-backed cache (server, survives process restart)
```ts
import { createNodeFsStorage } from "@suaveplan/storage-file";
import { StorageCache, IdentityCodec } from "@suaveplan/cache-storage";
const cache = new StorageCache<Uint8Array>({
storage: createNodeFsStorage({ rootDir: "/var/cache/drive", mkdirp: true }),
codec: new IdentityCodec(),
});
await drive.usePlugin(driveCachePlugin({ cache }));
```
### 4. Redis-backed cache (server, cross-process shared)
```ts
import { createRedisCache } from "@suaveplan/cache-redis";
const cache = createRedisCache({ client, prefix: "drive-cache:" });
await drive.usePlugin(driveCachePlugin({ cache, namespace: "media" }));
```
### 5. Stale-while-revalidate via `cache.wrap()`
```ts
const handle = await drive.usePlugin(driveCachePlugin({ cache }));
const bytes = await handle.cache.wrap(
"drive::clip.mov",
() => drive.read("clip.mov"),
{ staleAge: 60_000 }
);
```
Recipe 5 illustrates why the plugin exposes `handle.cache`: stale-while-revalidate, lock-and-load, refresh-ahead, and every other policy `cache.wrap()` already supports stays one composition step away — without the plugin growing options for each.
## Defaults
| Option | Default | Rationale |
|---|---|---|
| `namespace` | `"drive"` | Single-drive consumers don't need to pick. Multi-drive consumers pass distinct strings and share one cache. |
| `invalidateOnWrite` | `true` | Cached bytes go stale the moment storage accepts new bytes. Post-success deletion is the safe default. |
| `invalidateOnDelete` | `true` | Symmetric with `invalidateOnWrite`. |
| `maxEntryBytes` | `16 * 1024 * 1024` (16 MiB) | Matches `drive-mesh-protocol`'s `MAX_FRAME_BYTES`. Empirically safe in every consumer already running drive. Bypass via a custom `shouldCache` for large-blob workflows. |
| `shouldCache` | `bytes.byteLength <= maxEntryBytes` | The byte-ceiling check is the only failure mode for the default predicate. |
## Out of scope
- Caching `Drive.stat` / `Drive.list` results.
- Range / streaming reads (`Drive.createReadStream`) — bypass middleware by design.
- Per-tenant key partitioning (`keyFn(ctx)` extension).
- Built-in `wrap()` integration — consumers compose via `handle.cache.wrap(...)`.
See the design proposal for the full design rationale.

View File

@@ -0,0 +1,60 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` defines the structured error surface for `@suaveplan/drive-plugin-cache`. It exports the `DRIVE_CACHE_CODES` registry produced by `defineCodes("DRIVE_CACHE", [...] as const)` and the `DriveCacheError` class that carries optional `path`, `namespace`, `reason`, `byteLength`, and `maxEntryBytes` discriminants for operator correlation.
The plugin deliberately owns a narrow registry — three codes — because the interesting failures are upstream (`CACHE.*`, `DRIVE.*`) and pass through unchanged. Allocating a wide error registry here would bloat the surface and mask the real diagnostic value already carried by the cache and drive packages.
## Features
- Frozen `DRIVE_CACHE_CODES` registry with three reasons: `INVALID_OPTIONS`, `CACHE_TOO_LARGE`, `INSTALL_FAILED`.
- `DriveCacheError extends SuaveplanError<DriveCacheErrorCode>` with five optional discriminants.
- Every code is reachable from a real call site exercised by the test suite.
## Usage: Basic
```ts
import { DRIVE_CACHE_CODES, DriveCacheError } from "@suaveplan/drive-plugin-cache/error-codes";
throw new DriveCacheError(
DRIVE_CACHE_CODES.INVALID_OPTIONS,
"driveCachePlugin requires options.cache",
{ reason: "cache" }
);
```
## Usage: Advanced
```ts
import { DriveCacheError, DRIVE_CACHE_CODES } from "@suaveplan/drive-plugin-cache/error-codes";
try {
drive.useMiddleware(myMiddleware);
} catch (cause) {
throw new DriveCacheError(
DRIVE_CACHE_CODES.INSTALL_FAILED,
"Drive.useMiddleware rejected the cache middleware",
{ cause, namespace: "drive" }
);
}
```
Catch by `instanceof DriveCacheError` to access the discriminants; the original underlying throwable is preserved on `cause` via the `SuaveplanError` base class so operators can walk the chain with `walkCauses`.
## API Reference
| Export | Shape | Description |
|---|---|---|
| `DRIVE_CACHE_CODES` | `{ readonly INVALID_OPTIONS: "DRIVE_CACHE.INVALID_OPTIONS"; ... }` | Frozen record of every code string. |
| `DriveCacheErrorCode` | union of the three code string literals | Discriminant for switch/match. |
| `DriveCacheErrorOptions` | `{ path?: string; namespace?: string; reason?: string; byteLength?: number; maxEntryBytes?: number; cause?: unknown; ... }` | Constructor options bag. |
| `DriveCacheError` | `extends SuaveplanError<DriveCacheErrorCode>` | Structured error with discriminants and preserved cause chain. |
## Implementation Notes
The registry uses `as const` on the reasons array so `defineCodes` can infer the literal-typed return shape. The class assigns each discriminant from the options bag in the constructor, defaulting to `undefined` when absent — the field is declared `readonly` to make the populated state immutable after construction. `this.name = "DriveCacheError"` aligns runtime `name` with the class name for stack traces in environments that read it (e.g. Sentry, V8 stack frames).

View File

@@ -0,0 +1,59 @@
# `handle`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`handle.ts` builds the `DriveCacheHandle` returned by `driveCachePlugin.install(drive)`. The handle exposes a stats snapshot, a single-path invalidator, a cache clear shortcut, and the underlying `AbstractCache` reference for advanced wiring (e.g. `cache.wrap(key, loader, { staleAge })`).
It also defines the `MutableStats` counter bag shared between the middleware (which increments inline as `read`/`write`/`delete` interceptions resolve) and the handle (whose `getStats()` returns a shallow snapshot so consumers cannot mutate the live counters).
## Features
- `createMutableStats()` builds a fresh zeroed counter bag per plugin instance.
- `createHandle({ cache, stats, namespace })` returns a `DriveCacheHandle` whose methods read/mutate the shared `stats` reference without ever exposing it.
- `getStats()` always returns a fresh shallow snapshot, so callers can cache the value without risk of mutation.
- `invalidate(path)` swallows underlying cache errors, increments `stats.errors`, and records via OTel.
## Usage: Basic
```ts
import { createHandle, createMutableStats } from "@suaveplan/drive-plugin-cache/handle";
const stats = createMutableStats();
const handle = createHandle({ cache, stats, namespace: "drive" });
await handle.invalidate("clip.mov");
const snapshot = handle.getStats();
```
## Usage: Advanced
```ts
const handle = createHandle({ cache, stats, namespace: "media" });
// Compose with cache.wrap for stale-while-revalidate
const bytes = await handle.cache.wrap(
"media::clip.mov",
() => drive.read("clip.mov"),
{ staleAge: 60_000 }
);
// Or clear when switching drives
await handle.clear();
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `MutableStats` | `{ hits: number; misses: number; populations: number; invalidations: number; errors: number }` | Mutable counter bag — middleware-internal, never exposed. |
| `createMutableStats()` | `() => MutableStats` | Fresh zeroed instance per plugin. |
| `CreateHandleArgs` | `{ cache; stats; namespace }` | Constructor bag. |
| `createHandle(args)` | `(CreateHandleArgs) => DriveCacheHandle` | Returns the handle whose `getStats()` snapshots the shared counter. |
## Implementation Notes
`getStats()` returns a fresh plain object every call rather than spreading the underlying `stats` reference. This produces an identical observable behaviour but lets the live counter bag stay untouched even if a consumer mutates the returned object (e.g. `Object.assign(snap, { hits: 0 })` after caching the snapshot somewhere). The `invalidate(path)` method silently swallows underlying cache errors — failure to delete a stale cache entry is a non-fatal optimisation slip, not a contract breach. The error is captured via `stats.errors += 1` and the OTel `errors_total{namespace, op: "delete"}` counter so operators can still see it.

View File

@@ -0,0 +1,53 @@
# `middleware.invalidation`
## Purpose
`middleware.invalidation.ts` provides the `write` and `delete` hooks of the `DriveMiddleware` built in `middleware.ts`. Each hook runs `next()` first; on successful resolution it deletes the corresponding cache entry. When `next()` throws, the throw propagates and the cache is left untouched — invariably the bytes in the cache are still the bytes the consumer last wrote.
This split keeps `middleware.ts` focused on the read path (the hot path) and makes the invalidation logic independently testable. The two hooks share a single `invalidate(path)` closure that wraps `cache.delete` with stats accounting, OTel recording, and error swallowing.
## Features
- Post-success invalidation — no race window where a concurrent read can repopulate stale bytes between the cache delete and the storage write.
- `invalidateOnWrite` / `invalidateOnDelete` per-side toggles default to `true` but accept `false` for callers wanting append-only or write-through behaviour.
- Underlying `cache.delete` rejection is swallowed, counted on `stats.errors`, and recorded via OTel as `errors_total{namespace, op: "delete"}`.
- The `delete` hook returns the original `next()` boolean unchanged — semantics match `Drive.delete`'s "did the file exist" contract.
## Usage: Basic
```ts
import { buildInvalidationMiddleware } from "@suaveplan/drive-plugin-cache/middleware";
const hooks = buildInvalidationMiddleware({
cache,
stats,
namespace: "drive",
invalidateOnWrite: true,
invalidateOnDelete: true,
});
```
## Usage: Advanced
```ts
const hooks = buildInvalidationMiddleware({
cache,
stats,
namespace: "audit",
invalidateOnWrite: false, // append-only: writes never displace cached bytes
invalidateOnDelete: true,
logger,
});
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `BuildInvalidationArgs` | `{ cache; stats; namespace; invalidateOnWrite; invalidateOnDelete; logger? }` | Constructor bag. |
| `InvalidationHooks` | `{ write(ctx, next); delete(ctx, next) }` | Hook pair compatible with `DriveMiddleware`. |
| `buildInvalidationMiddleware(args)` | `(BuildInvalidationArgs) => InvalidationHooks` | Returns the two hooks. |
## Implementation Notes
The shared `invalidate(path)` helper is a closure over the per-build state — `cache`, `stats`, `namespace`, optional `logger`. This avoids passing those four parameters through every invocation while keeping the function pure modulo the explicit mutable state. The `delete` hook still calls `invalidate` when `next()` returns `false` (the file did not exist on disk). This is safe — `cache.delete` is idempotent on missing keys — and it cleans any stale cache entry produced by an earlier reader that observed the file before a concurrent removal.

View File

@@ -0,0 +1,57 @@
# `middleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`middleware.ts` builds the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`. The middleware intercepts `read(ctx, next)` to short-circuit hot paths from the cache and to populate fresh entries on a miss. `write` and `delete` interceptions are delegated to `middleware.invalidation`. `stat` and `list` are pure passthroughs in this iteration.
A cache is a perf optimisation, not a correctness boundary. Every underlying cache failure (`get` rejection, `set` rejection, `delete` rejection) is captured into `stats.errors` and the OTel `errors_total{namespace, op}` counter — and is never propagated to the consumer.
## Features
- Pre-aborted `AbortSignal` short-circuits the cache work and defers to `next()` so the inner storage surfaces canonical `DRIVE.ABORTED`.
- Live `AbortSignal` is forwarded to `cache.get` for caches whose `get` accepts a signal.
- `shouldCache(path, bytes)` predicate gates population — defaults to a 16-MiB byte ceiling matching `drive-mesh-protocol`'s `MAX_FRAME_BYTES`.
- Population enforces the byte ceiling defensively; an oversize entry raises a structured `DRIVE_CACHE.CACHE_TOO_LARGE` internally and is suppressed (counted on `stats.errors`).
- `stat` and `list` pass straight to `next()` without touching the cache.
## Usage: Basic
```ts
import { buildMiddleware } from "@suaveplan/drive-plugin-cache/middleware";
import { createMutableStats } from "@suaveplan/drive-plugin-cache/handle";
const stats = createMutableStats();
const mw = buildMiddleware({ cache, stats, namespace: "drive" });
drive.useMiddleware(mw);
```
## Usage: Advanced
```ts
const mw = buildMiddleware({
cache,
stats,
namespace: "media",
invalidateOnWrite: true,
invalidateOnDelete: false,
shouldCache: (path, bytes) =>
path.endsWith(".mov") && bytes.byteLength <= 8 * 1024 * 1024,
maxEntryBytes: 8 * 1024 * 1024,
logger,
});
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `BuildMiddlewareArgs` | `{ cache; stats; namespace?; invalidateOnWrite?; invalidateOnDelete?; shouldCache?; maxEntryBytes?; logger? }` | Constructor bag. |
| `buildMiddleware(args)` | `(BuildMiddlewareArgs) => DriveMiddleware` | Returns the full `DriveMiddleware` (read + write + delete + stat + list). |
## Implementation Notes
The cache key is computed as `${namespace}::${ctx.path}`. The `::` separator avoids accidental collision with paths that already contain `/`. Population happens **after** `next()` resolves — i.e. only when the inner storage actually produced bytes — so a thrown `next()` cannot poison the cache. Symmetrically, invalidation happens **after** a successful `write`/`delete` `next()`. The optimistic alternative (delete before next()) opens a race window where a concurrent read could repopulate stale bytes between the cache delete and the storage write.

View File

@@ -0,0 +1,46 @@
# `middleware.signal`
## Purpose
`middleware.signal.ts` provides two tiny `AbortSignal` helpers used by the drive-cache middleware: `getSignal(ctx)` extracts a signal from the open-shaped `MiddlewareContext`, and `isPreAborted(ctx)` returns true when the extracted signal is already aborted at the moment the middleware runs.
Drive's core does not currently thread a signal into `ctx`. The `MiddlewareContext` shape is an open `[key: string]: unknown` carrier, so downstream extensions and future Drive versions can. Centralising the lookup here means the read/write/delete middleware code stays a single line, and updating to a future canonical `ctx.signal` field is a one-file change.
## Features
- Type-safe extraction — returns `AbortSignal | undefined`, never throws.
- `isPreAborted` collapses the "have a signal and it is aborted" check into a single boolean.
- No runtime dependency — purely structural over the `MiddlewareContext` open shape.
## Usage: Basic
```ts
import { isPreAborted, getSignal } from "@suaveplan/drive-plugin-cache/middleware";
if (isPreAborted(ctx)) return next();
const signal = getSignal(ctx);
```
## Usage: Advanced
```ts
import { getSignal } from "@suaveplan/drive-plugin-cache/middleware";
const signal = getSignal(ctx);
if (signal !== undefined) {
// Pass the signal into the underlying cache.get when supported.
await (cache as { get(k: string, s?: AbortSignal): Promise<Uint8Array | undefined> })
.get(key, signal);
}
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `getSignal(ctx)` | `(MiddlewareContext) => AbortSignal \| undefined` | Returns the signal carried on `ctx.signal` when it is an `AbortSignal`, else `undefined`. |
| `isPreAborted(ctx)` | `(MiddlewareContext) => boolean` | True when `getSignal(ctx)?.aborted === true`. |
## Implementation Notes
The `instanceof AbortSignal` guard rejects loose duck-typed shapes that happen to carry an `aborted` boolean but lack the full signal contract (no `addEventListener`, no `reason`). This matters because forwarding such a shape into `cache.get` could cause the cache to call `signal.addEventListener` on a non-EventTarget object. The instanceof check is cheap and correct for every standards-conformant runtime; consumers building their own signal-shaped object should construct a real `AbortController` and surface its `signal`.

View File

@@ -0,0 +1,64 @@
# `otel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel.ts` wires the `@suaveplan/drive-cache` meter and tracer used by every middleware intercept in `@suaveplan/drive-plugin-cache`. It exposes lazily-built `getInstruments()`, a `setDriveCacheMeterProvider` override (so tests can inject a stub provider), `resetInstruments()` for inter-test isolation, and five `recordX` helpers that pair a counter increment with a single short-lived span carrying the canonical `drive.cache.*` attributes.
The instruments are cached after the first call and rebuilt only when the provider override changes or `resetInstruments()` is called. This avoids re-creating instrument handles on the hot read path while still letting tests drive their own provider.
## Features
- `drive_cache.hits_total{namespace}` / `misses_total{namespace}` / `populations_total{namespace}` / `invalidations_total{namespace}` counters.
- `drive_cache.errors_total{namespace, op}` counter — `op ∈ {"get", "set", "delete"}`.
- `drive_cache.entry_bytes{namespace}` histogram observed on every population.
- One-span-per-event spans with `drive.cache.namespace`, `drive.cache.path`, and `drive.cache.outcome ∈ {"hit", "miss", "populated", "invalidated", "error"}` attributes.
- `setDriveCacheMeterProvider(provider)` resets the cached instruments so tests can swap providers between cases.
## Usage: Basic
```ts
import { recordHit, recordMiss, recordPopulate } from "@suaveplan/drive-plugin-cache/otel";
recordMiss("drive", "clip.mov");
recordPopulate("drive", "clip.mov", 4096);
recordHit("drive", "clip.mov");
```
## Usage: Advanced
```ts
import {
setDriveCacheMeterProvider,
resetInstruments,
recordError,
} from "@suaveplan/drive-plugin-cache/otel";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider();
setDriveCacheMeterProvider(provider);
recordError("drive", "set", "clip.mov");
// run assertions on provider's metric readers
setDriveCacheMeterProvider(undefined);
resetInstruments();
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `getInstruments(force?)` | `() => DriveCacheInstruments` | Lazy accessor; pass `true` to force-rebuild. |
| `resetInstruments()` | `() => void` | Clear the cached bag (test-only). |
| `setDriveCacheMeterProvider(provider)` | `(p?: MeterProvider) => void` | Swap the meter provider; auto-resets the cache. |
| `recordHit(namespace, path)` | `(string, string) => void` | Increment hits + emit `outcome="hit"` span. |
| `recordMiss(namespace, path)` | `(string, string) => void` | Increment misses + emit `outcome="miss"` span. |
| `recordPopulate(namespace, path, byteLength)` | `(string, string, number) => void` | Increment populations + observe entry size + emit `outcome="populated"` span. |
| `recordInvalidate(namespace, path)` | `(string, string) => void` | Increment invalidations + emit `outcome="invalidated"` span. |
| `recordError(namespace, op, path)` | `(string, "get" \| "set" \| "delete", string) => void` | Increment errors + emit `outcome="error"` span with `op` attribute. |
## Implementation Notes
Spans are created via `tracer.startSpan(...).end()` rather than `tracer.startActiveSpan(...)`. The middleware does its own work inside the calling async flow; the only purpose of these spans is to carry the outcome attribute for trace-backed analysis. Long-lived active spans would force every middleware intercept to thread an `OpenTelemetry.Context`, which is overkill for a cache event. Counter attribute bags are constructed inline as plain objects — V8 inlines this allocation on the hot path so the impact is undetectable.

View File

@@ -0,0 +1,66 @@
# `plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`plugin.ts` is the public entry point of `@suaveplan/drive-plugin-cache`. It exports the `driveCachePlugin(options): DrivePlugin<DriveCacheHandle>` factory — the only function consumers normally need. The factory validates the options synchronously (so misuse fails fast at construction, not on first read), then assembles the cache middleware, stats bag, and handle.
`install(drive)` registers the middleware on the supplied `DriveLike` via `drive.useMiddleware` and returns the `DriveCacheHandle`. If `drive.useMiddleware` itself throws, the failure is wrapped as `DRIVE_CACHE.INSTALL_FAILED` so operators can correlate the install-time error chain.
## Features
- Synchronous `DRIVE_CACHE.INVALID_OPTIONS` throw when `options.cache` is missing or lacks function-typed `get`/`set`/`delete` methods.
- `INSTALL_FAILED` wraps any throw from `drive.useMiddleware`, preserving the original cause.
- Default namespace `"drive"`, default invalidation `true` for both write and delete, default `shouldCache` enforces the 16-MiB ceiling.
- Returns `DriveCacheHandle` whose `cache` reference is identical (`===`) to the cache passed in `options`.
## Usage: Basic
```ts
import { driveCachePlugin } from "@suaveplan/drive-plugin-cache";
import { MemoryCache } from "@suaveplan/cache";
const cache = new MemoryCache<Uint8Array>();
const handle = await drive.usePlugin(driveCachePlugin({ cache }));
const bytes1 = await drive.read("clip.mov"); // miss → next() → populate
const bytes2 = await drive.read("clip.mov"); // hit → return cached
```
## Usage: Advanced
```ts
import { driveCachePlugin } from "@suaveplan/drive-plugin-cache";
const handleA = await driveA.usePlugin(
driveCachePlugin({
cache, // shared underlying AbstractCache
namespace: "a", // distinct namespace per drive
invalidateOnWrite: true,
invalidateOnDelete: true,
shouldCache: (path, bytes) =>
bytes.byteLength <= 8 * 1024 * 1024,
maxEntryBytes: 8 * 1024 * 1024,
logger,
})
);
const handleB = await driveB.usePlugin(
driveCachePlugin({ cache, namespace: "b" })
);
// handleA and handleB share `cache` but their entries never overlap.
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `driveCachePlugin(options)` | `(DriveCacheOptions) => DrivePlugin<DriveCacheHandle>` | Factory. Throws `DRIVE_CACHE.INVALID_OPTIONS` synchronously on misuse. |
The returned plugin object exposes `name === "@suaveplan/drive-plugin-cache"` and an `install(drive)` method that registers the middleware and returns the handle.
## Implementation Notes
Option validation runs once at factory time. The factory snapshots the namespace into the middleware closure and into the handle closure separately — both stay in sync because the namespace is immutable after construction. The single mutable state shared between the middleware and the handle is the `MutableStats` counter bag; the handle's `getStats()` returns shallow snapshots, so consumers cannot reach back into the live counters.

View File

@@ -0,0 +1,52 @@
# `options.types`
## Purpose
`options.types.ts` declares the three public interfaces consumed across `@suaveplan/drive-plugin-cache`: `DriveCacheOptions` (the factory input), `DriveCacheHandle` (the install return value), and `DriveCacheStats` (the snapshot shape exposed by `handle.getStats()`). These types are the public contract — everything else in the package is implementation detail.
The shapes are deliberately small. `DriveCacheOptions` carries one required field (`cache`) and five optional knobs (invalidation toggles, namespace, predicate, byte ceiling, logger). `DriveCacheHandle` carries one readonly reference (`cache`) and three methods (`getStats`, `invalidate`, `clear`). `DriveCacheStats` is five readonly counters — `hits`, `misses`, `populations`, `invalidations`, `errors`.
## Features
- Strict `readonly` on every field so consumers cannot mutate snapshots.
- `cache: AbstractCache<Uint8Array>` — single injectable; backend is fully caller-owned.
- Defaults documented inline so the consumer can predict the runtime behaviour without reading source.
## Usage: Basic
```ts
import type { DriveCacheOptions } from "@suaveplan/drive-plugin-cache/types";
const options: DriveCacheOptions = {
cache: new MemoryCache<Uint8Array>(),
};
```
## Usage: Advanced
```ts
import type { DriveCacheOptions } from "@suaveplan/drive-plugin-cache/types";
const options: DriveCacheOptions = {
cache,
namespace: "media",
invalidateOnWrite: true,
invalidateOnDelete: false,
shouldCache: (path, bytes) =>
path.endsWith(".mov") && bytes.byteLength < 8 * 1024 * 1024,
maxEntryBytes: 8 * 1024 * 1024,
logger,
};
```
## API Reference
| Type | Fields | Notes |
|---|---|---|
| `DriveCacheOptions` | `cache; invalidateOnWrite?; invalidateOnDelete?; namespace?; shouldCache?; maxEntryBytes?; logger?` | Factory input. |
| `DriveCacheHandle` | `cache; getStats(); invalidate(path); clear()` | Install return value. |
| `DriveCacheStats` | `hits; misses; populations; invalidations; errors` | Snapshot returned by `getStats()`. All readonly. |
## Implementation Notes
The `readonly` modifier on `DriveCacheOptions` fields prevents accidental in-place mutation of options after they cross the factory boundary. The factory snapshots required values into local closure state at construction time so post-install mutation of the options object never affects runtime behaviour anyway. `AbstractCache<Uint8Array>` is the canonical surface — concrete cache implementations (`MemoryCache`, `StorageCache`, `RedisCache`) extend it without runtime cost.

View File

@@ -0,0 +1,129 @@
# @suaveplan/drive-plugin-chunks - Chunk-addressing plugin for @suaveplan/drive. Defines the version 0x0001 binary chunk format, server-signed manifests, storage layouts, and streaming encrypt-then-chunk pipeline.
## Summary
Chunk-addressing plugin for `@suaveplan/drive`. Splits files into signed, encrypted chunks using the locked `version 0x0001` binary format, with server-signed manifests and two storage layouts (`sparse-file` and `chunk-keys`).
<!-- begin:auto-docs -->
## Documentation tree
- [chunk](./src/codec/chunk.md) — Wraps the chunk wire format above the bare header: takes raw chunk bytes (header + signature + ciphertext) and parses t…
- [header](./src/codec/header.md) — Encodes and parses the fixed-width 100-byte metadata header that prefixes every chunk in the `@suaveplan/drive-plugin-c…
- [inline](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
<!-- end:auto-docs --> - [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
## Install
```bash
bun add @suaveplan/drive-plugin-chunks
```
## Quick start
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
import { MemoryStorage } from "@suaveplan/storage/memory";
const storage = Object.assign(new MemoryStorage(), { id: "local" });
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: ed25519PrivKey,
serverPubKey: ed25519PubKey,
chunkSize: 65536,
})
);
await drive.write("hello.txt", new TextEncoder().encode("world"));
await chunks.ingest("hello.txt");
const manifest = await chunks.loadManifest(fileId);
console.log(manifest.totalChunks);
await drive.dispose();
```
## Modules
- [src/codec/header.md](src/codec/header.md) — binary header encoding / parsing
- [src/codec/chunk.md](src/codec/chunk.md) — full chunk encode / parse
- [src/pipeline/encrypt-stream.md](src/pipeline/encrypt-stream.md) — XChaCha20-Poly1305 streaming encryption
- [src/pipeline/chop-stream.md](src/pipeline/chop-stream.md) — ciphertext slicing into chunk payloads
- [src/pipeline/sign-chunk.md](src/pipeline/sign-chunk.md) — ed25519 server signing
- [src/pipeline/verify-chunk.md](src/pipeline/verify-chunk.md) — signature verification
- [src/layout/sparse-file.md](src/layout/sparse-file.md) — single-object sparse layout
- [src/layout/chunk-keys.md](src/layout/chunk-keys.md) — one-key-per-chunk layout
- [src/layout/auto.md](src/layout/auto.md) — automatic layout selection
- [src/manifest/build.md](src/manifest/build.md) — manifest assembly and signing
- [src/manifest/load.md](src/manifest/load.md) — manifest fetching and validation
- [src/manifest/source.md](src/manifest/source.md) — distribution strategy switch
- [src/plugin.md](src/plugin.md) — plugin handle and Drive middleware
- [src/types/chunks.types.md](src/types/chunks.types.md) — public TypeScript interfaces
- [src/schemas/manifest.schema.md](src/schemas/manifest.schema.md) — Zod boundary schemas
- [src/error-codes.md](src/error-codes.md) — `CHUNKS_CODES` and `ChunksError`

View File

@@ -0,0 +1,51 @@
# chunk
## Purpose
Wraps the chunk wire format above the bare header: takes raw chunk bytes (header + signature + ciphertext) and parses them into the structured `Chunk` and `EncodedChunk` shapes that the rest of the plugin consumes. This is the inverse of `encrypt-stream`'s output and the input to `verify-chunk` / `decrypt-chunk`.
## Features
- `parseChunk(raw): Chunk` — splits raw bytes into `metadata`, `sig`, and `ciphertext` slices using `HEADER_SIZE` as the boundary.
- Length validation — rejects anything shorter than `HEADER_SIZE + payloadLen`.
- Allocation-light — uses `Uint8Array.subarray` views where safe and `slice` only where ownership matters.
- Round-trip stable when paired with `encodeHeader` and the encrypt-stream output.
## Usage: Basic Example
```ts
import { parseChunk } from "@suaveplan/drive-plugin-chunks";
const raw: Uint8Array = await chunks.getChunk(fileId, 0).then((b) => b!);
const chunk = parseChunk(raw);
console.log(chunk.metadata.chunkId, chunk.ciphertext.length);
```
## Usage: Advanced Example
```ts
import {
decryptChunk,
parseChunk,
verifyChunkSig,
} from "@suaveplan/drive-plugin-chunks";
async function readVerifiedChunk(fileId: Uint8Array, idx: number) {
const raw = await chunks.getChunk(fileId, idx);
if (!raw) throw new Error("missing chunk");
const chunk = parseChunk(raw);
verifyChunkSig(raw, serverPubKey);
const plaintext = decryptChunk(chunk.metadata, chunk.ciphertext, fileKey);
return plaintext.slice(0, chunk.metadata.payloadLen);
}
```
## API Reference
- `parseChunk(raw: Uint8Array): Chunk` — returns `{ metadata, sig, ciphertext }`. `metadata` flows through `parseHeader`; `sig` is the 64-byte Ed25519 signature slice; `ciphertext` is the trailing `payloadLen` bytes.
- Throws `ChunksError(CHUNKS.METADATA_INVALID)` for under-length buffers (delegated to `parseHeader`).
- Throws `ChunksError(CHUNKS.PAYLOAD_LEN_INVALID)` when the trailing payload is shorter than `metadata.payloadLen`.
## Implementation Notes
`parseChunk` does not verify the signature or decrypt — those are independent stages so peers can short-circuit on metadata-only inspection (manifest checks, availability gossip). The ciphertext is sliced rather than viewed when its length differs from the underlying buffer to avoid pinning the larger raw allocation in caches.

View File

@@ -0,0 +1,67 @@
# header
## Purpose
Encodes and parses the fixed-width 100-byte metadata header that prefixes every chunk in the `@suaveplan/drive-plugin-chunks` wire format. Big-endian throughout, no trailing version bytes, layout pinned by constants so receivers can locate each field without consulting the manifest.
## Features
- `encodeHeader(metadata): Uint8Array` — packs `ChunkMetadata` into a 100-byte buffer using big-endian byte order.
- `parseHeader(buf): ChunkMetadata` — reverses the encoding and rejects under-length buffers with `CHUNKS.METADATA_INVALID`.
- `METADATA_SIZE` (100) — header length excluding signature.
- `SIG_SIZE` (64) — signature length.
- `HEADER_SIZE` (164) — `METADATA_SIZE + SIG_SIZE`, used by `encrypt-stream` and the layout helpers.
## Usage: Basic Example
```ts
import { encodeHeader, parseHeader } from "@suaveplan/drive-plugin-chunks";
const metadata = {
version: 1, fileId: new Uint8Array(32), chunkId: 0, totalChunks: 1,
fileSize: 8n, chunkSize: 1024, payloadLen: 8, flags: 0,
nonce: new Uint8Array(24), aeadTag: new Uint8Array(16),
};
const header = encodeHeader(metadata);
const back = parseHeader(header);
```
## Usage: Advanced Example
```ts
import { HEADER_SIZE, METADATA_SIZE, SIG_SIZE, parseHeader } from "@suaveplan/drive-plugin-chunks";
function decodeChunk(raw: Uint8Array) {
const metadata = parseHeader(raw.subarray(0, METADATA_SIZE));
const sig = raw.subarray(METADATA_SIZE, HEADER_SIZE);
const ciphertext = raw.subarray(HEADER_SIZE, HEADER_SIZE + metadata.payloadLen);
return { metadata, sig, ciphertext };
}
```
## API Reference
- `METADATA_SIZE: 100` — bytes reserved for metadata.
- `SIG_SIZE: 64` — bytes reserved for the Ed25519 signature.
- `HEADER_SIZE: 164` — `METADATA_SIZE + SIG_SIZE`.
- `encodeHeader(metadata: ChunkMetadata): Uint8Array` — returns a freshly-allocated 100-byte buffer.
- `parseHeader(buf: Uint8Array): ChunkMetadata` — throws `ChunksError(CHUNKS.METADATA_INVALID)` if `buf.length < METADATA_SIZE`. Slices `fileId`, `nonce`, and `aeadTag` so callers can mutate the input safely.
## Implementation Notes
Layout (big-endian):
| Offset | Length | Field |
|-------:|-------:|--------------|
| 0 | 2 | version |
| 2 | 32 | fileId |
| 34 | 4 | chunkId |
| 38 | 4 | totalChunks |
| 42 | 8 | fileSize (u64) |
| 50 | 4 | chunkSize |
| 54 | 4 | payloadLen |
| 58 | 2 | flags |
| 60 | 24 | nonce |
| 84 | 16 | aeadTag |
The header pads to exactly 100 bytes so `encrypt-stream` can compute payload offsets without recomputing field sums.

View File

@@ -0,0 +1,59 @@
# error-codes
## Purpose
Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin can produce is registered through `defineCodes("CHUNKS", [...])` so that downstream consumers can branch on stable string codes (`"CHUNKS.AEAD_TAG_INVALID"`, etc.) without parsing English-language error messages. The companion `ChunksError` class extends the project-wide `SuaveplanError` base so chunked-file errors compose with cause-chain traversal, redaction, and OTel span-event adapters defined in `@suaveplan/error`.
## Features
- Frozen `CHUNKS_CODES` enum-shaped constant covering the twelve failure classes that can occur during chunk encoding, signing, manifest exchange, layout selection, ingest, and inline-hint verification.
- `ChunksErrorCode` literal union derived from `CHUNKS_CODES` so call sites stay type-safe.
- `ChunksError` class with the `SuaveplanError` constructor signature widened to public, accepting `(code, message, options?)` plus the standard `cause` field.
- Side-effect-safe registration via `defineCodes` so subpath consumers automatically see codes registered too.
## Usage: Basic Example
```ts
import { CHUNKS_CODES, ChunksError } from "@suaveplan/drive-plugin-chunks";
throw new ChunksError(
CHUNKS_CODES.AEAD_TAG_INVALID,
"chunk AEAD tag verification failed"
);
```
## Usage: Advanced Example
```ts
import { CHUNKS_CODES, ChunksError } from "@suaveplan/drive-plugin-chunks";
try {
await chunks.ingest("photo.jpg");
} catch (err) {
if (err instanceof ChunksError) {
switch (err.code) {
case CHUNKS_CODES.SIG_INVALID:
return alertOps("server signing key drift");
case CHUNKS_CODES.MANIFEST_NOT_FOUND:
return rebuildManifest(err);
default:
throw new ChunksError(
CHUNKS_CODES.INGEST_FAILED,
"wrapped ingest failure",
{ cause: err }
);
}
}
throw err;
}
```
## API Reference
- `CHUNKS_CODES` — `Readonly<Record<string, string>>` of `"CHUNKS.<NAME>"` strings.
- `ChunksErrorCode` — string-literal union of every value in `CHUNKS_CODES`.
- `ChunksError` — `class ChunksError extends SuaveplanError<ChunksErrorCode>` with constructor `(code: ChunksErrorCode, message: string, options?: SuaveplanErrorOptions)`. Inherits `name`, `code`, `message`, `cause`, and the structured-redaction surface from `SuaveplanError`.
## Implementation Notes
The twelve codes are deliberately narrow: each maps to one failure semantic (tag verification, signature verification, payload length mismatch, inline-hint signature failure, etc.) so monitoring dashboards can attach SLOs per code without redacting cardinality. `INLINE_SIG_INVALID` was added in DTP-25 to signal that an `InlineHint` failed Ed25519 verification against the drive's signing key over `fileId ‖ bytes`; legacy `LegacyInlineHint` shapes (no `sig` field) do not throw — they emit a `drive:inline:legacy` warning and trigger a rewrite-with-sig on next write.

View File

@@ -0,0 +1,81 @@
# inline-hint
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements DTP-25 (drive-plugin-chunks-inline-sig). Provides the signed `InlineHint` shape used by `FileLocation.storageHints` entries with `kind: 'inline'`, the Ed25519 signing helper that produces them, the verifier that the drive runs before returning inline bytes, and the legacy-detection / upgrade helpers that let pre-DTP-25 hints (no `sig` field) round-trip safely. The signing input is the canonical concatenation `fileId ‖ bytes`, which binds the signature to one file so a replay onto a different fileId would fail verification.
## Features
- `InlineHint` — `{ kind: 'inline'; bytes: Uint8Array; sig: Uint8Array }` for the DTP-25 wire format.
- `LegacyInlineHint` — `{ kind: 'inline'; bytes: Uint8Array }` for backward-compat reads.
- `signInlineHint` — Ed25519-sign over `fileId ‖ bytes` under the drive's secret key.
- `verifyInlineHint` — verify and return bytes; throws `CHUNKS.INLINE_SIG_INVALID` on failure (catch arm covers verifier exceptions too).
- `isLegacyInlineHint` — type-guard distinguishing legacy from signed shapes.
- `upgradeLegacyInlineHint` — sign a legacy hint with the drive's key so the next write rewrites it as `InlineHint`.
- `inlineSigningInput` — exposes the canonical concatenation so other layers can re-derive the bytes that are signed.
- Cause-chain preservation: verifier exceptions are wrapped as `ChunksError` with the original error attached as `cause`.
## Usage: Basic Example
```ts
import {
signInlineHint,
verifyInlineHint,
} from "@suaveplan/drive-plugin-chunks";
import { sha256 } from "@suaveplan/crypto-hash";
const fileId = sha256(plaintext);
const hint = signInlineHint(fileId, plaintext, driveSecretKey);
// later, on read:
const bytes = verifyInlineHint(hint, fileId, drivePublicKey);
console.log(bytes.byteLength);
```
## Usage: Advanced Example
```ts
import {
isLegacyInlineHint,
upgradeLegacyInlineHint,
verifyInlineHint,
type AnyInlineHint,
} from "@suaveplan/drive-plugin-chunks";
function readInline(
hint: AnyInlineHint,
fileId: Uint8Array,
driveSecretKey: Uint8Array,
drivePublicKey: Uint8Array,
onLegacyWarning: () => void
): Uint8Array {
if (isLegacyInlineHint(hint)) {
onLegacyWarning(); // emit drive:inline:legacy
const upgraded = upgradeLegacyInlineHint(
hint,
fileId,
driveSecretKey
);
// Persist `upgraded` on the next write; for the current read we
// can return the bytes directly because legacy hints predate the
// sig requirement.
return upgraded.bytes;
}
return verifyInlineHint(hint, fileId, drivePublicKey);
}
```
## API Reference
- `signInlineHint(fileId: Uint8Array, bytes: Uint8Array, driveSecretKey: Uint8Array): InlineHint` — Ed25519-sign `fileId ‖ bytes` and return the signed hint.
- `verifyInlineHint(hint: InlineHint, fileId: Uint8Array, drivePublicKey: Uint8Array): Uint8Array` — verify and return `hint.bytes`. Throws `ChunksError(CHUNKS.INLINE_SIG_INVALID, ...)` if the signature does not verify or the verifier raises.
- `isLegacyInlineHint(hint: AnyInlineHint): hint is LegacyInlineHint` — true when the hint omits `sig`.
- `upgradeLegacyInlineHint(legacy: LegacyInlineHint, fileId: Uint8Array, driveSecretKey: Uint8Array): InlineHint` — sign the legacy bytes under the drive's key.
- `inlineSigningInput(fileId: Uint8Array, bytes: Uint8Array): Uint8Array` — canonical signing input.
- `InlineHint`, `LegacyInlineHint`, `AnyInlineHint`, `StorageHint`, `FileLocation` — wire types.
## Implementation Notes
The verifier uses `@noble/curves` Ed25519 via `@suaveplan/crypto-curve/ed25519`. `verify()` is implemented to never throw on malformed inputs in the noble path, but the catch arm exists for engine-substitution scenarios where a custom verifier may throw — the `injectable verifyFn` pattern is intentionally not surfaced here because the chunks plugin always uses noble. The `CHUNKS.INLINE_SIG_INVALID` code is registered through `defineCodes("CHUNKS", ...)` in `error-codes.ts`. Domain separation is provided by including `fileId` in the signing input; this is sufficient because every inline-hint context already has a unique fileId, so the signature cannot be replayed across files even if an attacker controls one inline body.

View File

@@ -0,0 +1,46 @@
# auto
## Purpose
Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capabilities.partialWrites`, falls back to `chunk-keys` otherwise. This is the default `layout` value used by `chunksPlugin` when no explicit strategy is passed in.
## Features
- `createAutoLayout(storage): ChunkLayout` — single-call dispatcher.
- Capability-driven: reads `storage.capabilities.partialWrites` exactly once.
- Stable per storage: the returned `ChunkLayout` keeps the same `name` for the lifetime of the storage instance.
- No additional dependencies — composes the two concrete layouts.
## Usage: Basic Example
```ts
import { createAutoLayout } from "@suaveplan/drive-plugin-chunks";
const layout = createAutoLayout(storage);
console.log(layout.name); // "sparse-file" or "chunk-keys"
```
## Usage: Advanced Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
const drive = new Drive(storage);
await drive.initialize();
// `auto` is the default; equivalent to omitting `layout`
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey, serverPubKey, layout: "auto" })
);
console.log(chunks.layout); // resolved by createAutoLayout(storage)
```
## API Reference
- `createAutoLayout(storage: DriveStorage): ChunkLayout` — returns either a `chunk-keys` or `sparse-file` `ChunkLayout` based on `storage.capabilities`.
## Implementation Notes
Capability inspection is intentionally narrow — only `partialWrites` matters here because the sparse-file layout is the only one that needs offset-addressable writes. Storages that advertise other capabilities (`atomicWrites`, `contentAddressable`) but not `partialWrites` will receive the `chunk-keys` layout. There is no probe for write feasibility — the auto layer trusts the capabilities object; storages with mis-declared capabilities will fail at the first `setPartial` call rather than at layout-creation time.
The dispatcher returns a fully-constructed `ChunkLayout` rather than a thin proxy so subsequent calls into `putChunk` / `getChunk` go straight to the concrete implementation with no per-call branch. This matters when a single ingest produces tens of thousands of chunks: the resolved layout is captured once in the plugin's closure and referenced by stable identity for the lifetime of the plugin instance. Switching layouts mid-flight is intentionally unsupported — the auto resolver runs only at plugin construction.

View File

@@ -0,0 +1,54 @@
# chunk-keys
## Purpose
Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<chunkId>`). This is the simplest layout — every chunk is a separate `set/get` operation, no preallocation, no offset arithmetic — and it works on any `DriveStorage` whose `set` and `list` methods are implemented.
## Features
- `createChunkKeysLayout(storage): ChunkLayout` — factory returning a `ChunkLayout` with `name: "chunk-keys"`.
- `putChunk(fileId, chunkId, rawBytes)` — single `storage.set` call.
- `getChunk(fileId, chunkId)` — single `storage.get` call returning `undefined` for missing chunks (no error).
- `hasChunks(fileId)` — returns a sparse `boolean[]` indexed by chunkId, derived from `storage.list({ prefix })`.
- `availableChunks(fileId)` — flattens `hasChunks` into the list of present indices.
## Usage: Basic Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
import { MemoryStorage } from "@suaveplan/storage/memory";
const storage = Object.assign(new MemoryStorage(), { id: "default" });
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey, serverPubKey, layout: "chunk-keys" })
);
```
## Usage: Advanced Example
```ts
import { createChunkKeysLayout } from "@suaveplan/drive-plugin-chunks";
const layout = createChunkKeysLayout(storage);
await layout.putChunk(fileId, 0, raw);
const present = await layout.hasChunks(fileId);
const available = await layout.availableChunks(fileId);
console.log(`have ${available.length}/${present.length}`);
```
## API Reference
- `createChunkKeysLayout(storage: DriveStorage): ChunkLayout`.
- `ChunkLayout.name === "chunk-keys"`.
- `putChunk(fileId, chunkId, rawBytes): Promise<void>`.
- `getChunk(fileId, chunkId): Promise<Uint8Array | undefined>`.
- `hasChunks(fileId): Promise<boolean[]>` — `bits.length === maxChunkId + 1`; uses `-1` sentinel when no chunks exist (yields empty array).
- `availableChunks(fileId): Promise<number[]>`.
## Implementation Notes
The `hex` helper formats `fileId` to lowercase hex with leading zeros, identical to the formatting in `plugin.ts` so stored chunks remain addressable across plugin and layout boundaries. `hasChunks` uses `storage.list({ prefix })` once and then computes the bit array client-side, which is O(n) over present chunks and avoids `n` storage round trips. Missing chunks correspond to `false` slots in the bit array; gaps are explicit, not implicit.

View File

@@ -0,0 +1,51 @@
# sparse-file
## Purpose
Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single sparse storage object. Chunks are written via `storage.setPartial(key, offset, bytes)` and read back via `storage.getPartial`. A side-band bitmap key records which chunks have been written so callers can answer `hasChunks` / `availableChunks` without paging the full file.
## Features
- `createSparseFileLayout(storage): ChunkLayout` — factory that throws `CHUNKS.LAYOUT_INCOMPATIBLE` if `storage.capabilities.partialWrites !== true`.
- `preallocate(storage, fileId, totalChunks, chunkSize, lastPayloadLen)` — sets the file size up-front via a single `storage.set` call so subsequent `setPartial` writes never extend the underlying object.
- Bitmap state tracked in `bitmap:<fileIdHex>` keys, packed eight bits per byte.
- Per-fileId `WeakMap` caches keep `chunkSize` and `totalChunks` reachable inside the layout's read path without forcing every call to consult the manifest.
## Usage: Basic Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
const drive = new Drive(sparseStorage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey, serverPubKey, layout: "sparse-file" })
);
```
## Usage: Advanced Example
```ts
import {
createSparseFileLayout,
preallocate,
} from "@suaveplan/drive-plugin-chunks";
const layout = createSparseFileLayout(storage);
await preallocate(storage, fileId, totalChunks, chunkSize, lastPayloadLen);
for (const c of encryptedChunks) {
await layout.putChunk(fileId, c.metadata.chunkId, c.rawBytes);
}
const present = await layout.hasChunks(fileId);
```
## API Reference
- `createSparseFileLayout(storage: DriveStorage): ChunkLayout` — name `"sparse-file"`.
- `preallocate(storage, fileId, totalChunks, chunkSize, lastPayloadLen): Promise<void>` — populates the WeakMap caches and zero-fills the file/bitmap keys.
- Layout methods mirror `chunk-keys`: `putChunk`, `getChunk`, `hasChunks`, `availableChunks`.
## Implementation Notes
The total file length is computed as `(totalChunks - 1) * chunkSize + HEADER_SIZE + lastPayloadLen`, matching the encrypt-stream output shape. The 164 in the inline arithmetic is `HEADER_SIZE` from `header.ts`. The bitmap is grown lazily inside `setBit` so the layout can survive a missing or truncated bitmap key (e.g. partial restore from backup) without losing chunks. `getChunk` returns `undefined` for missing files (`getItemSize === null`) and trusts `getPartial` for present-but-not-yet-populated regions.

View File

@@ -0,0 +1,48 @@
# build
## Purpose
Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize`, `totalChunks`), encryption envelope (`algo`, `fileKeyWrapped`, `baseNonce`), and per-chunk integrity hashes into a single record, then signs the canonical encoding under the server private key. The manifest is the consensus artefact: peers that trust the server public key can verify it without holding any chunk bytes.
## Features
- `buildManifest(options): Manifest` — pure function, no I/O, no global state.
- `canonicalSerialize(manifest): Uint8Array` — deterministic big-endian length-prefixed encoding suitable for cross-platform Ed25519 signing.
- `algo` is encoded as a length-prefixed UTF-8 string so future encryption algorithms can be added without breaking the wire format.
- Per-chunk entries pack `(chunkId: u32, hash: 32 bytes, payloadLen: u32)` into 40 bytes — bounded growth, predictable memory.
## Usage: Basic Example
```ts
import { buildManifest } from "@suaveplan/drive-plugin-chunks";
const manifest = buildManifest({
fileId: sha256Bytes,
fileSize: 12n,
chunkSize: 1024,
totalChunks: 1,
encryption: { algo: "xchacha20poly1305", fileKeyWrapped: fileKey, baseNonce },
chunks: [{ chunkId: 0, hash: chunkHash, payloadLen: 12 }],
serverPrivKey,
});
```
## Usage: Advanced Example
```ts
import { buildManifest, canonicalSerialize } from "@suaveplan/drive-plugin-chunks";
import { verify } from "@suaveplan/crypto-curve/ed25519";
const manifest = buildManifest({ /* ... */ });
const { sig, ...base } = manifest;
const ok = verify(sig, canonicalSerialize(base), serverPubKey);
```
## API Reference
- `buildManifest(options): Manifest` — returns `{ version, fileId, fileSize, chunkSize, totalChunks, encryption, chunks, sig }`.
- `canonicalSerialize(manifest: Omit<Manifest, "sig">): Uint8Array` — used both for signing and for verification.
## Implementation Notes
Field order in `canonicalSerialize` is fixed and big-endian; changing it would break every previously-stored manifest. The `algo` length prefix makes the encoded form self-delimiting so receivers don't need an explicit version bump to add new algorithms — they only need to add a parsing branch keyed on the algo string. The final `buf.slice(0, off)` trims any over-allocated tail, but `totalLen` is computed exactly so this is normally a no-op.

View File

@@ -0,0 +1,54 @@
# load
## Purpose
Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`manifestSource: "manifest-as-chunk"`) and a separate manifest server keyed by file id (`manifestSource: "server-only"`). Every code path runs the same `parseAndVerifyManifest` so signature verification can never be skipped.
## Features
- `loadManifestAsChunk(fileId, getChunk, serverPubKey)` — reads chunkId `-1` from the chunk source.
- `loadManifestFromServer(fileId, url, fetchFn, serverPubKey)` — issues a `fetch(url + "/manifest/<idHex>")` GET; injectable `fetchFn` for testing.
- `parseAndVerifyManifest(raw, serverPubKey)` — JSON-decodes (with `base64:` Uint8Array unwrapping), validates against `ManifestSchema`, and runs Ed25519 verification with the canonical encoder.
- `MANIFEST_NOT_FOUND` and `MANIFEST_INVALID` are the only errors that can come out of this module — every internal failure is mapped onto one of those two codes.
## Usage: Basic Example
```ts
import { loadManifestAsChunk } from "@suaveplan/drive-plugin-chunks";
const manifest = await loadManifestAsChunk(
fileId,
(id, idx) => chunks.getChunk(id, idx),
serverPubKey
);
```
## Usage: Advanced Example
```ts
import {
loadManifestFromServer,
parseAndVerifyManifest,
} from "@suaveplan/drive-plugin-chunks";
const manifest = await loadManifestFromServer(
fileId,
"https://manifests.example.com",
fetch,
serverPubKey
);
// Verifying a manifest received over a side-channel
const verified = parseAndVerifyManifest(receivedBytes, serverPubKey);
```
## API Reference
- `loadManifestAsChunk(fileId, getChunk, serverPubKey): Promise<Manifest>`.
- `loadManifestFromServer(fileId, url, fetchFn, serverPubKey): Promise<Manifest>`.
- `parseAndVerifyManifest(raw: Uint8Array, serverPubKey: Uint8Array): Manifest`.
- All three throw `ChunksError(CHUNKS.MANIFEST_NOT_FOUND)` (missing input) or `ChunksError(CHUNKS.MANIFEST_INVALID)` (schema or signature failure).
## Implementation Notes
`parseAndVerifyManifest` first tries to parse as UTF-8 JSON because manifests are persisted as JSON inside `plugin.ingest`; the catch falls back to treating the raw bytes as the parsed value, which lets future binary manifest formats slot in without changing the call site. Signature verification rebuilds `withoutSig` with a 64-byte zero placeholder and asks the canonical encoder to lay out the bytes; this matches the layout used at sign time so the verification is over identical bytes.

View File

@@ -0,0 +1,44 @@
# source
## Purpose
Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small but load-bearing: it short-circuits the `"server-only"` branch when no `manifestServerUrl` is supplied so the rest of the plugin can assume the source is well-formed by the time it runs.
## Features
- `resolveManifestSource(source, manifestServerUrl?): { source, url }` — returns the normalised configuration tuple.
- Eager validation — throws synchronously instead of failing inside the read pipeline.
- Pure function — no I/O, no global state, deterministic for fixed inputs.
- Unaware of `getChunk` / `fetch` — those concerns live in `load.ts`.
## Usage: Basic Example
```ts
import { resolveManifestSource } from "@suaveplan/drive-plugin-chunks";
const cfg = resolveManifestSource("manifest-as-chunk");
```
## Usage: Advanced Example
```ts
import { resolveManifestSource } from "@suaveplan/drive-plugin-chunks";
const cfg = resolveManifestSource("server-only", "https://manifests.example.com");
console.log(cfg.url); // "https://manifests.example.com"
try {
resolveManifestSource("server-only");
} catch (err) {
console.error("missing manifestServerUrl");
}
```
## API Reference
- `resolveManifestSource(source: ManifestSource, manifestServerUrl?: string): { source: ManifestSource; url: string | undefined }`.
- Throws a plain `Error` (not `ChunksError`) when `source === "server-only"` and `manifestServerUrl` is missing, because this is a programmer error in plugin construction, not a runtime fault that consumers should branch on.
## Implementation Notes
The two valid `ManifestSource` values are intentionally narrow: `"manifest-as-chunk"` reuses the chunk-storage substrate as the manifest store, while `"server-only"` keeps manifests out-of-band on a separate HTTPS endpoint. Future sources (e.g. a libp2p PubSub channel) would slot in by extending the union type and the dispatcher in `load.ts`. The plain `Error` is surfaced as soon as the plugin object is constructed so misconfigurations show up at boot, not during the first `read`.

View File

@@ -0,0 +1,57 @@
# decrypt-chunk
## Purpose
The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via `header.parseHeader`), the chunk ciphertext, and the file key, it concatenates the ciphertext with the embedded AEAD tag and asks `@suaveplan/crypto-cipher/xchacha20-poly1305` to verify-and-decrypt in one call.
## Features
- `decryptChunk(metadata, ciphertext, fileKey): Uint8Array` — returns plaintext bytes; the caller is expected to truncate to `metadata.payloadLen`.
- Tag-failure path is structured: a `decrypt` failure is wrapped as `ChunksError(CHUNKS.AEAD_TAG_INVALID)` regardless of whether the underlying error was a tag mismatch, an invalid nonce length, or an internal `@noble/ciphers` rejection.
- Allocation-light — only one fresh buffer (`fullCiphertext`) is allocated per call.
## Usage: Basic Example
```ts
import { decryptChunk, parseChunk } from "@suaveplan/drive-plugin-chunks";
const chunk = parseChunk(rawChunk);
const plain = decryptChunk(chunk.metadata, chunk.ciphertext, fileKey);
const trimmed = plain.slice(0, chunk.metadata.payloadLen);
```
## Usage: Advanced Example
```ts
import {
CHUNKS_CODES,
ChunksError,
decryptChunk,
parseChunk,
} from "@suaveplan/drive-plugin-chunks";
async function reassembleSafely(rawChunks: Uint8Array[], fileKey: Uint8Array) {
const out: Uint8Array[] = [];
for (const raw of rawChunks) {
const c = parseChunk(raw);
try {
const plain = decryptChunk(c.metadata, c.ciphertext, fileKey);
out.push(plain.slice(0, c.metadata.payloadLen));
} catch (err) {
if (err instanceof ChunksError && err.code === CHUNKS_CODES.AEAD_TAG_INVALID) {
return null; // request a re-fetch from a different peer
}
throw err;
}
}
return out;
}
```
## API Reference
- `decryptChunk(metadata: ChunkMetadata, ciphertext: Uint8Array, fileKey: Uint8Array): Uint8Array` — concatenates `ciphertext || metadata.aeadTag` and runs XChaCha20-Poly1305 decrypt under `fileKey` and `metadata.nonce`. Throws `ChunksError(CHUNKS.AEAD_TAG_INVALID)` on any verification failure.
## Implementation Notes
The function does NOT trim the output to `payloadLen` — that's the caller's job. This avoids a redundant `.slice` allocation in transport code that already writes plaintext into a preallocated reassembly buffer. The error message is intentionally generic (`"chunk AEAD tag verification failed"`) so consumers cannot use the message text to distinguish between attacker-induced tag mismatches and accidental ciphertext corruption — both are fatal.

View File

@@ -0,0 +1,54 @@
# encrypt-stream
## Purpose
The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence. It owns the chunk-level nonce derivation, the AEAD ciphertext + tag split, the per-chunk metadata population, and the immediate signing pass. Together these steps form the deterministic transform from `{ plaintext, fileId, fileKey, baseNonce, chunkSize, serverPrivKey }` to a stream of `EncodedChunk` objects.
## Features
- `encryptChunks(options): Generator<EncodedChunk>` — yields one chunk per `chunkSize - HEADER_SIZE` payload window, signed and ready to persist.
- `deriveNonce(baseNonce, chunkId): Uint8Array` — XORs the chunk index into the high-order bytes of the 24-byte XChaCha20 nonce; rejects non-24-byte inputs with `CHUNKS.METADATA_INVALID`.
- `TAG_LENGTH` constant (16) for the AEAD tag size.
- Synchronous generator — no async overhead, allocations bounded per chunk.
## Usage: Basic Example
```ts
import { randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
import { encryptChunks } from "@suaveplan/drive-plugin-chunks";
const chunks = Array.from(
encryptChunks({
plaintext: new TextEncoder().encode("hello"),
fileId: new Uint8Array(32),
fileKey: new Uint8Array(32).fill(7),
baseNonce: new Uint8Array(24).fill(11),
chunkSize: 1024,
serverPrivKey: randomPrivateKey(),
})
);
```
## Usage: Advanced Example
```ts
import { deriveNonce, encryptChunks, TAG_LENGTH } from "@suaveplan/drive-plugin-chunks";
for (const chunk of encryptChunks({ plaintext, fileId, fileKey, baseNonce, chunkSize: 4096, serverPrivKey })) {
await storage.set(`chunks:${idHex}:${chunk.metadata.chunkId}`, chunk.rawBytes);
}
// Reconstruct the per-chunk nonce on the receiver
const nonce = deriveNonce(baseNonce, chunkIdReceived);
console.log(TAG_LENGTH); // 16
```
## API Reference
- `encryptChunks(options: EncryptOptions): Generator<EncodedChunk>` — `EncryptOptions` carries `plaintext`, `fileId`, `fileKey`, `baseNonce` (24 bytes), `chunkSize`, `serverPrivKey`. Yields `{ rawBytes, metadata }` per chunk.
- `deriveNonce(baseNonce: Uint8Array, chunkId: number): Uint8Array` — XORs `chunkId` into bytes 0–3.
- `TAG_LENGTH: 16` — AEAD tag size.
## Implementation Notes
`encryptChunks` writes the signature into the raw buffer at `[METADATA_SIZE, HEADER_SIZE)` immediately after computing it, so consumers can stream chunks through transports without ever holding a full file plaintext in memory. The XOR-based nonce derivation is sound for chunk indices ≤ 2³² because XChaCha20-Poly1305 takes a 24-byte nonce: under fixed `baseNonce` no two chunks of the same file ever share a nonce.

View File

@@ -0,0 +1,50 @@
# sign-chunk
## Purpose
Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes plus the encrypted payload) so receivers can verify integrity without first decrypting. The output is fixed at 64 bytes, which is the slot reserved by `header.ts` (`SIG_SIZE = 64`).
## Features
- `signChunk(chunk, privKey): Uint8Array` — produces a deterministic Ed25519 signature over `metadata || ciphertext`.
- `buildSignedChunk(chunk, privKey): Chunk` — convenience wrapper that returns a chunk with the freshly-computed signature inlined.
- Pure function — no global state, no side effects, deterministic for fixed inputs (Ed25519 with seeded keys).
## Usage: Basic Example
```ts
import { randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
import { signChunk } from "@suaveplan/drive-plugin-chunks";
const privKey = randomPrivateKey();
const chunk = {
metadata: { /* ChunkMetadata */ } as never,
sig: new Uint8Array(64),
ciphertext: new Uint8Array([1, 2, 3]),
};
const sig = signChunk(chunk, privKey);
```
## Usage: Advanced Example
```ts
import {
buildSignedChunk,
parseChunk,
verifyChunkSig,
} from "@suaveplan/drive-plugin-chunks";
const signed = buildSignedChunk(parseChunk(rawChunk), serverPriv);
verifyChunkSig(/* raw bytes containing signed.sig */, serverPub);
```
## API Reference
- `signChunk(chunk: Chunk, privKey: Uint8Array): Uint8Array` — returns 64-byte signature. Concatenates `encodeHeader(metadata)` and `chunk.ciphertext` then signs the concatenation.
- `buildSignedChunk(chunk: Chunk, privKey: Uint8Array): Chunk` — same chunk with a populated `sig` field.
## Implementation Notes
The signature input deliberately excludes the existing `chunk.sig` field — re-signing a chunk produces the same signature for fixed inputs even though Ed25519 is not strictly deterministic across implementations (suaveplan pins to `@noble/curves` which is RFC-8032 deterministic). Verification is implemented in `verify-chunk.ts` using the matching `getPublicKey` derivation.
The signed payload is `encodeHeader(metadata) || ciphertext` rather than the full raw chunk because the signature occupies a fixed slot inside the raw bytes; including it in the signing input would be circular. This split also lets transports verify a chunk in two passes: parse the metadata to learn `payloadLen`, then sign-verify a contiguous range of the buffer without copying. Signing happens at ingest time and is the only place the `serverPrivKey` ever leaves the plugin's option object — verification only needs the public key.

View File

@@ -0,0 +1,52 @@
# verify-chunk
## Purpose
Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk — before persistence, before decryption, before reassembly. A mismatched or corrupted signature surfaces as `ChunksError(CHUNKS.SIG_INVALID)` so callers can drop bad chunks without ever exposing them to the rest of the pipeline.
## Features
- `verifyChunkSig(raw, pubKey): void` — reads the embedded signature out of the raw bytes, recomputes the signed payload, and verifies in constant-time via `@noble/curves/ed25519`.
- Single-pass — runs over the original raw buffer without re-encoding.
- Throws on failure rather than returning `false`, matching the rest of the pipeline's error contract.
## Usage: Basic Example
```ts
import { verifyChunkSig } from "@suaveplan/drive-plugin-chunks";
verifyChunkSig(rawChunk, serverPubKey);
// throws ChunksError(CHUNKS.SIG_INVALID) on mismatch
```
## Usage: Advanced Example
```ts
import {
CHUNKS_CODES,
ChunksError,
parseChunk,
verifyChunkSig,
} from "@suaveplan/drive-plugin-chunks";
async function ingestPeerChunk(raw: Uint8Array) {
try {
verifyChunkSig(raw, expectedServerPub);
} catch (err) {
if (err instanceof ChunksError && err.code === CHUNKS_CODES.SIG_INVALID) {
return reportPeerCorruption(raw);
}
throw err;
}
const parsed = parseChunk(raw);
return persist(parsed);
}
```
## API Reference
- `verifyChunkSig(raw: Uint8Array, pubKey: Uint8Array): void` — validates that `raw` carries a correct signature for its embedded metadata + ciphertext under `pubKey`. Throws `ChunksError(CHUNKS.SIG_INVALID)` on any failure (including under-length buffers, since `parseHeader` runs first).
## Implementation Notes
The function rebuilds the signed payload as `metadata || ciphertext` rather than treating the raw bytes as already-signed because the signature occupies a fixed offset between metadata and ciphertext — re-encoding metadata after parsing keeps verification independent of any future header padding changes. suaveplan pins to `@noble/curves` for both signing and verification, which is constant-time and audited.

View File

@@ -0,0 +1,63 @@
# plugin
## Purpose
Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into signed encrypted chunks, persists them through a `DriveStorage`, and reassembles them on read. The plugin is the orchestration layer that wires together encryption, signing, manifest construction, layout selection, and the storage middleware that intercepts `drive.read` calls so consumers see whole files even though the bytes live in chunks.
## Features
- `chunksPlugin(options)` factory returning a `DrivePlugin<ChunksApi>` ready to be passed to `drive.usePlugin(...)`.
- `ingest(path)` reads a plaintext file from the drive, deterministically derives the file id (`sha256(plaintext)`), encrypts with XChaCha20-Poly1305 under a random per-file key and base nonce, signs each chunk with `serverPrivKey`, stores chunks via the chosen layout, and persists a server-signed manifest.
- Read middleware that, for any path with a known file id, reassembles plaintext on demand and short-circuits the storage read with the original bytes.
- Layout-aware behaviour: `sparse-file` preallocates a single offset-addressable file via `setPartial`; `chunk-keys` writes per-chunk keys; `auto` selects per storage capabilities.
- `getChunk(fileId, chunkId)`, `loadManifest(fileId)`, and `availableChunks(fileId)` for transport peers.
- Structured error codes for every failure path (signature, AEAD tag, manifest, layout, ingest).
## Usage: Basic Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
import { MemoryStorage } from "@suaveplan/storage/memory";
import { getPublicKey, randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
const privKey = randomPrivateKey();
const drive = new Drive(Object.assign(new MemoryStorage(), { id: "default" }));
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey: privKey, serverPubKey: getPublicKey(privKey) })
);
await drive.write("hello.txt", new TextEncoder().encode("world"));
const fileId = await chunks.ingest("hello.txt");
const back = await drive.read("hello.txt"); // reassembled plaintext
```
## Usage: Advanced Example
```ts
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey,
serverPubKey,
chunkSize: 64 * 1024,
layout: "sparse-file",
manifestSource: "manifest-as-chunk",
})
);
const fileId = await chunks.ingest("big.bin");
const raw = await chunks.getChunk(fileId, 0);
const manifest = await chunks.loadManifest(fileId);
```
## API Reference
- `chunksPlugin(options: ChunksPluginOptions): DrivePlugin<ChunksApi>` — factory.
- `ChunksApi.ingest(path: string): Promise<Uint8Array>` — returns `fileId`.
- `ChunksApi.getChunk(fileId: Uint8Array, chunkId: number): Promise<Uint8Array | undefined>`.
- `ChunksApi.loadManifest(fileId: Uint8Array): Promise<Manifest>`.
- `ChunksApi.availableChunks(fileId: Uint8Array): Promise<number[]>`.
- `ChunksApi.layout: ChunkLayoutStrategy`.
## Implementation Notes
`ingest` collects all encoded chunks into an array before persistence so the sparse-file layout can preallocate the underlying file before the first `setPartial` call — without preallocation, sparse-file storages refuse out-of-order writes. The catch block in `ingest` re-throws `ChunksError` instances unchanged and wraps everything else (`Error` or arbitrary throwable) as `CHUNKS.INGEST_FAILED` with a synthesised message; this keeps the public failure surface narrow without losing the underlying cause.

View File

@@ -0,0 +1,61 @@
# manifest.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod schemas that gate every manifest the plugin parses from untrusted bytes. Schema validation is the line between "raw bytes that may or may not be a manifest" and "a typed `Manifest` ready to flow through the rest of the plugin"; it runs before signature verification so structural errors surface as `MANIFEST_INVALID` without paying the cost of an Ed25519 verify.
## Features
- `ManifestSchema` — the top-level schema for a complete signed manifest, including a 64-byte signature, version literal `0x0001`, 32-byte `fileId`, positive `chunkSize` / `totalChunks`, and an array of per-chunk entries.
- `ManifestEncryptionSchema` — the embedded encryption envelope: `algo` is locked to `"xchacha20poly1305"`, `baseNonce` is exactly 24 bytes, and `fileKeyWrapped` is left length-flexible to leave room for future KEMs.
- `ManifestChunkEntrySchema` — per-chunk entries with non-negative `chunkId` / `payloadLen` and a 32-byte SHA-256 `hash`.
- `fileSize` accepts both `bigint` and decimal-string inputs and normalises to `bigint`, which makes JSON-serialised manifests round-trip cleanly even though native JSON has no `bigint` type.
## Usage: Basic Example
```ts
import { ManifestSchema } from "@suaveplan/drive-plugin-chunks";
const result = ManifestSchema.safeParse(decodedFromJson);
if (!result.success) throw new Error(result.error.message);
const manifest = result.data;
```
## Usage: Advanced Example
```ts
import {
ManifestChunkEntrySchema,
ManifestEncryptionSchema,
ManifestSchema,
} from "@suaveplan/drive-plugin-chunks";
function isLooselyValid(maybe: unknown): boolean {
return ManifestSchema.safeParse(maybe).success;
}
const enc = ManifestEncryptionSchema.parse({
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
});
const entry = ManifestChunkEntrySchema.parse({
chunkId: 0,
hash: new Uint8Array(32),
payloadLen: 50,
});
```
## API Reference
- `ManifestSchema: ZodTypeAny` — top-level boundary schema.
- `ManifestEncryptionSchema: ZodTypeAny` — encryption sub-schema.
- `ManifestChunkEntrySchema: ZodTypeAny` — chunk-entry sub-schema.
- `type ManifestInput = z.input<typeof ManifestSchema>` — the pre-transform JSON shape.
## Implementation Notes
The `// schema: boundary-only` annotation tells the conformance script that these schemas are deliberately part of the public surface even when this file is the only place inside `src/` that calls `.parse` / `.safeParse` on them. Removing the annotation (or moving the schemas) would be an enterprise-grade signal change so consumers should not depend on internal validation paths.

View File

@@ -0,0 +1,60 @@
# chunks.types
## Purpose
Holds the cross-module type vocabulary for the chunks plugin: chunk metadata and chunk shapes, manifest entries and the manifest itself, the `ChunkLayout` interface that pluggable storage layouts implement, the `ChunkSource` interface that mesh / transport peers expose, and the plugin options object. Centralising the types here avoids `any` leaks across module boundaries and lets the rest of the codebase share a single language for chunk-shaped data.
## Features
- `ChunkMetadata` — every field of the on-wire metadata header in a single TypeScript shape.
- `Chunk` / `EncodedChunk` — parsed chunk with separate `sig` and `ciphertext` fields, plus the encoded bytes ready for storage.
- `Manifest` / `ManifestChunkEntry` / `ManifestEncryption` — manifest tree mirroring `ManifestSchema`.
- `ChunkLayout` — minimal interface: `name`, `putChunk`, `getChunk`, `hasChunks`, `availableChunks`.
- `ChunkSource` — peer-facing interface (`hasChunks`, `availableChunks`).
- `ChunksPluginOptions` — frozen options object with required `serverPrivKey` / `serverPubKey` and optional `chunkSize` / `layout` / `manifestSource` / `manifestServerUrl`.
- `ChunkLayoutStrategy = "sparse-file" | "chunk-keys" | "auto"`.
- `ManifestSource = "manifest-as-chunk" | "server-only"`.
## Usage: Basic Example
```ts
import type {
ChunkLayout,
ChunkMetadata,
Manifest,
} from "@suaveplan/drive-plugin-chunks";
declare const layout: ChunkLayout;
declare const metadata: ChunkMetadata;
declare const manifest: Manifest;
```
## Usage: Advanced Example
```ts
import type {
ChunksPluginOptions,
ChunkSource,
} from "@suaveplan/drive-plugin-chunks";
function buildOptions(serverPrivKey: Uint8Array, serverPubKey: Uint8Array): ChunksPluginOptions {
return { serverPrivKey, serverPubKey, chunkSize: 65536, layout: "auto" };
}
function describeAvailability(src: ChunkSource, fileId: Uint8Array) {
return src.availableChunks(fileId);
}
```
## API Reference
- `ChunkMetadata` — `{ version, fileId, chunkId, totalChunks, fileSize, chunkSize, payloadLen, flags, nonce, aeadTag }`.
- `Chunk` — `{ metadata, sig, ciphertext }`.
- `EncodedChunk` — `{ rawBytes, metadata }`.
- `Manifest`, `ManifestChunkEntry`, `ManifestEncryption` — see schemas.
- `ChunkLayout`, `ChunkSource`, `ChunksPluginOptions` — interfaces consumed by the plugin and its layouts.
- `ChunkLayoutStrategy`, `ManifestSource` — string-literal unions.
## Implementation Notes
`ChunkMetadata.fileSize` is `bigint` because chunk arithmetic on files larger than 2³² bytes requires 64-bit math; consumers serialising to JSON must convert with `String(value)` and re-parse via `BigInt` (the `ManifestSchema` accepts both forms). Every `Uint8Array` field is treated as opaque bytes — none of the types pin a specific endianness because that responsibility belongs to `header.encodeHeader` / `parseHeader`.

View File

@@ -0,0 +1,127 @@
# @suaveplan/drive-react-sharing - React UI for @suaveplan/drive-sharing: ShareDialog, PartitionBadge, FileAccessList components and useSharing hook wired to the real sharing engine.
## Summary
React-tier UI surface for `@suaveplan/drive-sharing`. Three controlled
components plus a real-engine-backed hook.
<!-- begin:auto-docs -->
## Documentation tree
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
<!-- end:auto-docs --> - [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_ - [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
## Install
```bash
bun add @suaveplan/drive-react-sharing @suaveplan/drive-sharing
```
Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`.
## Quick start
```tsx
import {
GrantManager,
InMemoryPartitionStore,
KeyRing,
PartitionManager,
TokenManager,
userId,
} from "@suaveplan/drive-sharing";
import {
ShareDialog,
useSharing,
} from "@suaveplan/drive-react-sharing";
import { useState } from "react";
const partitions = new PartitionManager({
store: new InMemoryPartitionStore(),
keyRing: new KeyRing(),
});
const grants = new GrantManager();
const tokens = new TokenManager();
function Trigger({ partitionId }: { partitionId: string }) {
const sharing = useSharing({
partitionId,
grantorId: userId("me"),
partitions,
grants,
tokens,
});
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Share</button>
<ShareDialog
open={open}
recipients={sharing.recipients}
loading={sharing.loading}
errorMessage={sharing.error?.message}
onAdd={(id, perm) => sharing.addRecipient(id, perm)}
onRemove={(id) => sharing.removeRecipient(id)}
onClose={() => setOpen(false)}
/>
</>
);
}
```
## Components
| Component | Purpose | Docs |
|---|---|---|
| `<ShareDialog />` | Modal share UI with add form + revoke per row | `src/components/share-dialog/share-dialog.md` |
| `<PartitionBadge />` | Inline badge: classification + encryption indicator | `src/components/partition-badge/partition-badge.md` |
| `<FileAccessList />` | Read-only access list with optional revoke | `src/components/file-access-list/file-access-list.md` |
## Hook
| Hook | Purpose | Docs |
|---|---|---|
| `useSharing(options)` | Live-engine binding for recipient state + add/remove/update/refresh actions | `src/hooks/use-sharing/use-sharing.md` |
## Accessibility
- `<ShareDialog />` renders `role="dialog"` with `aria-modal="true"`
and `aria-labelledby` pointing at the title heading. Focus moves to
the recipient input on open; ESC and backdrop click both close.
- `<FileAccessList />` renders a semantic `<ul role="list">` with one
`<li role="listitem">` per recipient. Revoke is a native `<button>`
with `aria-label` including the recipient identifier. Delete and
Backspace fire revoke directly when the button is focused.
- `<PartitionBadge />` renders `role="status"` and an `aria-label`
describing the partition type plus encryption state. The padlock
SVG is marked `aria-hidden="true"`.
## OTel
`useSharing` emits three counters:
- `drive.react.sharing.recipients_added_total{permission}`
- `drive.react.sharing.recipients_removed_total`
- `drive.react.sharing.permissions_updated_total{permission}`
## Type consolidation
Main shipped three overlapping permission aliases
(`SharePermission` / `AccessPermission` / `SharingPermission`).
imports `SharePermission` directly from
`@suaveplan/drive-sharing` and re-exports it as the single canonical
name. Aliases were not ported.

View File

@@ -0,0 +1,72 @@
# `<FileAccessList />`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<FileAccessList />` renders a read-only list of share recipients with
an optional revoke action per row. It is the lightweight surface used
inside file detail panels and partition sidebars where the full
`<ShareDialog />` would be overkill. The component is purely
presentational — recipient data flows in via `props.recipients` and
mutations exit via the `onRevoke(grantId)` callback. There is no
internal state, no engine dependency, and no async lifecycle.
## Features
- Renders one `<li>` per recipient with `data-grant-id` and
`data-revoked` attributes for external styling and scripting.
- Permission label rendered from the `primaryPermission` projection
computed by `useSharing` (no recompute here).
- Optional `onRevoke` — when omitted, the revoke button is hidden and
the component is strictly read-only.
- Keyboard accessibility: revoke button is a native `<button>`, plus
Delete / Backspace shortcuts trigger revoke when the button is
focused.
- Empty state: when `recipients` is empty, renders a polite
`aria-live="polite"` status message rather than an empty list.
## Usage: Basic Example
```tsx
import { FileAccessList } from "@suaveplan/drive-react-sharing";
<FileAccessList recipients={sharing.recipients} />;
```
## Usage: Advanced Example
```tsx
function Panel() {
const sharing = useSharing(/* ... */);
return (
<FileAccessList
recipients={sharing.recipients}
onRevoke={(id) => sharing.removeRecipient(id)}
revokeLabel="Remove"
emptyLabel="No collaborators yet"
listLabel="Document collaborators"
/>
);
}
```
## API Reference
- `FileAccessListProps`:
- `recipients: readonly ShareRecipient[]` — required.
- `onRevoke?: (grantId: string) => void` — optional revoke callback.
- `revokeLabel?: string` — defaults to `"Revoke"`.
- `emptyLabel?: string` — defaults to `"No recipients"`.
- `listLabel?: string` — defaults to `"Recipients with access"`.
- `className?: string`, `style?: CSSProperties` — root overrides.
- `FileAccessList(props): ReactElement` — pure functional component.
## Implementation Notes
The component intentionally renders a `<button>` rather than a
right-click context menu so revoke is reachable via keyboard alone.
The Delete / Backspace shortcuts fire `onRevoke` directly without
requiring a confirmation step — consumers that need a confirmation
flow should compose their own modal in front of the callback.

View File

@@ -0,0 +1,69 @@
# `<PartitionBadge />`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PartitionBadge />` renders a compact label visualising a partition's
classification — `personal`, `shared`, `public`, or `encrypted` — plus
an optional padlock indicator when the partition is encrypted. It is a
pure presentational component with no engine dependencies, suitable for
list rows, sidebar headers, and breadcrumbs.
## Features
- Four partition classifications matching the engine's `PartitionType`
union.
- Automatic padlock SVG when `type === "encrypted"` or `encrypted` prop
is explicit-true.
- Inline-styled by default so the component drops into any tree without
a stylesheet. `className` / `style` props override.
- Accessibility: `role="status"`, `aria-label` mentioning the type and
encryption state, decorative SVG marked `aria-hidden="true"`.
## Usage: Basic Example
```tsx
import { PartitionBadge } from "@suaveplan/drive-react-sharing";
<PartitionBadge type="shared" />;
```
## Usage: Advanced Example
```tsx
<PartitionBadge
type="encrypted"
label="E2E"
className="my-badge"
style={{ marginInlineStart: 12 }}
/>
```
`encrypted` forces the lock icon even when the type is something other
than `encrypted` — useful for representing a shared partition whose
policy mandates encryption.
```tsx
<PartitionBadge type="shared" encrypted />
```
## API Reference
- `PartitionBadgeProps`:
- `type: PartitionType` — required, drives label + colour.
- `encrypted?: boolean` — optional, forces lock icon.
- `label?: string` — optional, overrides the default text.
- `className?: string` — applied to root `<span>`.
- `style?: CSSProperties` — merged onto default style.
- `PartitionBadge(props): ReactElement` — pure functional component.
## Implementation Notes
Colours are hard-coded HEX values with a 10% alpha background, chosen
to ensure 4.5:1 WCAG contrast against the colour foreground. Consumers
that require theme-aware colouring should pass an explicit `style`
override rather than monkey-patching the defaults. The `data-partition-type`
attribute is set so external stylesheets can target each variant
without depending on the inline `style` prop.

View File

@@ -0,0 +1,108 @@
# `<ShareDialog />`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<ShareDialog />` renders the modal sharing UI for a partition. It
exposes a recipients list, an add-recipient form (text input plus
permission select), and a revoke button per row. The component is
controlled — `open`, `recipients`, `loading`, and `errorMessage` are
all owned by the parent. Wire it to `useSharing` for live engine
integration or hand it static props in Storybook.
## Features
- Controlled `open` prop — renders `null` when closed so callers can
conditionally mount.
- Focus moves to the recipient input on open.
- ESC key closes the dialog. Backdrop click also closes; clicks
inside the dialog do NOT close (stopPropagation).
- ARIA: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` tied to
the title heading. Error region uses `role="alert"`.
- Recipient list reuses the same `data-grant-id` row contract as
`<FileAccessList />`.
- Localisable: pass `labels` to override every string. Permission
options localise individually.
## Usage: Basic Example
```tsx
import { ShareDialog } from "@suaveplan/drive-react-sharing";
import { useState } from "react";
function Trigger() {
const sharing = useSharing(/* ... */);
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Share</button>
<ShareDialog
open={open}
recipients={sharing.recipients}
onAdd={(id, p) => sharing.addRecipient(id, p)}
onRemove={(id) => sharing.removeRecipient(id)}
onClose={() => setOpen(false)}
/>
</>
);
}
```
## Usage: Advanced Example
Custom labels for a localised application:
```tsx
<ShareDialog
open={open}
recipients={sharing.recipients}
loading={sharing.loading}
errorMessage={sharing.error?.message}
defaultPermission="write"
onAdd={(id, p) => sharing.addRecipient(id, p)}
onRemove={(id) => sharing.removeRecipient(id)}
onClose={() => setOpen(false)}
labels={{
title: "Partager",
add: "Ajouter",
revoke: "Révoquer",
empty: "Aucun destinataire",
permissionOptions: {
read: "Lecture",
write: "Écriture",
delete: "Suppression",
share: "Partage",
admin: "Administrateur",
},
}}
/>;
```
## API Reference
- `ShareDialogProps`:
- `open: boolean` — required.
- `recipients: readonly ShareRecipient[]` — required.
- `onAdd(recipientId, permission): void | Promise<void>` — required.
- `onRemove(grantId): void | Promise<void>` — required.
- `onClose(): void` — required.
- `title?: string` — overrides `labels.title`.
- `loading?: boolean` — disables interactive controls.
- `errorMessage?: string` — renders `role="alert"` banner.
- `defaultPermission?: SharePermission` — initial select value.
- `labels?: Partial<ShareDialogLabels>` — string overrides.
- `ShareDialogLabels` — exported interface; permission options
override individually.
## Implementation Notes
The dialog is intentionally a controlled component because the open
state is usually tied to other UI in the parent (an avatar stack, a
"Share" button, a routing flag). Storybook can drive the entire surface
with mocked callbacks. The error region is rendered only when
`errorMessage` is a non-empty string — passing an undefined or
zero-length string hides it. The submit button is disabled when the
recipient draft is empty so users cannot accidentally fire `onAdd`
with whitespace.

View File

@@ -0,0 +1,84 @@
# `drive-react-sharing` error codes
## Purpose
`@suaveplan/drive-react-sharing` raises every failure as a
`DriveReactSharingError` — a `SuaveplanError` subclass with a stable code
registry. Codes are namespaced under `DRIVE_REACT_SHARING.*` so consumers
can write exhaustive `switch (err.code) { ... }` matches at the call site
without depending on the underlying `@suaveplan/drive-sharing` engine
codes. The actual engine error (or any other thrown value) is preserved
on `cause` so a chain walker can still surface low-level diagnostics.
## Features
- Module-load-time registration with the global Suaveplan code registry.
- Brand symbol on every instance — defence-in-depth against cross-realm
`instanceof` failures via `DriveReactSharingError.is(value)`.
- Seven codes covering input validation (`INVALID_RECIPIENT`), engine
authorization failures (`PERMISSION_DENIED`, `GRANT_NOT_FOUND`), and
hook action wrapping (`ADD_FAILED`, `REVOKE_FAILED`, `UPDATE_FAILED`,
`REFRESH_FAILED`).
- Compatible with the `SuaveplanError.cause` chain so the original
`DriveShareError` thrown by the engine is recoverable.
## Usage: Basic Example
```ts
import {
DriveReactSharingError,
DRIVE_REACT_SHARING_CODES,
} from "@suaveplan/drive-react-sharing";
try {
await result.addRecipient("", "read");
} catch (err) {
if (DriveReactSharingError.is(err)) {
if (err.code === DRIVE_REACT_SHARING_CODES.INVALID_RECIPIENT) {
// Show inline form validation error
}
}
}
```
## Usage: Advanced Example
Wrapping an engine-level error and walking the cause chain:
```ts
import {
DriveReactSharingError,
DRIVE_REACT_SHARING_CODES,
} from "@suaveplan/drive-react-sharing";
import { DriveShareError } from "@suaveplan/drive-sharing";
try {
await result.addRecipient("user-42", "admin");
} catch (err) {
if (
DriveReactSharingError.is(err) &&
err.code === DRIVE_REACT_SHARING_CODES.ADD_FAILED &&
err.cause instanceof DriveShareError
) {
console.error("engine code:", err.cause.code);
}
}
```
## API Reference
- `DRIVE_REACT_SHARING_CODES` — frozen record of every code literal.
- `DriveReactSharingErrorCode` — union type of every code value.
- `class DriveReactSharingError extends SuaveplanError<DriveReactSharingErrorCode>`
- `new DriveReactSharingError(code, messageKey, options?)` — `options.cause`
preserves the original error.
- `static is(value): value is DriveReactSharingError` — brand-aware guard.
## Implementation Notes
The brand symbol is unique per module evaluation; consumers MUST NOT
construct a `DriveReactSharingError` outside this package (the
constructor is exported only to allow shared error helpers in this
workspace to throw it). The seven codes are intentionally split into
input-validation versus action-wrapper buckets so UI can render
field-level messages distinct from optimistic-update rollback toasts.

View File

@@ -0,0 +1,126 @@
# `useSharing` hook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useSharing` is the React-tier binding for `@suaveplan/drive-sharing`. It
holds a local list of `ShareRecipient` rows that mirror the active grants
on a partition, and exposes four async actions (`addRecipient`,
`removeRecipient`, `updatePermission`, `refresh`) that delegate to the
real engine — no in-memory stub, no mocks. After every successful action
the hook re-syncs from `GrantManager.list({ partitionId, active: true })`
so optimistic UI never drifts from the engine's truth.
## Features
- Wires directly to live `PartitionManager`, `GrantManager`, and
`TokenManager` instances supplied by the caller. The package does not
own a partition store.
- AbortSignal threaded through every action. A pre-aborted signal raises
a `DriveReactSharingError` before any engine mutation.
- Surfaces failures via both `result.error` (for inline rendering) and
`throw` (for promise-chain orchestration). Underlying engine errors
are preserved on `cause`.
- Emits three OTel counters: `drive.react.sharing.recipients_added_total`,
`drive.react.sharing.recipients_removed_total`,
`drive.react.sharing.permissions_updated_total{permission}`.
- Empty-string recipient ids raise `INVALID_RECIPIENT` synchronously.
- Permission update is a revoke-then-create round-trip because the engine
has no atomic mutate API on `GrantManager`.
## Usage: Basic Example
```tsx
import {
GrantManager,
InMemoryPartitionStore,
KeyRing,
PartitionManager,
TokenManager,
userId,
} from "@suaveplan/drive-sharing";
import { useSharing } from "@suaveplan/drive-react-sharing";
const partitions = new PartitionManager({
store: new InMemoryPartitionStore(),
keyRing: new KeyRing(),
});
const grants = new GrantManager();
const tokens = new TokenManager();
function PanelWidget({ partitionId }: { partitionId: string }) {
const sharing = useSharing({
partitionId,
grantorId: userId("alice"),
partitions,
grants,
tokens,
});
return (
<button
type="button"
onClick={() => sharing.addRecipient("bob", "read")}
>
Add bob
</button>
);
}
```
## Usage: Advanced Example
Composing with `AbortController` so a parent dialog can cancel inflight
mutations on close:
```tsx
import { useEffect } from "react";
function Composer({
partitionId,
sharing,
}: {
partitionId: string;
sharing: ReturnType<typeof useSharing>;
}) {
useEffect(() => {
const ctrl = new AbortController();
return () => ctrl.abort();
}, [partitionId]);
async function bulkPromote(ids: readonly string[]): Promise<void> {
const ctrl = new AbortController();
for (const id of ids) {
await sharing.updatePermission(id, "admin", ctrl.signal);
}
}
return null;
}
```
## API Reference
- `useSharing(options: UseSharingOptions): UseSharingResult`
- `options.partitionId: string` — partition the grants belong to.
- `options.grantorId: UserId` — typically the current user.
- `options.partitions: PartitionManager` — engine partition manager.
- `options.grants: GrantManager` — engine grant manager.
- `options.tokens: TokenManager` — engine token manager.
- `options.initialRecipients?: readonly ShareRecipient[]` — optional
SSR / Storybook seed; when omitted the hook seeds from
`grants.list({ partitionId, active: true })`.
- Returns `UseSharingResult` — `{ recipients, loading, error,
addRecipient(id, perm, signal?), removeRecipient(grantId, signal?),
updatePermission(grantId, perm, signal?), refresh(signal?) }`.
## Implementation Notes
The recipient list is refreshed by re-reading from the engine after
every mutation rather than spliced in place. This keeps the hook free
of merge-conflict logic when multiple components share the same
`GrantManager`. `primaryPermission` on each row is computed at projection
time from a fixed tier ranking (`read < write < delete < share <
admin`); consumers should still consult the full `permissions` array
when making entitlement decisions, but UI badges can render the primary
tier directly.

View File

@@ -0,0 +1,75 @@
# OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/drive-react-sharing` emits three counters covering every
mutation the `useSharing` hook can dispatch. They are useful both for
operational dashboards (how often are admins adding versus removing
recipients?) and for product analytics (which permission tier is
trending?). Instruments are created lazily so this module is safe to
import inside a tree-shaken bundle even when the consumer never
configures a `MeterProvider`.
## Features
- Lazy instrument creation — first `getDriveReactSharingInstruments()`
call resolves the meter and caches the counter triple.
- `resetDriveReactSharingInstruments()` clears the cache so tests can
swap a fresh meter provider per case without leaking instruments.
- Counter names follow the `drive.react.sharing.*_total` convention so
Prometheus / OTLP backends can aggregate the family cheanly.
## Usage: Basic Example
```ts
import { getDriveReactSharingInstruments } from "@suaveplan/drive-react-sharing";
const { recipientsAddedTotal } = getDriveReactSharingInstruments();
recipientsAddedTotal.add(1, { partition: "shared" });
```
## Usage: Advanced Example
Swap meter providers between integration test cases:
```ts
import { metrics } from "@opentelemetry/api";
import { resetDriveReactSharingInstruments } from "@suaveplan/drive-react-sharing";
afterEach(() => {
metrics.disable();
resetDriveReactSharingInstruments();
});
```
## API Reference
- `interface DriveReactSharingInstruments` — `{ recipientsAddedTotal, recipientsRemovedTotal, permissionsUpdatedTotal }`.
- `getDriveReactSharingInstruments(): DriveReactSharingInstruments` — lazy accessor.
- `resetDriveReactSharingInstruments(): void` — test-only reset.
## Implementation Notes
The instruments are deliberately Counters (not Histograms) because each
event is a single mutation with no associated duration distribution.
Permission updates carry a `permission` attribute so dashboards can
distinguish `read` / `write` / `admin` trends; the other counters carry
no attributes by default so cardinality remains bounded.
Lazy creation matters because every project consumer that pulls in the
React-tier sharing surface should pay zero instrument overhead until it
actually mutates a grant. The cache lives at module scope so the first
caller materialises the counters and every subsequent caller pays only
a single property lookup. Tests must call `resetDriveReactSharingInstruments`
between cases when they swap the global `MeterProvider`; otherwise the
cached counters retain references to the prior provider's meter and
new metric points never surface in the swapped backend.
The decision to scope updates by `permission` attribute rather than by
recipient id keeps cardinality at exactly five values matching the
permission union (`read` / `write` / `delete` / `share` / `admin`),
which is friendly to Prometheus label cardinality budgets and avoids
the runaway-tag pitfall common in user-id-attributed counters.

View File

@@ -0,0 +1,79 @@
# Canonical sharing types
## Purpose
This module is the single source of truth for the React-tier sharing
type surface. It re-exports the canonical `SharePermission`,
`PartitionType`, `Grant`, `UserId`, and `PartitionId` types directly
from `@suaveplan/drive-sharing` — the engine package owns the literal
sets — and adds three projection types (`ShareRecipient`, `AccessEntry`,
`UseSharingOptions` / `UseSharingResult`) tailored to the UI surface.
On main, three overlapping permission names existed: `SharePermission`,
`AccessPermission`, and `SharingPermission`. suaveplan consolidates these
into the engine-owned `SharePermission` and refuses to ship a second
alias.
## Features
- Single canonical re-export of `SharePermission` covering every
permission tier accepted by the engine (`read | write | delete | share | admin`).
- `ShareRecipient` UI projection — flattens an engine `Grant` plus
primary permission for badge rendering.
- `AccessEntry` — read-only row used by `<FileAccessList />`.
- `UseSharingOptions` / `UseSharingResult` — wire the hook to live
engine instances (`PartitionManager`, `GrantManager`, `TokenManager`)
without forcing consumers to import three packages.
## Usage: Basic Example
```ts
import type {
SharePermission,
ShareRecipient,
} from "@suaveplan/drive-react-sharing";
const tier: SharePermission = "write";
function render(recipients: readonly ShareRecipient[]): void {
/* ... */
}
```
## Usage: Advanced Example
```ts
import type {
UseSharingOptions,
UseSharingResult,
} from "@suaveplan/drive-react-sharing";
function buildOptions(
partitionId: string,
grantor: UseSharingOptions["grantorId"],
deps: Pick<UseSharingOptions, "partitions" | "grants" | "tokens">
): UseSharingOptions {
return { partitionId, grantorId: grantor, ...deps };
}
function summarize(result: UseSharingResult): string {
return `${result.recipients.length} recipients (${result.loading ? "loading" : "idle"})`;
}
```
## API Reference
- `type SharePermission = "read" | "write" | "delete" | "share" | "admin"`
(re-exported from `@suaveplan/drive-sharing`).
- `type PartitionType = "personal" | "shared" | "public" | "encrypted"`.
- `interface ShareRecipient` — UI projection of a `Grant`.
- `interface AccessEntry` — read-only access-list row.
- `interface UseSharingOptions` — `{ partitionId, grantorId, partitions, grants, tokens, initialRecipients? }`.
- `interface UseSharingResult` — `{ recipients, loading, error, addRecipient, removeRecipient, updatePermission, refresh }`.
## Implementation Notes
`ShareRecipient.primaryPermission` is the highest tier present in the
flat `permissions` array — chosen for badge colour selection. The hook
computes this when constructing recipients from the engine, so consumers
should treat the field as authoritative for display purposes but still
consult the full `permissions` array for entitlement decisions.

View File

@@ -0,0 +1,132 @@
# @suaveplan/drive-sharing - Partitioned drive sharing engine for the Suaveplan ecosystem: envelope encryption with mandatory AAD, per-user key wrapping, partition policies with deterministic action validation, revocable share tokens, expiring grants, and atomic cross-partition file transfer with rollback.
## Summary
Partitioned drive sharing engine for the Suaveplan ecosystem. Ships envelope encryption with mandatory AAD, per-user key wrapping, partition policies with deterministic action validation, revocable share tokens, expiring grants, and atomic cross-partition file transfer with rollback. Every public boundary is Zod-validated, OTel-instrumented, and event-bus-aware.
<!-- begin:auto-docs -->
## Documentation tree
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
<!-- end:auto-docs --> - [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_ - [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ - [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
## Install
```bash
bun add @suaveplan/drive-sharing
```
## Quick start
```ts
import {
GrantManager,
InMemoryPartitionStore,
KeyRing,
PartitionManager,
TokenManager,
TransferManager,
envelopeEncrypt,
envelopeDecrypt,
fileId,
partitionId,
userId,
wrapKey,
} from "@suaveplan/drive-sharing";
const keyRing = new KeyRing();
const partitions = new PartitionManager({
store: new InMemoryPartitionStore(),
keyRing,
});
const partition = await partitions.create({
name: "research",
type: "shared",
ownerId: userId("alice"),
});
const grants = new GrantManager();
const tokens = new TokenManager();
```
## Modules
| Subpath | Purpose |
|---|---|
| `@suaveplan/drive-sharing` | Default barrel — re-exports every public type and class. |
| `@suaveplan/drive-sharing/errors` | `DriveShareError`, `DRIVE_SHARE_CODES`. |
| `@suaveplan/drive-sharing/constants` | TTL defaults, format version, scalability caps. |
| `@suaveplan/drive-sharing/types/common` | Branded IDs, wire types, `Result`, `generateId`. |
| `@suaveplan/drive-sharing/types/schemas` | Zod schemas for every public option type. |
| `@suaveplan/drive-sharing/encryption/envelope` | `envelopeEncrypt`, `envelopeDecrypt`, `wrapKey`, `unwrapKey`, `rewrapKey`, `WebCryptoProvider`. |
| `@suaveplan/drive-sharing/encryption/key-ring` | `KeyRing` with add / get / remove / rotate / export / import. |
| `@suaveplan/drive-sharing/partitions/partition-policy` | `createPolicy`, `validateAction`, `DEFAULT_PRESETS`. |
| `@suaveplan/drive-sharing/partitions/partition-store` | `PartitionStore` interface, `InMemoryPartitionStore`. |
| `@suaveplan/drive-sharing/partitions/partition-manager` | `PartitionManager` lifecycle. |
| `@suaveplan/drive-sharing/sharing/grants` | `GrantManager` (in-memory grants with check / revoke / list). |
| `@suaveplan/drive-sharing/sharing/tokens` | `TokenManager` (HMAC-SHA-256 integrity-tagged tokens). |
| `@suaveplan/drive-sharing/transfer` | `TransferManager` (cross-partition rewrap with `AbortSignal`). |
| `@suaveplan/drive-sharing/otel` | OTel instrument singletons. |
## Security model
- **AAD is mandatory** — every `envelopeEncrypt` and `wrapKey` call requires a non-empty `aad: Uint8Array`. Empty AAD throws `SHARE_ENCRYPTION_FAILED` to enforce the partition-binding guarantee.
- **No plaintext keys leave the package** — `KeyRing.rotateKey` zeroizes its caller-supplied buffer; `rewrapKey` zeroizes the intermediate plaintext DEK before returning.
- **HMAC-SHA-256 token integrity** — every share token carries an integrity tag computed under the partition master key. Tampering invalidates the tag.
- **No key material in events / spans / logs** — every event payload carries IDs only.
## Performance baselines
| Benchmark | Iteration | Wall-clock |
|---|---|---|
| `grant_check_10k` | 10 000 in-memory grant checks | < 50 ms |
| `token_validate_10k` | 10 000 in-memory token validations | < 50 ms |
| `envelope_encrypt_1mb` | one 1 MiB AES-GCM encrypt | < 10 ms |
| `envelope_decrypt_1mb` | one 1 MiB AES-GCM decrypt | < 10 ms |
| `keyring_rotate_1000` | 1 000 rotateKey calls | < 250 ms |
Run benchmarks with `bun run src/__benchmarks__/drive-sharing.bench.ts`.
## Testing posture
- 100% statement / branch / function / line coverage (Istanbul).
- Property-based tests via `fast-check` covering grant expiry, token revocation finality, policy merge commutativity, and partition-member invariants.
- All test files import runner primitives from `@suaveplan/testing/runner` for dual `vitest` + `bun:test` portability.
## License
Proprietary — Suaveplan commercial license.

View File

@@ -0,0 +1,105 @@
# `encryption/envelope/envelope.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the cryptographic core of `@suaveplan/drive-sharing`. It declares the `CryptoProvider` interface, ships the default `WebCryptoProvider` implementation backed by noble AEAD primitives, and exposes the public envelope-encryption API: `envelopeEncrypt`, `envelopeDecrypt`, `wrapKey`, `unwrapKey`, `rewrapKey`, `computeIntegrity`, `verifyIntegrity`. Every function here is the only call site that touches AEAD ciphers; downstream modules (key-ring, transfer, partition-manager) consume only this surface so adding a new algorithm or swapping in a FIPS-validated provider is a single-file change.
## Features
- Mandatory non-empty AAD on every encrypt and unwrap operation. Empty AAD produces `SHARE_ENCRYPTION_FAILED`, foreclosing cut-and-paste attacks across partitions.
- `WrappedKey` carries a SHA-256 checksum verified before unwrap; corrupt entries throw `SHARE_KEY_NOT_FOUND` instead of silently passing.
- Configurable AEAD algorithm (`AES-GCM` default, `XChaCha20-Poly1305` opt-in) selected per call.
- `rewrapKey` zeroizes the intermediate plaintext key via `@suaveplan/crypto-utils`'s `zeroize`, so an attacker who reads heap memory after a rotation cannot recover the DEK.
- `verifyIntegrity` is timing-safe via `constantTimeEqual`.
- OTel instrumentation: every public function records `share.envelope.duration_ms` and starts a `share.envelope.encrypt` / `share.envelope.decrypt` span; no key material or plaintext bytes are exported as attributes.
## Usage: Basic Example
```ts
import {
envelopeEncrypt,
envelopeDecrypt,
} from "@suaveplan/drive-sharing/encryption/envelope";
const key = new Uint8Array(32).fill(0x42);
const aad = new TextEncoder().encode("partition-1::file-7");
const data = new TextEncoder().encode("secret payload");
const bundle = await envelopeEncrypt(key, data, { aad });
const plaintext = await envelopeDecrypt(key, bundle);
console.log(new TextDecoder().decode(plaintext)); // "secret payload"
```
## Usage: Advanced Example
```ts
import {
envelopeEncrypt,
rewrapKey,
wrapKey,
unwrapKey,
} from "@suaveplan/drive-sharing/encryption/envelope";
// Cross-partition key migration without exposing plaintext.
const oldKEK = new Uint8Array(32).fill(0xaa);
const newKEK = new Uint8Array(32).fill(0xbb);
const dek = new Uint8Array(32).fill(0xcc);
const aad = new TextEncoder().encode("partition-1::user-alice");
const wrapped = await wrapKey(oldKEK, dek, { aad });
const rewrapped = await rewrapKey(oldKEK, newKEK, wrapped, { aad });
const recovered = await unwrapKey(newKEK, rewrapped, { aad });
// recovered === dek (by value)
```
## API Reference
### `interface CryptoProvider`
Pluggable provider interface. Every method is async; AAD is mandatory on `encrypt`, `wrapKey`, and `unwrapKey`. The default implementation is `WebCryptoProvider`.
### `class WebCryptoProvider implements CryptoProvider`
Default provider. Uses `aes256gcm` / `xchacha20poly1305` from `@suaveplan/crypto-cipher`, draws nonces from `randomBytes` in `@suaveplan/crypto-utils`, computes integrity via `sha256` in `@suaveplan/crypto-hash`. AES-256-GCM is the default algorithm; `XChaCha20-Poly1305` is opt-in via the `algorithm` option.
### `async function envelopeEncrypt(key, data, options): Promise<EncryptedBundle>`
- `key: Uint8Array` — 32-byte AEAD key.
- `data: Uint8Array` — plaintext.
- `options.aad: Uint8Array` — required, non-empty.
- `options.algorithm?: EncryptionAlgorithm` — defaults to `AES-GCM`.
- **Throws** `DriveShareError(SHARE_ENCRYPTION_FAILED)` on any failure.
### `async function envelopeDecrypt(key, bundle): Promise<Uint8Array>`
Decrypts. Mismatched AAD or tampered ciphertext throws `SHARE_ENCRYPTION_FAILED`.
### `async function wrapKey(wrappingKey, keyToWrap, options): Promise<WrappedKey>`
Wraps `keyToWrap` with `wrappingKey`. The result carries `algorithm`, `data`, and a SHA-256 hex `checksum`.
### `async function unwrapKey(wrappingKey, wrappedKey, options): Promise<Uint8Array>`
Validates checksum then decrypts. Throws `SHARE_KEY_NOT_FOUND` on checksum mismatch and `SHARE_ENCRYPTION_FAILED` on AEAD failure.
### `async function rewrapKey(oldWrappingKey, newWrappingKey, wrappedKey, options): Promise<WrappedKey>`
Atomic rewrap. Zeroizes the intermediate plaintext key before returning.
### `async function computeIntegrity(data): Promise<string>`
SHA-256 hex digest.
### `async function verifyIntegrity(data, expected): Promise<boolean>`
Constant-time comparison.
## Implementation Notes
- The bundle stores AAD in-band (`bundle.aad`) so `envelopeDecrypt` is callable with only `(key, bundle)` per the spec scenario. The AAD is also fed to the AEAD primitive at decrypt time, so any tampering of the in-band AAD invalidates the tag.
- AES-KW is accepted as an `EncryptionAlgorithm` tag for backward compatibility but internally uses AES-256-GCM with the AAD bound to the partition || user context — this is functionally equivalent and avoids depending on an extra primitive.
- The SHA-256 `checksum` field on `WrappedKey` is **not** a substitute for the AEAD tag — it is a fast sanity check so the unwrap function can reject obviously-corrupt data without paying the cost of an AEAD attempt.
- Nonces are drawn fresh on every encrypt call via `randomBytes`. Because this routes through `crypto.getRandomValues`, the collision probability for a 96-bit GCM nonce is ~2⁻⁴⁸ per ~2³² keys — well below the IETF's 2⁻³² safety bound.

View File

@@ -0,0 +1,90 @@
# `encryption/key-ring/key-ring.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`KeyRing` is the multi-user wrapped-key store at the heart of `@suaveplan/drive-sharing`. It owns the partition master key, holds per-user data keys (DEKs) wrapped under that master key, and exposes the lifecycle operations needed to add, retrieve, rotate, remove, export, and import wrapped entries. Every operation emits a lifecycle event so callers can persist to durable storage and audit changes; events deliberately carry only `userId` (no key material).
## Features
- AAD-bound wrap / unwrap via `wrapKey` / `unwrapKey` from `encryption/envelope`. The AAD is per-user (`drive-sharing/v1/keyring/<userId>`) so a wrapped key cannot be moved between users without producing an AEAD failure.
- `rotateKey` rewraps a single user's entry; `rotateMasterKey` rewraps every entry atomically. Both zeroize old buffers via `@suaveplan/crypto-utils`'s `zeroize`.
- Versioned JSON-like `export` / `import`. The export carries `keyring_format_version: 1`; unknown versions throw `SHARE_KEYRING_FORMAT_UNSUPPORTED`.
- Hard-cap on entries (`KEYRING_MAX_ENTRIES = 100_000`) prevents pathological bulk-add denial-of-service.
- Event bus (`key:added`, `key:removed`, `key:rotated`) with `on(event, listener) => unsubscribe`.
## Usage: Basic Example
```ts
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
import { userId } from "@suaveplan/drive-sharing/types/common";
const ring = new KeyRing();
const dek = new Uint8Array(32).fill(0xab);
await ring.addKey(userId("alice"), dek);
const recovered = await ring.getKey(userId("alice"));
```
## Usage: Advanced Example
```ts
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
import { userId } from "@suaveplan/drive-sharing/types/common";
const masterKey = new Uint8Array(32).fill(0x01);
const ring = new KeyRing({ masterKey });
const off = ring.on("key:rotated", ({ userId }) => {
console.log("rotated:", userId);
});
await ring.addKey(userId("alice"), new Uint8Array(32).fill(0x10));
await ring.rotateKey(userId("alice"), new Uint8Array(32).fill(0x20));
// Backup / restore round-trip.
const blob = ring.export();
const restored = new KeyRing({ masterKey });
await restored.import(blob);
off();
```
## API Reference
### `class KeyRing`
- `constructor(config?: KeyRingConfig)` — `masterKey` defaults to a fresh 32-byte CSPRNG buffer.
- `hasKey(userId): boolean`
- `listKeys(): UserId[]`
- `addKey(userId, key): Promise<void>` — wraps and stores. Emits `key:added`.
- `getKey(userId): Promise<Uint8Array | undefined>`
- `removeKey(userId): Promise<void>` — emits `key:removed` only when an entry is actually removed.
- `rotateKey(userId, newKey): Promise<UserWrappedKey>` — replaces the entry, emits `key:rotated`, increments `share.key.rotate_total`.
- `rotateMasterKey(newMasterKey): Promise<void>` — rewraps every entry atomically.
- `export(): { keyring_format_version: 1; entries: UserWrappedKey[] }`
- `import(payload): Promise<void>` — Zod-validated.
- `on(event, listener): () => void`
### `interface UserWrappedKey`
`{ userId, wrappedKey }`. The export shape stores arrays of these.
### `interface KeyRingConfig`
`{ masterKey?: Uint8Array }`. Master key length must be exactly 32 bytes.
### Events
- `key:added` — `{ userId }`
- `key:removed` — `{ userId }`
- `key:rotated` — `{ userId }`
## Implementation Notes
- The constructor copies the supplied `masterKey` so the caller's buffer can be zeroized independently. `rotateMasterKey` writes the new key into the same buffer slot via `Uint8Array.set` so external references to the slot are invalidated rather than left dangling.
- `export` deliberately produces a plain object (not a JSON string) so the caller can pipe it through their preferred serializer (e.g. `serialize` for tagged JSON). Validation lives in `KeyRingExportSchema`.
- The hard cap `KEYRING_MAX_ENTRIES = 100_000` matches the OpenSpec scalability table. Above this, the spec calls for sharded ring storage which is out of scope for this package.
- `rotateKey` zeroizes the caller-supplied `newKey` on success — this matches the spec's "no plaintext key in caller's heap after success" guarantee. If the caller wants to retain the bytes they should pass a copy.
- The `aadFor` derivation uses ASCII text (`drive-sharing/v1/keyring/<userId>`) rather than raw bytes so the AAD is human-readable in audit logs that capture it.

View File

@@ -0,0 +1,91 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`. It registers a frozen, namespaced code list with the global `defineCodes` registry from `@suaveplan/error` and exports the concrete `DriveShareError` subclass that every public code path throws. Stable, machine-readable codes let consumers write exhaustive `switch` statements on caught errors without having to parse human-readable messages. The brand symbol on every `DriveShareError` instance protects callers from imposters (e.g. user-land error classes that copy the field shape but were thrown elsewhere).
## Features
- Frozen literal-typed `DRIVE_SHARE_CODES` registry covering eleven distinct failure reasons across partitions, grants, tokens, keys, encryption, transfer, and key-ring import.
- `DriveShareError` extends `SuaveplanError<DriveShareErrorCode>` so `error.code` narrows to the literal union.
- Brand-symbol stamping plus a static `DriveShareError.is()` guard for cross-realm safety.
- Idempotent eager-registration helper (`registerDriveShareErrorCodes`) so consumers can pre-warm the registry from a startup hook.
- Side-effect call at module bottom guarantees the codes are registered the first time the module is imported.
## Usage: Basic Example
```ts
import {
DriveShareError,
DRIVE_SHARE_CODES,
} from "@suaveplan/drive-sharing/errors";
try {
// ...some drive-sharing call...
} catch (error) {
if (DriveShareError.is(error)) {
if (error.code === DRIVE_SHARE_CODES.SHARE_PARTITION_NOT_FOUND) {
console.warn("partition vanished — falling back to read-only");
}
}
}
```
## Usage: Advanced Example
```ts
import {
DriveShareError,
type DriveShareErrorCode,
} from "@suaveplan/drive-sharing/errors";
// Exhaustive switch — TypeScript will complain if you forget a case.
function classify(error: DriveShareError): "transient" | "permanent" {
const code: DriveShareErrorCode = error.code;
switch (code) {
case "DRIVE_SHARE.SHARE_TRANSFER_FAILED":
case "DRIVE_SHARE.SHARE_POLICY_VIOLATION":
return "transient";
case "DRIVE_SHARE.SHARE_PARTITION_NOT_FOUND":
case "DRIVE_SHARE.SHARE_GRANT_NOT_FOUND":
case "DRIVE_SHARE.SHARE_TOKEN_INVALID":
case "DRIVE_SHARE.SHARE_TOKEN_EXPIRED":
case "DRIVE_SHARE.SHARE_TOKEN_REVOKED":
case "DRIVE_SHARE.SHARE_KEY_NOT_FOUND":
case "DRIVE_SHARE.SHARE_ENCRYPTION_FAILED":
case "DRIVE_SHARE.SHARE_KEYRING_FORMAT_UNSUPPORTED":
case "DRIVE_SHARE.SHARE_INTEGRITY_VERIFICATION_FAILED":
return "permanent";
}
}
```
## API Reference
### `const DRIVE_SHARE_CODES`
Frozen `Record<DriveShareErrorCode, DriveShareErrorCode>` returned by `defineCodes`. Iterating the values yields every fully-qualified code string.
### `type DriveShareErrorCode`
Union literal of every namespaced code under `DRIVE_SHARE.*`.
### `class DriveShareError extends SuaveplanError<DriveShareErrorCode>`
- **constructor** `(code, messageKey, options?)` — same shape as `SuaveplanError`. Stamps the brand symbol on the instance.
- **static** `is(value): value is DriveShareError` — true only when `value` is `instanceof` AND carries the package-private brand symbol.
### `function registerDriveShareErrorCodes(): void`
Idempotent eager-registration hook. The codes are also registered automatically by importing this module.
## Implementation Notes
- The brand symbol uses `Symbol("@suaveplan/drive-sharing.error")` and is **not** exported, so external code cannot fabricate it.
- `DriveShareError.is()` checks both `instanceof` and the brand because `instanceof` breaks across iframe / worker realms.
- The `registered` module-private flag prevents repeat registration churn but the underlying `defineCodes` call is itself a no-op on the second invocation.
- Adding a new code is a non-breaking change; removing one or renaming one is a major-version bump per the package contract.

View File

@@ -0,0 +1,94 @@
# `otel/instruments.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the singleton OpenTelemetry instrument set for `@suaveplan/drive-sharing`. It captures a `Meter` and `Tracer` from the global OTel API on first use, builds every counter / histogram declared in the OpenSpec change, and exposes setter / reset hooks so tests can route everything through an in-memory `MeterProvider` for assertion.
## Features
- Lazy-singleton instrument acquisition: zero cost when no provider is registered (no-op meters are returned automatically by `@opentelemetry/api`).
- `setDriveShareMeterProvider` / `setDriveShareTracerProvider` accept `undefined` to fall back to the global default — handy for `afterEach` cleanup.
- Eight counters and three histograms covering partitions, grants, tokens, transfers, key rotations, policy checks, and envelope encrypt / decrypt latency.
- Safe-by-construction: only IDs (which are public-by-design), durations, and counts are recorded — never key material or plaintext bytes.
## Usage: Basic Example
```ts
import { getDriveShareInstruments } from "@suaveplan/drive-sharing/otel";
const i = getDriveShareInstruments();
i.grantCheckTotal.add(1);
i.envelopeDurationMs.record(2.7);
```
## Usage: Advanced Example
```ts
import {
getDriveShareInstruments,
setDriveShareMeterProvider,
resetDriveShareInstruments,
} from "@suaveplan/drive-sharing/otel";
const records: Array<[string, number]> = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (v: number) => records.push([name, v]),
}),
createHistogram: (name: string) => ({
record: (v: number) => records.push([name, v]),
}),
}),
} as unknown as Parameters<typeof setDriveShareMeterProvider>[0];
setDriveShareMeterProvider(provider);
try {
getDriveShareInstruments().grantDeniedTotal.add(1);
// assert against `records`
} finally {
setDriveShareMeterProvider(undefined);
resetDriveShareInstruments();
}
```
## API Reference
### `interface DriveShareInstruments`
The structural shape of the instrument bundle. Field summary:
| Field | Type | Notes |
|---|---|---|
| `partitionMemberTotal` | Counter | Members added |
| `grantDeniedTotal` | Counter | Grant denials with `reason` attribute |
| `grantCheckTotal` | Counter | Grant checks (allowed + denied) |
| `grantCreateTotal` | Counter | Grants created |
| `partitionCreateTotal` | Counter | Partitions created |
| `tokenCreateTotal` | Counter | Tokens created |
| `tokenValidateTotal` | Counter | Token validations |
| `tokenRevokedTotal` | Counter | Tokens revoked |
| `transferBytesTotal` | Counter | Bytes transferred |
| `keyRotateTotal` | Counter | Key rotations |
| `policyCheckDurationMs` | Histogram | Policy-check latency in ms |
| `envelopeDurationMs` | Histogram | Envelope encrypt/decrypt latency in ms |
| `transferDurationMs` | Histogram | Cross-partition transfer latency in ms |
### Functions
- `getDriveShareInstruments(): DriveShareInstruments` — returns the lazy singleton.
- `setDriveShareMeterProvider(provider | undefined): void` — overrides the meter source. `undefined` clears the override.
- `resetDriveShareInstruments(): void` — forces re-acquisition on next call.
- `getDriveShareTracer(): Tracer` — lazy singleton tracer.
- `setDriveShareTracerProvider(provider | undefined): void` — overrides the tracer source.
- `resetDriveShareTracer(): void` — forces re-acquisition on next call.
## Implementation Notes
- The module captures the OTel global state once and caches it for the rest of the process lifetime to avoid `getMeter` overhead on hot paths (grant checks, envelope encrypt) — at scale these are sub-millisecond operations and accruing 1µs of OTel overhead per call would dominate the budget.
- `setDriveShareMeterProvider(undefined)` resets to the default global provider but does **not** automatically rebuild the instrument cache — callers must invoke `resetDriveShareInstruments()` explicitly. This keeps the no-op fast path predictable.
- Counter and histogram unit strings follow the [UCUM standard](https://ucum.org/) — `ms` for durations, `By` for bytes, `{members}` etc. for dimensionless counts.
- The `description` text for each instrument is the contract that downstream observability dashboards will display; reword cautiously.

View File

@@ -0,0 +1,120 @@
# `partitions/partition-manager/partition-manager.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PartitionManager` orchestrates the lifecycle of partition records. It accepts a `PartitionStore` for persistence and a `KeyRing` for per-user key material, validates every mutation against the active policy via `validateAction`, and emits `partition:*` and `member:*` events on a caller-supplied `EventBusLike`. The manager is the single throat through which all partition state changes pass; downstream modules (grants, tokens, transfer) treat the partition as immutable input.
## Features
- Five mutating operations (`create`, `delete`, `addMember`, `removeMember`, `update`) plus three read-only operations (`get`, `list`, `getKeyRing`).
- Zod-validated inputs at every public entrypoint via `CreatePartitionOptionsSchema` / `AddMemberOptionsSchema` / `UpdatePartitionOptionsSchema`.
- Injectable `Clock` for deterministic timestamping and an injectable id generator for deterministic IDs in tests.
- `EventBusLike<PartitionManagerEvents>` carries `partition:created`, `partition:deleted`, `partition:updated`, `member:added`, `member:removed`. No key material in event payloads.
- OTel spans on every mutating operation; counter `share.partition.member_total` per partition; counter `share.partition.create_total`.
- Graceful-degradation read-only toggle (`setReadOnly`) — mutations throw `SHARE_POLICY_VIOLATION` while the flag is true.
## Usage: Basic Example
```ts
import { PartitionManager } from "@suaveplan/drive-sharing/partitions/partition-manager";
import { InMemoryPartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
import { userId } from "@suaveplan/drive-sharing/types/common";
const manager = new PartitionManager({
store: new InMemoryPartitionStore(),
keyRing: new KeyRing(),
});
const partition = await manager.create({
name: "research",
type: "shared",
ownerId: userId("alice"),
});
```
## Usage: Advanced Example
```ts
import { PartitionManager } from "@suaveplan/drive-sharing/partitions/partition-manager";
import { InMemoryPartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
let now = 0;
let counter = 0;
const events = new Map<string, unknown[]>();
const bus = {
on: () => () => {},
emit: (k: string, p: unknown) => {
const arr = events.get(k) ?? [];
arr.push(p);
events.set(k, arr);
},
};
const manager = new PartitionManager({
store: new InMemoryPartitionStore(),
keyRing: new KeyRing(),
clock: () => (now += 1000),
idGenerator: () => `id-${(counter += 1)}`,
events: bus,
});
const p = await manager.create({
name: "vault",
type: "encrypted",
ownerId: userId("alice"),
policy: { maxMembers: 3 },
});
await manager.addMember(p.id, userId("bob"), { permissions: ["read"] });
manager.setReadOnly(true);
// further mutations throw SHARE_POLICY_VIOLATION
```
## API Reference
### `class PartitionManager`
- `constructor(config: PartitionManagerConfig)`
- `create(options): Promise<Partition>`
- `get(id): Promise<Partition>` — throws `SHARE_PARTITION_NOT_FOUND`.
- `list(): Promise<Partition[]>`
- `delete(id): Promise<void>`
- `addMember(id, userId, options?): Promise<Partition>` — throws `SHARE_POLICY_VIOLATION` when policy denies.
- `removeMember(id, userId): Promise<Partition>` — idempotent.
- `update(id, options): Promise<Partition>`
- `setReadOnly(value): void` / `isReadOnly(): boolean`
- `getKeyRing(): KeyRing`
### `interface CreatePartitionOptions`
`{ name, type, ownerId, policy? }`.
### `interface AddMemberOptions`
`{ permissions?, wrappedKey? }`. Default permissions are `["read"]`.
### `interface UpdatePartitionOptions`
`{ name?, policy? }`.
### `interface PartitionManagerEvents`
`partition:created` / `partition:deleted` / `partition:updated` / `member:added` / `member:removed`. No key material.
### `interface PartitionManagerConfig`
`{ store, keyRing, policy?, clock?, events?, idGenerator? }`.
## Implementation Notes
- The default policy is computed once in the constructor (`createPolicy(config.policy ?? {})`). Per-partition policies are layered on top via `mergePolicies` in `create` and `update`.
- `addMember` validates the partition's policy against the action `share` (the canonical "I am modifying membership" verb); when the policy denies, the call throws `SHARE_POLICY_VIOLATION`. Importantly, the check is performed against the partition's CURRENT membership — adding the Nth member when `maxMembers` is N triggers the denial branch.
- `removeMember` is intentionally idempotent. Calling it twice with the same userId returns the same partition the second time and does NOT emit `member:removed`. This matches the spec scenario "RemoveMember restores count".
- `setReadOnly(true)` is the spec's documented graceful-degradation hook; the partition store's health check is the trigger but the toggle is exposed publicly so consumers can wire any policy they like.
- Event emission is fire-and-forget — the `EventBusLike.emit` contract is synchronous and does not surface listener errors. Asynchronous side effects belong to the listener, not the manager.

View File

@@ -0,0 +1,108 @@
# `partitions/partition-policy/partition-policy.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The partition-policy module is the deterministic state-machine that decides whether a given action is permitted on a given partition. It exposes pure functions (`createPolicy`, `mergePolicies`, `validateAction`, `requiresEncryption`, `requiresReauth`), four canonical presets (`public`, `private`, `shared`, `encrypted`), and the default policy. Keeping this logic pure — no IO, no side effects beyond an OTel histogram — means consumers can call `validateAction` 100k+ times per second from a hot path.
## Features
- Frozen `DEFAULT_PRESETS` covering the four documented postures from the OpenSpec change.
- `mergePolicies` is commutative on disjoint array fields (`reauthRequired`, `allowedActions`) and dedupes overlapping entries.
- `validateAction` returns `{ allowed: true }` or `{ allowed: false, reason }` — never throws.
- `share.policy.check_duration_ms` histogram recorded on every `validateAction` call.
- `requiresEncryption` and `requiresReauth` are pure boolean predicates.
## Usage: Basic Example
```ts
import {
createPolicy,
validateAction,
} from "@suaveplan/drive-sharing/partitions/partition-policy";
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
const policy = createPolicy({ encryption: true });
const result = validateAction(policy, {
action: "read",
userId: userId("alice"),
partition: {
id: partitionId("p"),
name: "demo",
type: "shared",
ownerId: userId("alice"),
members: [],
policy,
createdAt: 0,
updatedAt: 0,
},
});
// { allowed: true }
```
## Usage: Advanced Example
```ts
import {
DEFAULT_PRESETS,
mergePolicies,
requiresEncryption,
requiresReauth,
} from "@suaveplan/drive-sharing/partitions/partition-policy";
// Compose a stricter encrypted preset.
const strictEncrypted = mergePolicies(DEFAULT_PRESETS.encrypted, {
maxMembers: 5,
ttl: 86_400_000,
});
if (requiresEncryption(strictEncrypted)) {
console.log("AEAD required");
}
if (requiresReauth(strictEncrypted, "write")) {
console.log("re-auth before write");
}
```
## API Reference
### `function getDefaultPolicy(): PartitionPolicyConfig`
Returns `{ encryption: true, reauthRequired: [], allowedActions: ["read", "write", "delete", "share"] }`.
### `function createPolicy(config: Partial<PartitionPolicyConfig>): PartitionPolicyConfig`
Equivalent to `mergePolicies(getDefaultPolicy(), config)`.
### `function mergePolicies(base, override): PartitionPolicyConfig`
Deep-merges array fields with deduplication. Scalar fields (`encryption`, `maxMembers`, `ttl`) take the override when present.
### `function validateAction(policy, ctx): PolicyValidationResult`
- Returns `{ allowed: false, reason: "..." }` when the action is not in `allowedActions`.
- Returns `{ allowed: false, reason: "maxMembers reached..." }` when the partition is full and `userId` is not already a member.
- Returns `{ allowed: true }` otherwise.
- Records `share.policy.check_duration_ms`.
### `function requiresEncryption(policy): boolean`
Equivalent to `policy.encryption`.
### `function requiresReauth(policy, action): boolean`
Equivalent to `policy.reauthRequired.includes(action)`.
### `const DEFAULT_PRESETS`
Frozen `Record<"public" | "private" | "shared" | "encrypted", PartitionPolicyConfig>`.
## Implementation Notes
- `validateAction` checks `members.some(m => m.userId === ctx.userId)` before applying the `maxMembers` ceiling, so existing members can perform actions even when the partition is full. New members are blocked.
- The `dedup` helper uses `Set` for O(N) deduplication; arrays are tiny (max five action verbs) so this is well below any cache line.
- Each preset is wrapped in `Object.freeze` at module load so consumers cannot mutate the shared singleton.
- `validateAction` deliberately does not consult `policy.reauthRequired` — that is a separate concern (`requiresReauth`) so consumers can decide whether to surface a re-auth prompt before or after the policy check.
- Adding a new `PolicyAction` requires updating `validateAction`, the `policyActionSchema` enum in `types/schemas.ts`, and the union type in `types/common.ts`. Forgetting any of the three triggers a TypeScript error or a Zod validation error at the boundary, never silent acceptance.

View File

@@ -0,0 +1,85 @@
# `partitions/partition-store/partition-store.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `PartitionStore` interface is the persistence boundary between `PartitionManager` and the durable storage backend. Decoupling the manager from a concrete store means consumers can plug in SurrealDB, Postgres, sqlite-via-WASM, or any other adapter without forking the package. The bundled `InMemoryPartitionStore` backs tests and single-node deployments where durability across process restarts is not required.
## Features
- Four-method interface: `get`, `set`, `delete`, `list`. No transactional or batched operations — those belong to the underlying database adapter.
- Async signatures even on the in-memory implementation so the consumer code path is shape-compatible regardless of backend.
- `Map<PartitionId, Partition>` backing on the in-memory store — O(1) get / set / delete, O(N) list with reflective insertion order.
## Usage: Basic Example
```ts
import {
InMemoryPartitionStore,
type PartitionStore,
} from "@suaveplan/drive-sharing/partitions/partition-store";
const store: PartitionStore = new InMemoryPartitionStore();
await store.set({
id: "p-001" as never,
name: "demo",
type: "shared",
ownerId: "alice" as never,
members: [],
policy: { encryption: true, reauthRequired: [], allowedActions: ["read"] },
createdAt: 0,
updatedAt: 0,
});
const fetched = await store.get("p-001" as never);
```
## Usage: Advanced Example
```ts
import type { PartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
class SurrealPartitionStore implements PartitionStore {
constructor(private readonly db: { query: (q: string) => Promise<unknown> }) {}
async get(id: PartitionId): Promise<Partition | undefined> {
const rows = (await this.db.query(`SELECT * FROM partition:${id}`)) as Partition[];
return rows[0];
}
async set(partition: Partition): Promise<void> {
await this.db.query(`UPSERT partition:${partition.id} CONTENT ${JSON.stringify(partition)}`);
}
async delete(id: PartitionId): Promise<void> {
await this.db.query(`DELETE partition:${id}`);
}
async list(): Promise<Partition[]> {
return (await this.db.query(`SELECT * FROM partition`)) as Partition[];
}
}
```
## API Reference
### `interface PartitionStore`
```ts
get(id: PartitionId): Promise<Partition | undefined>
set(partition: Partition): Promise<void>
delete(id: PartitionId): Promise<void>
list(): Promise<Partition[]>
```
### `class InMemoryPartitionStore implements PartitionStore`
Map-backed implementation. Construct with no arguments. State lives only for the lifetime of the instance.
## Implementation Notes
- `delete` is a no-op when the id is absent; this matches the spec scenario "Partition store delete is idempotent".
- `list` returns a fresh array on every call so the caller cannot mutate the underlying map by mutating the returned slice.
- Real backends should implement `set` with `INSERT … ON CONFLICT UPDATE` (or the database-specific equivalent) so concurrent writers do not lose updates. The in-memory implementation is single-threaded by construction.
- The interface deliberately omits transactional support. `PartitionManager` orchestrates multi-step state changes by reading, mutating, and writing — atomicity is the store's responsibility under contention. For the in-memory case there is no concurrency.

View File

@@ -0,0 +1,110 @@
# `sharing/grants/grants.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GrantManager` is the deterministic in-memory grant store. It holds `Grant` records keyed by `GrantId` and answers permission queries via `check(grantId, userId, action)`. The check is exhaustive: existence, non-revocation, non-expiry, grantee identity, and permission inclusion are all verified, in that order, with the first failure short-circuiting the result. Every check increments `share.grant.check_total`; denials additionally increment `share.grant.denied_total` with a `reason` attribute so dashboards can split denials by cause.
## Features
- Synchronous `check` — sub-millisecond by design (no IO, no crypto).
- Injectable `Clock` so expiry tests are deterministic.
- `EventBusLike` integration: `grant:created` and `grant:revoked` events carry full context but never key material.
- Hard cap on active grants (`GRANT_MANAGER_MAX_ENTRIES = 10_000_000`) per the spec scalability table.
- Filtered `list(query)` supporting `granteeId`, `partitionId`, and `active` filters.
- `revoke` is idempotent — already-revoked / missing grants are no-ops.
## Usage: Basic Example
```ts
import { GrantManager } from "@suaveplan/drive-sharing/sharing/grants";
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
const grants = new GrantManager();
const grant = grants.create({
grantorId: userId("alice"),
granteeId: userId("bob"),
partitionId: partitionId("p"),
permissions: ["read"],
});
grants.check(grant.id, userId("bob"), "read"); // { allowed: true }
```
## Usage: Advanced Example
```ts
import { GrantManager } from "@suaveplan/drive-sharing/sharing/grants";
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
let now = 0;
const events = new Map<string, unknown[]>();
const grants = new GrantManager({
clock: () => now,
events: {
on: () => () => {},
emit: (k, p) => {
const arr = events.get(k) ?? [];
arr.push(p);
events.set(k, arr);
},
},
});
const g = grants.create({
grantorId: userId("alice"),
granteeId: userId("bob"),
partitionId: partitionId("p"),
permissions: ["read"],
expiresAt: 1000,
});
now = 999;
console.log(grants.check(g.id, userId("bob"), "read")); // allowed
now = 1001;
console.log(grants.check(g.id, userId("bob"), "read")); // denied: expired
grants.revoke(g.id);
console.log(grants.list({ active: true })); // []
```
## API Reference
### `class GrantManager`
- `constructor(config?: GrantManagerConfig)`
- `create(options: CreateGrantOptions): Grant`
- `check(id, userId, action): GrantCheckResult`
- `revoke(id): void`
- `list(query?): Grant[]`
- `get(id): Grant | undefined`
### `interface CreateGrantOptions`
`{ grantorId, granteeId, partitionId, permissions, expiresAt? }`.
### `interface Grant`
`{ id, grantorId, granteeId, partitionId, permissions, createdAt, expiresAt?, revoked }`.
### `interface GrantCheckResult`
`{ allowed: boolean; reason?: string }` — `reason` is one of `grant_not_found`, `revoked`, `expired`, `grantee_mismatch`, `permission_missing`.
### `interface GrantQuery`
`{ granteeId?, partitionId?, active? }`.
### `interface GrantManagerEvents`
- `grant:created` — full grant
- `grant:revoked` — `{ id, partitionId, userId }`
## Implementation Notes
- The denial-reason strings are stable contract — dashboards may key on them. Renaming a reason is a major-version bump.
- `revoke` is intentionally idempotent so a duplicate "revoke this grant" message from a retry-loop does not trigger a second `grant:revoked` event.
- `list({ active: true })` reads the clock once per grant; for very large grant stores callers should prefer `list({ partitionId, active: true })` so the filter narrows before the active-check fires.
- The Zod schema (`CreateGrantOptionsSchema`) enforces non-empty `permissions`. An attacker who calls `create` with `permissions: []` is rejected at the boundary, never silently inserted.
- `check` runs the action verb through `permissions.includes(action as SharePermission)` — the cast is safe because every `PolicyAction` is also a valid `SharePermission` in the canonical list.

View File

@@ -0,0 +1,105 @@
# `sharing/tokens/tokens.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TokenManager` issues, validates, revokes, and lists revocable share tokens. Every token carries an HMAC-SHA-256 integrity tag computed under a per-instance master key over the canonical wire form (id || partitionId || createdBy || sorted permissions || createdAt || expiresAt). Validation re-derives the tag in constant time and rejects any tampering. Tokens have a configurable default TTL (24 h) overridable per call via `expiresIn`.
## Features
- Constant-time integrity verification via `constantTimeEqual` from `@suaveplan/crypto-utils`.
- Injectable `Clock` for deterministic expiry tests.
- Configurable `defaultTTL` per instance (`DEFAULT_TOKEN_TTL_MS = 24 h`) and per-call `expiresIn` override.
- Hard cap on active tokens (`TOKEN_MANAGER_MAX_ENTRIES = 10_000_000`).
- `EventBusLike` integration: `token:created` / `token:revoked` carry context but never the integrity tag itself.
- OTel: `share.token.create_total`, `share.token.validate_total`, `share.token.revoked_total`, plus `share.token.create` / `validate` spans.
## Usage: Basic Example
```ts
import { TokenManager } from "@suaveplan/drive-sharing/sharing/tokens";
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
const tokens = new TokenManager();
const token = tokens.create({
partitionId: partitionId("p"),
createdBy: userId("alice"),
permissions: ["read"],
});
const result = tokens.validate(token.id);
console.log(result.valid); // true
```
## Usage: Advanced Example
```ts
import { TokenManager } from "@suaveplan/drive-sharing/sharing/tokens";
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
let now = 0;
const events: Array<[string, unknown]> = [];
const masterKey = new Uint8Array(32).fill(0x42);
const tokens = new TokenManager({
clock: () => now,
defaultTTL: 60_000,
masterKey,
events: { on: () => () => {}, emit: (k, p) => events.push([k, p]) },
});
const token = tokens.create({
partitionId: partitionId("p"),
createdBy: userId("alice"),
permissions: ["read"],
expiresIn: 5_000,
});
now = 1_000;
console.log(tokens.validate(token.id).valid); // true
now = 6_001;
console.log(tokens.validate(token.id)); // { valid: false, reason: "expired" }
tokens.revoke(token.id);
console.log(tokens.validate(token.id)); // { valid: false, reason: "revoked" }
```
## API Reference
### `class TokenManager`
- `constructor(config?: TokenManagerConfig)`
- `create(options: CreateTokenOptions): ShareToken`
- `validate(id: TokenId): TokenValidationResult`
- `revoke(id: TokenId): void`
- `list(): ShareToken[]`
### `interface CreateTokenOptions`
`{ partitionId, createdBy, permissions, expiresIn? }`. `permissions` must be non-empty (Zod-enforced).
### `interface ShareToken`
`{ id, partitionId, createdBy, permissions, createdAt, expiresAt, revoked, integrity }`.
### `interface TokenValidationResult`
`{ valid, token?, reason? }` — `reason` is one of `not_found`, `revoked`, `expired`.
### `interface TokenManagerConfig`
`{ clock?, defaultTTL?, masterKey?, events?, idGenerator? }`. Default master key is a fresh 32-byte CSPRNG buffer; default TTL is 24 hours.
### `interface TokenManagerEvents`
- `token:created` — full token (including the integrity tag, since the consumer who created the token already knows it)
- `token:revoked` — `{ id, partitionId }`
## Implementation Notes
- The canonical wire form sorts `permissions` before serialising so two tokens with the same logical content but different array order produce identical integrity tags. This protects against a subtle bug where a downstream serializer reorders array elements.
- The HMAC key (`masterKey`) is held in instance state — it never appears in events, logs, or token bodies. Rotation is done by constructing a new `TokenManager`; the old one's tokens become invalid because the new master key produces different integrity tags.
- `validate` always recomputes the integrity tag, even when the token is revoked or expired. This is deliberate: an attacker who has tampered with the in-memory token map cannot evade detection by also setting `revoked = true`. (We DO short-circuit on `not_found`, since a missing token cannot have been tampered with.)
- The Zod schema rejects `expiresIn: 0` and negative values; these would produce already-expired tokens which are dead on arrival.
- `revoke` is idempotent so a duplicate revocation does not double-count `share.token.revoked_total`.

View File

@@ -0,0 +1,110 @@
# `transfer/transfer/transfer.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TransferManager` performs cross-partition file transfers. Given a source partition, target partition, file id, and user id, it resolves both KEKs via injected callbacks, retrieves the file's wrapped DEK (or plaintext DEK for the test path), validates source-and-target policies, rewraps the DEK atomically with `rewrapKey`, persists a `TransferRecord`, and returns the new `WrappedKey`. The whole flow is two-phase (resolve → validate → rewrap → record) with rollback on failure: if any step throws, no `TransferRecord` for the success path is created.
## Features
- AbortSignal support throughout: pre-aborted signals reject immediately, in-flight aborts reject with `SHARE_TRANSFER_FAILED` via a `Promise.race` against an abort listener.
- Two source modes: `wrappedKeyResolver` for production (rewrap without exposing plaintext) and `fileKeyStore` for tests (a `Map<FileId, Uint8Array>` of plaintext DEKs).
- Policy validation via `validateAction` from `partition-policy`. Source check is `share` (export) or `read` (import); target check is always `write`.
- OTel: `share.transfer` span, `share.transfer.duration_ms` histogram, `share.transfer.bytes_total` counter.
- Records persisted in-memory; `getRecord(id)` and `listRecords()` expose the audit trail.
## Usage: Basic Example
```ts
import { TransferManager } from "@suaveplan/drive-sharing/transfer";
import {
fileId,
partitionId,
userId,
} from "@suaveplan/drive-sharing/types/common";
const transfer = new TransferManager({
kekResolver: async () => new Uint8Array(32).fill(0xaa),
policyResolver: async (id) => ({
policy: { encryption: true, reauthRequired: [], allowedActions: ["read", "write", "share"] },
partition: {
id,
name: "demo",
type: "shared",
ownerId: userId("alice"),
members: [{ userId: userId("alice"), permissions: ["read", "write", "share"], addedAt: 0 }],
policy: { encryption: true, reauthRequired: [], allowedActions: ["read", "write", "share"] },
createdAt: 0,
updatedAt: 0,
},
}),
fileKeyStore: new Map([[fileId("f-1"), new Uint8Array(32).fill(0x10)]]),
});
const result = await transfer.transfer({
fileId: fileId("f-1"),
sourcePartitionId: partitionId("p-1"),
targetPartitionId: partitionId("p-2"),
userId: userId("alice"),
direction: "export",
});
console.log(result.success); // true
```
## Usage: Advanced Example
```ts
import { TransferManager } from "@suaveplan/drive-sharing/transfer";
const ac = new AbortController();
setTimeout(() => ac.abort(), 0);
await transfer.transfer({
fileId: fileId("f-1"),
sourcePartitionId: partitionId("p-1"),
targetPartitionId: partitionId("p-2"),
userId: userId("alice"),
direction: "export",
signal: ac.signal,
}); // throws DriveShareError(SHARE_TRANSFER_FAILED)
```
## API Reference
### `class TransferManager`
- `constructor(config: TransferConfig)`
- `transfer(options: TransferOptions): Promise<TransferResult>` — throws `DriveShareError` on any failure.
- `getRecord(id): TransferRecord | undefined`
- `listRecords(): TransferRecord[]`
### `interface TransferConfig`
`{ kekResolver, policyResolver, wrappedKeyResolver?, fileKeyStore?, crypto?, clock?, algorithm? }`.
### `interface TransferOptions`
`{ fileId, sourcePartitionId, targetPartitionId, userId, direction, signal? }`.
### `type KekResolver`
`(userId, partitionId) => Promise<Uint8Array>` — must throw if no KEK exists for the user/partition pair.
### `type PolicyResolver`
`(partitionId) => Promise<{ policy, partition }>` — returns both so the validator can check `maxMembers` and other partition-level state.
### `type WrappedKeyResolver`
`(fileId, partitionId) => Promise<WrappedKey | undefined>` — returns the wrapped DEK currently stored for the file in the given partition.
## Implementation Notes
- The abort handler is attached BEFORE the rewrap kicks off and removed in `finally`, so an abort that arrives between resolve and rewrap correctly fires.
- `runTransfer` is the work function; it never reads `options.signal` directly (the outer wrapper handles abort) except for an early `signal.aborted` short-circuit between resolve and rewrap. This bounds the wasted work to two policy resolves and one wrapped-key fetch.
- Source action verb: `share` for export, `read` for import. The intuition is "exporting" requires explicit grant/share authority on the source; "importing" only requires read access on the source plus write on the target.
- Failure path also persists a `TransferRecord` with `success: false` (id prefixed `fail:`) so dashboards can split successful and failed transfers.
- The `crypto?: CryptoProvider` config field is currently a hook for future custom providers; the production rewrap path uses the package's default provider via `rewrapKey`. Substituting a HSM-backed provider will require threading the override through the helper functions; this is documented as a follow-up in the spec's "Out of scope" section.
- The `wrappedKeyResolver` path is the primary security-hardened production path: plaintext DEK never leaves the rewrap function. The `fileKeyStore` path is convenient for tests where the caller already has the plaintext DEK in hand.

View File

@@ -0,0 +1,92 @@
# `types/common.ts`
## Purpose
This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the branded ID types, the wrapped-key wire shape, the partition / member / policy / grant / token / transfer record interfaces, and the `Result<T, E>` discriminated union helper. Centralising these declarations means downstream modules (encryption, partitions, sharing, transfer) consume one canonical type instead of drifting copies, and the public API surface is fully typed at every boundary.
## Features
- Six branded IDs (`UserId`, `PartitionId`, `GrantId`, `TokenId`, `FileId`, `KeyId`) implemented via the nominal `Brand<T, B>` helper.
- `WrappedKey` carries an algorithm tag plus a SHA-256 checksum so consumers can detect tampering without unwrapping.
- `EncryptedBundle` separates `nonce`, `ciphertext`, and (optional) `tag` so AEAD primitives that produce them as distinct outputs round-trip cleanly.
- `Result<T, E>` and the `ok` / `err` helpers provide a non-throwing return shape for predicates that prefer railway-style flow.
- `generateId` accepts an optional RNG for deterministic test runs and falls back to `crypto.randomUUID` (or RFC 4122 v4 if unavailable).
- `FileKeyStore` is a `Map<FileId, Uint8Array>` alias used by `TransferManager`.
## Usage: Basic Example
```ts
import {
type UserId,
userId,
type PartitionId,
partitionId,
ok,
err,
} from "@suaveplan/drive-sharing/types/common";
const u: UserId = userId("alice");
const p: PartitionId = partitionId("p-001");
const r = u === ("alice" as UserId) ? ok(u) : err(new Error("mismatch"));
```
## Usage: Advanced Example
```ts
import {
type Partition,
type PartitionPolicyConfig,
generateId,
partitionId,
userId,
} from "@suaveplan/drive-sharing/types/common";
// Deterministic IDs in tests via a stub RNG.
let counter = 0;
const stubRng = () => `id-${(counter += 1)}`;
const policy: PartitionPolicyConfig = {
encryption: true,
reauthRequired: ["admin"],
allowedActions: ["read", "write"],
maxMembers: 100,
};
const partition: Partition = {
id: partitionId(generateId(stubRng)),
name: "research",
type: "shared",
ownerId: userId("alice"),
members: [],
policy,
createdAt: 0,
updatedAt: 0,
};
```
## API Reference
### Branded IDs
`UserId`, `PartitionId`, `GrantId`, `TokenId`, `FileId`, `KeyId` — each `Brand<string, "...">`. Construct via the matching factory function.
### Factories
- `userId(value)`, `partitionId(value)`, `grantId(value)`, `tokenId(value)`, `fileId(value)`, `keyId(value)` — type-cast a raw `string` into the branded shape.
- `generateId(rng?: () => string): string` — returns a fresh ID. When `rng` is supplied its return value is used verbatim; otherwise `crypto.randomUUID` is preferred and an RFC 4122 v4 fallback is used in environments lacking it.
### `Result<T, E>`
Discriminated union. Helpers `ok(value)` and `err(error)` construct each arm.
### Wire types
`WrappedKey`, `EncryptedBundle`, `PartitionMember`, `PartitionPolicyConfig`, `Partition`, `PolicyValidationContext`, `PolicyValidationResult`, `TransferRecord`, `TransferResult`. Every shape is `readonly` and immutably typed.
## Implementation Notes
- All branded IDs are erased at runtime (`as UserId`); the brand exists only in the type system. This keeps the runtime cost zero while still preventing cross-domain misuse at compile time.
- `generateId` deliberately delegates to `crypto.randomUUID` when present so we benefit from the runtime CSPRNG; the manual fallback path is exercised only in legacy environments and is still implemented in terms of `crypto.getRandomValues` (never `Math.random()`).
- Every algorithm tag in `EncryptionAlgorithm` (`AES-GCM` / `XChaCha20-Poly1305` / `AES-KW`) is recognised by `envelopeEncrypt` and `wrapKey` — adding a new tag requires updating both modules.
- Policy fields use `readonly` arrays to prevent in-place mutation by downstream consumers; mutations must go through `PartitionManager.update`.

View File

@@ -0,0 +1,70 @@
# `types/schemas.ts`
## Purpose
This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired at the public API boundary of every module so the package raises a descriptive `ZodError` before any mutation is performed. Schemas double as the canonical reference for valid inputs and are reused by `tests/property.test.ts` to drive fuzz cases without re-declaring shapes.
## Features
- One schema per public option / config type: `WrappedKeySchema`, `PartitionPolicyConfigSchema`, `EnvelopeEncryptOptionsSchema`, `KeyRingConfigSchema`, `CreatePartitionOptionsSchema`, `AddMemberOptionsSchema`, `UpdatePartitionOptionsSchema`, `PartitionManagerOptionsSchema`, `CreateGrantOptionsSchema`, `CreateTokenOptionsSchema`, `TokenManagerConfigSchema`, `TransferOptionsSchema`, `KeyRingExportSchema`.
- `WrappedKeySchema` enforces a 64-hex-char SHA-256 checksum so corrupt entries cannot pass without explicit handling.
- `AadSchema` rejects empty AAD which is the central security guarantee for envelope encryption.
- `KeyRingExportSchema` pins the export wire format to `keyring_format_version: 1` so old or future versions are rejected at parse time.
- `PartitionPolicyConfigSchema` accepts only the five canonical action verbs and rejects unknown strings.
## Usage: Basic Example
```ts
import {
CreatePartitionOptionsSchema,
} from "@suaveplan/drive-sharing/types/schemas";
const result = CreatePartitionOptionsSchema.safeParse({
name: "research",
type: "shared",
ownerId: "alice",
});
if (!result.success) {
throw new Error(result.error.message);
}
```
## Usage: Advanced Example
```ts
import z from "zod";
import {
PartitionPolicyConfigSchema,
KeyRingExportSchema,
} from "@suaveplan/drive-sharing/types/schemas";
// Build a strict policy parser that auto-fills defaults.
const StrictPolicy = PartitionPolicyConfigSchema.transform((p) => ({
encryption: p.encryption,
reauthRequired: p.reauthRequired,
allowedActions: p.allowedActions,
maxMembers: p.maxMembers ?? 1000,
ttl: p.ttl ?? 0,
}));
// Validate an externally-supplied keyring backup file before importing.
function ingest(json: unknown): z.infer<typeof KeyRingExportSchema> {
return KeyRingExportSchema.parse(json);
}
```
## API Reference
Every export is a Zod schema instance. The schemas accept the public option types declared in `types/common.ts` and their narrower wire-format counterparts. Notable contracts:
- `AadSchema` — rejects empty `Uint8Array`.
- `WrappedKeySchema` — `algorithm` is one of `AES-GCM`, `XChaCha20-Poly1305`, `AES-KW`; `data` is a non-empty `Uint8Array`; `checksum` matches `/^[0-9a-f]{64}$/`.
- `PartitionPolicyConfigSchema` — `allowedActions` and `reauthRequired` are arrays of the canonical five verbs; unknown verbs raise `ZodError`.
- `KeyRingExportSchema` — `keyring_format_version` is the literal `1`.
## Implementation Notes
- The schemas in this module deliberately avoid `.passthrough()` — extra fields are stripped to prevent attackers from smuggling state into the package.
- `Uint8Array` checks rely on `z.instanceof(Uint8Array)` which works under both Node and the browser because the global is the same.
- The `partial()` schemas (used by `policy?: Partial<PartitionPolicyConfig>`) preserve the `readonly` nature of the parent type at runtime: missing fields stay missing rather than being filled with `undefined`.
- Adding a new field to a schema is non-breaking when the field is optional. Tightening an existing constraint is a major-version bump.

View File

@@ -0,0 +1,78 @@
# @suaveplan/drive-storage-archive-core - Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode enforcement, error code registry, and a conformance harness consumed by every per-format drive-storage adapter.
Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode enforcement, error code registry, and a conformance harness consumed by every per-format `drive-storage-*` adapter (zip, tar, gz, tgz, rar, 7z).
This package is the FOUNDATION layer — it does not itself read any archive. The per-format adapters live in sibling packages and depend on this one for their cross-cutting concerns.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/error-codes.md) — Declares the `ARCHIVE_CODES` registry — ten stable string identifiers covering every failure mode the archive core surf…
- [error-codes](./src/errors/error-codes.md) — Declares the `ARCHIVE_CODES` registry — ten stable string identifiers covering every failure mode the archive core surf…
- [factory](./src/factory/open-archive-storage.md) — Implements the public `openArchiveStorage(source, options)` factory plus the test-injection seam `setDynamicImporter`.…
- [open-archive-storage](./src/factory/open-archive-storage.md) — Implements the public `openArchiveStorage(source, options)` factory plus the test-injection seam `setDynamicImporter`.…
- [pending-mutations](./src/modes/pending-mutations.md) — Provides the `pendingMutations<T>()` factory and the `PendingMutations<T>` overlay interface that per-format archive ad…
- [validate-mode](./src/modes/validate-mode.md) — Provides `validateMode(mode, capabilities)` and `assertNotReadOnly(mode)` — the two helpers every per-format archive ad…
- [password](./src/password/password-hook.md) — Implements the small set of helpers every per-format archive adapter calls when handling encrypted entries: `wrapPasswo…
- [password-hook](./src/password/password-hook.md) — Implements the small set of helpers every per-format archive adapter calls when handling encrypted entries: `wrapPasswo…
- [rar-sibling-resolver](./src/resolvers/rar-sibling-resolver.md) — Default `ResolveSibling` factory for split-rar archives. Handles BOTH split-naming conventions transparently — the mode…
- [seven-zip-sibling-resolver](./src/resolvers/seven-zip-sibling-resolver.md) — Default `ResolveSibling` factory for split-7z archives following the canonical `<basename>.7z.NNN` naming convention. U…
- [sibling-host](./src/resolvers/sibling-host.md) — Declares the minimal `SiblingHost` structural-view interface that the default sibling-volume resolvers (`zipSiblingReso…
- [zip-sibling-resolver](./src/resolvers/zip-sibling-resolver.md) — Default `ResolveSibling` factory for split-zip archives following the PKZIP App-Note §8.5.3 convention: intermediate vo…
- [sniff](./src/sniff/sniff-format.md) — Identifies an archive's container format by inspecting magic bytes (and, where appropriate, structural artefacts like t…
- [sniff-format](./src/sniff/sniff-format.md) — Identifies an archive's container format by inspecting magic bytes (and, where appropriate, structural artefacts like t…
- [testing](./src/testing/run-archive-conformance.md) — Implements `runArchiveConformance(adapterFactory, opts)` — the cross-format conformance harness every per-format archiv…
- [run-archive-conformance](./src/testing/run-archive-conformance.md) — Implements `runArchiveConformance(adapterFactory, opts)` — the cross-format conformance harness every per-format archiv…
- [archive-capabilities](./src/types/archive-capabilities.md) — Declares the `ArchiveCapabilities` record shape that every per-format archive adapter MUST expose so consumers (and the…
- [archive-codec](./src/types/archive-codec.md) — Declares the `ArchiveCodec` interface — the contract a single byte-level compression codec (gzip, bzip2, xz, zstd…) MUS…
- [archive-format](./src/types/archive-format.md) — Declares the canonical `ArchiveFormat` string-literal union that names every archive container format the Suaveplan dri…
- [archive-mode](./src/types/archive-mode.md) — Declares the `ArchiveMode` string-literal union (`read-only`, `copy-on-write`, `rewrite`) that every per-format archive…
- [entry-reader](./src/types/entry-reader.md) — Declares the `EntryReader` interface that every per-format archive adapter implements internally to surface a single en…
- [open-archive-storage-options](./src/types/open-archive-storage-options.md) — Declares the option bag passed to `openArchiveStorage(source, options)` plus the supporting types used by the dynamic-i…
- [password-hook](./src/types/password-hook.md) — Declares the `PasswordHook` callback signature used by every archive adapter to fetch passwords lazily for encrypted en…
- [resolve-sibling](./src/types/resolve-sibling.md) — Declares the `ResolveSibling` callback contract that per-format archive adapters use to fetch additional split-volume b…
<!-- end:auto-docs -->
## Quick start
```ts
import { openArchiveStorage } from "@suaveplan/drive-storage-archive-core";
const adapter = await openArchiveStorage(zipBytes, {
format: "auto",
mode: "read-only",
password: async () => "correct horse battery staple",
});
console.log(adapter.mode); // "read-only"
```
## Modules
- `types/` — `ArchiveFormat`, `ArchiveMode`, `ArchiveCodec`, `ArchiveCapabilities`, `EntryReader`, `PasswordHook`, `ResolveSibling`, `OpenArchiveStorageOptions`.
- `sniff/` — `sniffFormat` + `sniffFormatWithReason` magic-byte detector covering ZIP, RAR4, RAR5, 7z, GZIP, USTAR tar.
- `factory/` — `openArchiveStorage(source, options)` dispatcher + `setDynamicImporter` test seam.
- `errors/` — `ARCHIVE_CODES` registry (10 stable identifiers) + `ArchiveError` subclass.
- `resolvers/` — default `SiblingHost`-backed resolvers for split-zip / split-rar / split-7z.
- `modes/` — `validateMode`, `assertNotReadOnly`, `pendingMutations<T>()` overlay with `TOMBSTONE`.
- `password/` — `wrapPasswordHook`, `assertPasswordHook`, `consumePassword`, `withPassword`.
- `testing/` (subpath export `./testing`) — `runArchiveConformance(adapterFactory, opts)` harness.
## Error codes
Ten stable identifiers via `defineCodes("ARCHIVE", [...])`:
- `ARCHIVE.FORMAT_UNKNOWN` — magic bytes did not match any known signature.
- `ARCHIVE.FORMAT_MISMATCH` — caller's declared extension disagrees with the sniffed format (warning).
- `ARCHIVE.SPLIT_MISSING_VOLUME` — `resolveSibling` returned `null` for a required volume.
- `ARCHIVE.PASSWORD_REQUIRED` — encrypted entry encountered without a hook.
- `ARCHIVE.PASSWORD_INVALID` — hook returned a value that failed authenticated decryption.
- `ARCHIVE.MODE_READ_ONLY` — mutation attempted on a read-only adapter.
- `ARCHIVE.FLUSH_REQUIRED` — adapter disposed with pending overlay mutations.
- `ARCHIVE.CORRUPT` — structural validation failed.
- `ARCHIVE.UNSUPPORTED_FEATURE` — archive uses a feature the adapter does not implement.
- `ARCHIVE.CODEC_MISSING` — composed adapter constructed without the required codec slot OR per-format package not installed.
## Status
`0.1.0` — initial bottom-up landing.

View File

@@ -0,0 +1,67 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the `ARCHIVE_CODES` registry — ten stable string identifiers covering every failure mode the archive core surfaces — and the `ArchiveError` class that wraps them. The codes are produced by `defineCodes("ARCHIVE", [...])` from `@suaveplan/error`, which concatenates the prefix and the member name into `"ARCHIVE.FORMAT_UNKNOWN"` etc. Each code is a non-reusable identifier: removing a member in a future release does NOT free its string for re-use because consumers may branch on the string value.
## Features
- Ten codes covering the entire core's failure surface (format detection, split volumes, password handling, mode enforcement, structural integrity, codec missing).
- `ArchiveError` subclass narrows `code` to `ArchiveErrorCode` via the generic type parameter on `SuaveplanError`.
- Public constructor so callers can construct errors directly (e.g. test fixtures, conformance harness).
## Usage: Basic Example
```ts
import { ARCHIVE_CODES, ArchiveError } from "@suaveplan/drive-storage-archive-core";
throw new ArchiveError(
ARCHIVE_CODES.MODE_READ_ONLY,
"archive opened in read-only mode; write rejected",
);
```
## Usage: Advanced Example
```ts
import {
ARCHIVE_CODES,
ArchiveError,
type ArchiveErrorCode,
} from "@suaveplan/drive-storage-archive-core";
function isRecoverable(err: unknown): err is ArchiveError {
if (!(err instanceof ArchiveError)) return false;
const recoverable: ArchiveErrorCode[] = [
ARCHIVE_CODES.PASSWORD_INVALID,
ARCHIVE_CODES.PASSWORD_REQUIRED,
];
return recoverable.includes(err.archiveCode);
}
try {
/* ... */
} catch (err) {
if (isRecoverable(err)) {
// prompt for a new password
} else {
throw err;
}
}
```
## API Reference
- `const ARCHIVE_CODES` — `defineCodes("ARCHIVE", [...])` registry of ten codes.
- `type ArchiveErrorCode` — union of every stable code value.
- `class ArchiveError extends SuaveplanError<ArchiveErrorCode>` — concrete throwable.
- `public readonly archiveCode: ArchiveErrorCode` — code mirror for inspection.
- `public constructor(code, message, options?)`.
## Implementation Notes
The redundant `archiveCode` field is intentional — it both mirrors `code` (which is typed as the union on `SuaveplanError<T>`) for ergonomic access in catch blocks AND gives the constructor body a non-trivial statement so lint rule `noUselessConstructor` does not fire. The pattern matches `SseError` and other newer Suaveplan error subclasses. Removing the field would require a `// biome-ignore` comment which CLAUDE.md rule 9 forbids in new code. The ten codes match the spec exactly — adding an eleventh requires both a spec change and a design proposal.

View File

@@ -0,0 +1,71 @@
# open-archive-storage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the public `openArchiveStorage(source, options)` factory plus the test-injection seam `setDynamicImporter`. The factory is the single entry point for mounting any archive format: it sniffs (when `format === "auto"`), emits an `ARCHIVE_FORMAT_MISMATCH` warning if a declared extension disagrees with the detected format, dynamic-imports the matching `@suaveplan/drive-storage-${format}` package, and forwards `(source, resolvedOptions)` to the package's exported `archiveAdapterFactory`. The factory returns whatever the adapter returns — typically a `DriveStorage`-shaped object with `flush()` and `mode` properties.
## Features
- Magic-byte format detection via `sniffFormat` against the first 512 bytes of a `Uint8Array` source.
- Optional `ARCHIVE_FORMAT_MISMATCH` warning when the caller's `declaredExtension` disagrees with the sniffed format.
- Dynamic-import dispatch keyed by format name (`@suaveplan/drive-storage-zip`, etc.).
- Test-injection seams: `options.__formatRegistry` for inline fake adapters, `setDynamicImporter(fn)` for swapping the underlying `import()` call.
- Typed errors: `ARCHIVE_FORMAT_UNKNOWN` when sniff fails AND `CODEC_MISSING` when the per-format package is not installed.
## Usage: Basic Example
```ts
import { openArchiveStorage } from "@suaveplan/drive-storage-archive-core";
const adapter = await openArchiveStorage(zipBytes, {
format: "auto",
mode: "read-only",
});
console.log(adapter.mode);
```
## Usage: Advanced Example
```ts
import {
openArchiveStorage,
setDynamicImporter,
type ArchiveAdapterFormatRegistry,
} from "@suaveplan/drive-storage-archive-core";
const registry: ArchiveAdapterFormatRegistry = {
zip: async (_source, opts) => ({
mode: opts.mode,
async flush() {
/* test stub */
},
}),
};
const restore = setDynamicImporter(async (_spec) => {
throw new Error("real dynamic import disabled in this test");
});
try {
const adapter = await openArchiveStorage(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), {
format: "zip",
__formatRegistry: registry,
});
console.log(adapter.mode);
} finally {
restore();
}
```
## API Reference
- `openArchiveStorage(source, options?): Promise<ArchiveStorageHandle>` — main factory.
- `setDynamicImporter(impl): () => void` — replace the underlying `import()` implementation; returns a restorer.
- `type OpenArchiveSource` — `Uint8Array | ReadableStream<Uint8Array> | unknown`.
## Implementation Notes
The factory deliberately refuses to drain a `ReadableStream` for sniffing — `sniffFormat` is synchronous and accepts only `Uint8Array`. When the caller passes a stream AND `format === "auto"`, the factory raises `ARCHIVE_FORMAT_UNKNOWN` rather than buffer-the-whole-stream-then-sniff. Callers that own the underlying bytes should pass the buffer; callers that genuinely have a stream MUST pass an explicit `format`. The dispatch table is a static `Record<ArchiveFormat, string>` keyed by format → package specifier so the dynamic import call site is a single statement that a bundler can trace. When the per-format package is not installed, `await import(specifier)` rejects with `MODULE_NOT_FOUND`; the factory rewraps it as `ARCHIVE_CODEC_MISSING` so callers see a stable typed error.

View File

@@ -0,0 +1,81 @@
# pending-mutations
## Purpose
Provides the `pendingMutations<T>()` factory and the `PendingMutations<T>` overlay interface that per-format archive adapters use to buffer writes and deletes in `copy-on-write` mode. The overlay distinguishes a pending write (path → bytes) from a pending delete (path → `TOMBSTONE` sentinel) so the adapter's `flush()` path can rebuild the archive deterministically, applying additions and removals in a single pass. Also exports `TOMBSTONE`, the unique-symbol marker that signals deletion, and an `assertEmpty()` helper that raises `ARCHIVE_FLUSH_REQUIRED` when an adapter is disposed without flushing.
## Features
- Explicit deletion tracking via a unique-symbol tombstone — adapters cannot confuse "absent from overlay" with "deleted in overlay".
- O(1) `set`/`delete`/`has`/`get` backed by a Map.
- `assertEmpty` integration with the FLUSH_REQUIRED error code so disposal-without-flush is a typed error consumers can catch.
- Generic over the value type — defaults to `Uint8Array` but adapters that need a structured payload (e.g. tar's `{ bytes, mtime }`) can specialise.
## Usage: Basic Example
```ts
import { pendingMutations, TOMBSTONE } from "@suaveplan/drive-storage-archive-core";
const overlay = pendingMutations();
overlay.set("notes.txt", new TextEncoder().encode("hello"));
overlay.delete("old.bin");
console.log(overlay.size); // 2
const out = overlay.get("old.bin");
if (out === TOMBSTONE) console.log("deleted");
```
## Usage: Advanced Example
```ts
import {
pendingMutations,
TOMBSTONE,
type PendingMutations,
} from "@suaveplan/drive-storage-archive-core";
class CopyOnWriteAdapter {
readonly #pending: PendingMutations = pendingMutations();
async write(path: string, bytes: Uint8Array): Promise<void> {
this.#pending.set(path, bytes);
}
async delete(path: string): Promise<boolean> {
this.#pending.delete(path);
return true;
}
async flush(): Promise<void> {
for (const [path, value] of this.#pending.entries()) {
if (value === TOMBSTONE) {
/* drop entry in re-encoded archive */
} else {
/* write new entry */
}
}
this.#pending.clear();
}
async dispose(): Promise<void> {
this.#pending.assertEmpty(); // throws ARCHIVE_FLUSH_REQUIRED if anything pending
}
}
```
## API Reference
- `pendingMutations<T = Uint8Array>(): PendingMutations<T>` — factory.
- `interface PendingMutations<T>`
- `set(path: string, value: T): void`
- `delete(path: string): void`
- `has(path: string): boolean`
- `get(path: string): T | typeof TOMBSTONE | undefined`
- `readonly size: number`
- `entries(): IterableIterator<[string, T | typeof TOMBSTONE]>`
- `clear(): void`
- `assertEmpty(): void` — throws `ARCHIVE_FLUSH_REQUIRED` when overlay non-empty.
- `const TOMBSTONE: unique symbol` — sentinel for pending deletes.
## Implementation Notes
The overlay is intentionally a flat Map rather than a tree. Archive entry paths are stored verbatim as keys; adapters that need hierarchical introspection iterate `entries()` and split on `/`. The tombstone is a `unique symbol` (not a string) so it cannot collide with any user-supplied path even if a malicious archive contained a literal entry named `"TOMBSTONE"`. `assertEmpty` is intentionally idempotent — calling it twice on an empty overlay does not throw — so disposal paths that mix it with other cleanup steps stay simple.

View File

@@ -0,0 +1,64 @@
# validate-mode
## Purpose
Provides `validateMode(mode, capabilities)` and `assertNotReadOnly(mode)` — the two helpers every per-format archive adapter calls at the boundary between user input and adapter state. `validateMode` checks that a requested mode is present in the adapter's static `ArchiveCapabilities.modes` array and throws `ARCHIVE_UNSUPPORTED_FEATURE` otherwise. `assertNotReadOnly` checks that the current mode is not `read-only` and throws `ARCHIVE_MODE_READ_ONLY` otherwise; adapters call this from every mutation entry point (`set`, `delete`, `setPartial`).
## Features
- Single source of truth for mode-validation error messages — every adapter throws the same typed error with the same shape.
- `validateMode` runs at construction time; `assertNotReadOnly` runs at mutation time. The two cover the full lifecycle.
- Error messages enumerate the supported modes so the user sees which mode they should have chosen.
## Usage: Basic Example
```ts
import { assertNotReadOnly, validateMode } from "@suaveplan/drive-storage-archive-core";
const caps = {
format: "zip" as const,
modes: ["read-only", "copy-on-write"] as const,
readsEncrypted: true,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
};
validateMode("copy-on-write", caps); // ok
```
## Usage: Advanced Example
```ts
import {
assertNotReadOnly,
validateMode,
type ArchiveMode,
} from "@suaveplan/drive-storage-archive-core";
class StubAdapter {
constructor(private readonly mode: ArchiveMode) {
validateMode(mode, {
format: "zip",
modes: ["read-only", "copy-on-write", "rewrite"],
readsEncrypted: false,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
});
}
write(_key: string, _value: Uint8Array): void {
assertNotReadOnly(this.mode);
// ... real write path ...
}
}
```
## API Reference
- `validateMode(mode: ArchiveMode, capabilities: ArchiveCapabilities): void` — throws `ARCHIVE_UNSUPPORTED_FEATURE` when `mode` is absent from `capabilities.modes`.
- `assertNotReadOnly(mode: ArchiveMode): void` — throws `ARCHIVE_MODE_READ_ONLY` when `mode === "read-only"`.
## Implementation Notes
`validateMode` and `assertNotReadOnly` are intentionally split because they fire at different lifecycle points: `validateMode` is a one-time check at adapter construction (a constant cost per adapter), while `assertNotReadOnly` runs on every mutation call (the cost adds up under load). Keeping them separate lets the v8 inliner specialise the hot mutation path without dragging in the capability-record lookup. Both throw `ArchiveError` instances with stable error codes so consumers can `instanceof ArchiveError` and branch on `archiveCode` — the codes ARE the API contract for these checks.

View File

@@ -0,0 +1,72 @@
# password-hook (helpers)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the small set of helpers every per-format archive adapter calls when handling encrypted entries: `wrapPasswordHook(hook?)` (enforces a defensive NFC-normalised copy on every invocation so an adapter cannot accidentally share the host's string reference), `assertPasswordHook(hook?)` (throws `ARCHIVE_PASSWORD_REQUIRED` when an encrypted entry is encountered without a configured hook), `consumePassword(hook)` (yields a release-able `PasswordHandle` for a single decrypt operation), and `withPassword(hook, fn)` (the higher-level try/finally wrapper that pairs `consumePassword` with automatic release on every exit path).
## Features
- Defensive copy via `String.prototype.normalize("NFC")` on every wrapped-hook call — adapters cannot leak the host's string identity.
- Typed `ARCHIVE_PASSWORD_REQUIRED` / `ARCHIVE_PASSWORD_INVALID` errors so consumers branch on stable codes.
- `release()` drops the wrapper's reference to the password as soon as the active decrypt scope completes — the GC can then reclaim the original allocation.
- `withPassword` provides try/finally semantics so adapters do not have to write the boilerplate themselves.
## Usage: Basic Example
```ts
import {
assertPasswordHook,
withPassword,
} from "@suaveplan/drive-storage-archive-core";
async function decryptEntry(hook: (() => Promise<string>) | undefined): Promise<Uint8Array> {
assertPasswordHook(hook); // throws ARCHIVE_PASSWORD_REQUIRED if undefined
return withPassword(hook, async (password) => {
// ... derive key, decrypt, return bytes ...
return new TextEncoder().encode(`pw len: ${password.length}`);
});
}
```
## Usage: Advanced Example
```ts
import {
consumePassword,
wrapPasswordHook,
type PasswordHook,
} from "@suaveplan/drive-storage-archive-core";
const hostHook: PasswordHook = async () => "correct horse battery staple";
const wrapped = wrapPasswordHook(hostHook);
if (wrapped !== undefined) {
const handle = await consumePassword(wrapped);
try {
console.log(handle.value.length);
} finally {
handle.release();
}
// Subsequent access throws ARCHIVE_PASSWORD_INVALID.
try {
console.log(handle.value);
} catch (err) {
console.error("expected:", (err as Error).message);
}
}
```
## API Reference
- `wrapPasswordHook(hook: PasswordHook | undefined): PasswordHook | undefined` — defensive-copy wrapper.
- `assertPasswordHook(hook: PasswordHook | undefined): asserts hook is PasswordHook` — throws when undefined.
- `consumePassword(hook: PasswordHook): Promise<PasswordHandle>` — yield a single-use handle.
- `withPassword<T>(hook: PasswordHook, fn: (pw: string) => Promise<T>): Promise<T>` — scoped helper with automatic release.
- `interface PasswordHandle { readonly value: string; release(): void }`.
## Implementation Notes
JavaScript strings are immutable, so `release()` cannot actually overwrite the bytes of the resolved password in the engine's heap. The strongest scrub the language permits is dropping every reference and letting the GC reclaim the allocation — which is what `release()` does (it sets the closure-captured `stored` to `undefined`). The NFC normalisation in `wrapPasswordHook` is the cheapest way to force a new String allocation distinct from the host's input. `withPassword` is the recommended entry point for adapters because it makes the release path automatic; `consumePassword` exists for adapters that need to thread the handle across multiple async ops (e.g. interleaved decrypt + verify).

View File

@@ -0,0 +1,44 @@
# rar-sibling-resolver
## Purpose
Default `ResolveSibling` factory for split-rar archives. Handles BOTH split-naming conventions transparently — the modern RAR5 / WinRAR ≥ 3 `<basename>.partN.rar` series AND the legacy RAR4 `<basename>.rar` + `<basename>.r00`..`.r99` series — by inspecting the primary-volume key to choose the scheme. RAR5 detection is purely syntactic (the key matches `\.partN\.rar$`); any other input is treated as a RAR4 primary. The resolver wraps a `SiblingHost` view of the underlying storage and returns a `ResolveSibling` closure that the per-format rar adapter calls with `(basename, index)`.
## Features
- RAR5: index 0 → `.part1.rar`, 1 → `.part2.rar`, ..., N → `.part(N+1).rar`.
- RAR4: index 0 → `.rar` (the primary itself), 1 → `.r00`, 2 → `.r01`, ..., 100 → `.r99`.
- Single resolver implementation; scheme is chosen once at factory time based on the primary key.
- Returns `null` for missing volumes per the `ResolveSibling` contract.
## Usage: Basic Example
```ts
import { rarSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const resolver = rarSiblingResolver(host, "movie.part1.rar");
const vol0 = await resolver("movie", 0); // → host.get("movie.part1.rar")
const vol1 = await resolver("movie", 1); // → host.get("movie.part2.rar")
```
## Usage: Advanced Example
```ts
import { rarSiblingResolver } from "@suaveplan/drive-storage-archive-core";
// Legacy RAR4 split with 5 volumes.
const legacy = rarSiblingResolver(host, "backup.rar");
const v0 = await legacy("backup", 0); // → host.get("backup.rar")
const v1 = await legacy("backup", 1); // → host.get("backup.r00")
const v2 = await legacy("backup", 2); // → host.get("backup.r01")
// ...
const v4 = await legacy("backup", 4); // → host.get("backup.r03")
```
## API Reference
- `rarSiblingResolver(host: SiblingHost, primaryKey: string): ResolveSibling` — returns a closure that maps a 0-based volume index to host-storage bytes.
## Implementation Notes
The RAR4 scheme is intentionally awkward — the primary volume is `.rar`, but every subsequent volume is `.r00`, `.r01`, etc. This means RAR4 volume `N` for `N > 0` corresponds to extension `.r{N-1:02}`. The resolver collapses that into one branch: `index === 0 ? '.rar' : '.r{NN}'`. The RAR5 detection regex anchors on `.partN.rar` (one or more digits between `.part` and `.rar`) so a file named `movie.party.rar` will NOT be misclassified as RAR5; it falls back to the RAR4 branch. The `stripTrailingExtension` helper is shared with the zip resolver so the basename normalisation is identical across formats.

View File

@@ -0,0 +1,49 @@
# seven-zip-sibling-resolver
## Purpose
Default `ResolveSibling` factory for split-7z archives following the canonical `<basename>.7z.NNN` naming convention. Unlike split-zip (which has a special-cased `.zip` terminal) or split-rar (which has two competing schemes), 7z splits use a single, regular series: `.7z.001`, `.7z.002`, ..., `.7z.NNN` with 3-digit zero-padded numbering starting at `001`. The resolver wraps a `SiblingHost` view of the underlying storage and returns a closure the per-format 7z adapter calls with `(basename, index)`.
## Features
- Single regular naming convention — no terminal-vs-intermediate special case.
- 3-digit zero-padded numbering starting at `001`; volume index N maps to `.7z.{(N+1):03}`.
- Returns `null` for missing volumes per the `ResolveSibling` contract.
- Shares `stripTrailingExtension` with the zip and rar resolvers so basename normalisation is consistent across formats.
## Usage: Basic Example
```ts
import { sevenZipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const resolver = sevenZipSiblingResolver(host, "dataset.7z.001");
const v0 = await resolver("dataset", 0); // → host.get("dataset.7z.001")
const v1 = await resolver("dataset", 1); // → host.get("dataset.7z.002")
```
## Usage: Advanced Example
```ts
import { sevenZipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
async function collectAll(primaryKey: string): Promise<Uint8Array[]> {
const resolver = sevenZipSiblingResolver(host, primaryKey);
const volumes: Uint8Array[] = [];
for (let i = 0; ; i++) {
const bytes = await resolver("dataset", i);
if (bytes === null) break;
if (bytes instanceof Uint8Array) {
volumes.push(bytes);
}
}
return volumes;
}
```
## API Reference
- `sevenZipSiblingResolver(host: SiblingHost, primaryKey: string): ResolveSibling` — returns a closure mapping a 0-based volume index to host-storage bytes.
## Implementation Notes
The 7z split convention does NOT use the unsplit `.7z` filename as a terminal. A split archive `dataset.7z.001..003` does not have a `dataset.7z` companion; the first volume is `.7z.001` and the trailing header lives in the last `.7z.NNN`. That means the resolver does not need a fallback path the way the zip resolver does. The 3-digit padding lets a single archive grow to 999 volumes — beyond that, the 7z spec is silent and consumer tools (7-Zip, PeaZip) all roll over to 4-digit padding, which the resolver does NOT support; callers that need 1000+ volumes must provide a custom resolver. Returning `null` for missing volumes lets the adapter raise `ARCHIVE_SPLIT_MISSING_VOLUME` with the precise missing index.

View File

@@ -0,0 +1,57 @@
# sibling-host
## Purpose
Declares the minimal `SiblingHost` structural-view interface that the default sibling-volume resolvers (`zipSiblingResolver`, `rarSiblingResolver`, `sevenZipSiblingResolver`) consume to fetch additional split-archive volumes. The view is intentionally tiny — just `get(key)` and `exists(key)` — so the same resolver implementations work over any of the host shapes the drive ecosystem exposes: `AbstractStorage`, `DriveStorage`, a plain `Map`-backed test fixture, or an HTTP-backed adapter.
## Features
- Structural typing — any object with the two methods satisfies the contract; no inheritance, no module-name coupling.
- Only the methods the resolvers actually need — `list`/`stat`/`delete` are deliberately absent so test doubles stay small.
- Works for sync OR async backends because both methods return Promises.
## Usage: Basic Example
```ts
import type { SiblingHost } from "@suaveplan/drive-storage-archive-core";
const store = new Map<string, Uint8Array>();
const host: SiblingHost = {
async get(key) {
return store.get(key);
},
async exists(key) {
return store.has(key);
},
};
```
## Usage: Advanced Example
```ts
import type { SiblingHost } from "@suaveplan/drive-storage-archive-core";
function httpHost(baseUrl: string): SiblingHost {
return {
async get(key) {
const res = await fetch(`${baseUrl}/${key}`);
if (res.status === 404) return undefined;
return new Uint8Array(await res.arrayBuffer());
},
async exists(key) {
const res = await fetch(`${baseUrl}/${key}`, { method: "HEAD" });
return res.ok;
},
};
}
```
## API Reference
- `interface SiblingHost`
- `get(key: string): Promise<Uint8Array | undefined>` — returns volume bytes or `undefined` when absent.
- `exists(key: string): Promise<boolean>` — fast presence check (resolvers call this before `get` for non-final volumes).
## Implementation Notes
The view returns `undefined` (not `null`) from `get` to match `AbstractStorage.get`'s existing contract — that lets a `DriveStorage` value pass directly as a `SiblingHost` without an adapter wrapper. The resolvers translate `undefined` into the `null` value that the `ResolveSibling` contract expects, so callers of the resolver are insulated from the `undefined`/`null` distinction. `exists` is separate from `get` so HTTP-backed hosts can use a HEAD probe instead of fetching a multi-MiB volume just to discover it is missing — that matters for split-archive resolution where 6 of 7 volumes are present and one is not.

View File

@@ -0,0 +1,56 @@
# zip-sibling-resolver
## Purpose
Default `ResolveSibling` factory for split-zip archives following the PKZIP App-Note §8.5.3 convention: intermediate volumes are `<basename>.z01`, `.z02`, ..., `.zNN` and the terminal volume (the one holding the End-of-Central-Directory record) is `<basename>.zip`. The resolver wraps a `SiblingHost` view of the underlying storage and returns a closure that the per-format zip adapter calls with `(basename, index)`. Also exports `stripTrailingExtension` — a small helper that normalises a primary-volume key to its basename by stripping any known archive extension (zip / rar / 7z variants).
## Features
- PKZIP-conventional naming: intermediate volumes use `.zNN` with 2-digit zero-padded numbering starting at `01`, terminal uses `.zip`.
- Falls back to the `.zip` terminal when an intermediate key is absent — the adapter discovers the final volume on its own request without an explicit signal.
- Returns `null` (per `ResolveSibling` contract) when neither volume exists so the adapter can raise `ARCHIVE_SPLIT_MISSING_VOLUME` with the missing index.
- `stripTrailingExtension` covers every extension the archive family understands (zip / partN.rar / r00 / rar / 7z / 7z.NNN) so a single helper handles all three resolvers.
## Usage: Basic Example
```ts
import { zipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const host = {
async get(key: string) {
return store.get(key);
},
async exists(key: string) {
return store.has(key);
},
};
const resolver = zipSiblingResolver(host, "bundle.zip");
const volume0 = await resolver("bundle", 0); // → store.get("bundle.z01")
```
## Usage: Advanced Example
```ts
import {
stripTrailingExtension,
zipSiblingResolver,
} from "@suaveplan/drive-storage-archive-core";
const primary = "shared/archive-2026.zip";
const basename = stripTrailingExtension(primary); // "shared/archive-2026"
const resolver = zipSiblingResolver(host, primary);
for (let i = 0; ; i++) {
const bytes = await resolver(basename, i);
if (bytes === null) break;
// ... feed to the zip adapter ...
}
```
## API Reference
- `zipSiblingResolver(host: SiblingHost, primaryKey: string): ResolveSibling` — returns a sibling-resolver closure for split-zip archives.
- `stripTrailingExtension(key: string): string` — strips any known archive extension from a host-storage key.
## Implementation Notes
The fallback to `.zip` after a missing `.zNN` is intentional and matches how every real-world split-zip implementation lays out files: zip64 spec puts the EOCD in the final volume and that volume is always `.zip`. The two-step lookup keeps the resolver stateless — it does not pre-compute the volume count from the primary's EOCD because the resolver is invoked BEFORE the adapter has parsed the EOCD. The `stripTrailingExtension` helper uses ordered alternatives because `.partN.rar` and `.rar` both end with `.rar`; the longer pattern is checked first to avoid a false positive that would leave `.partN` in the basename.

View File

@@ -0,0 +1,52 @@
# sniff-format
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Identifies an archive's container format by inspecting magic bytes (and, where appropriate, structural artefacts like the ZIP End-of-Central-Directory record or the tar header checksum) in the first ~512 bytes of a buffer. Returns `null` when nothing matches so the caller can fall back to caller-declared format or surface `ARCHIVE_FORMAT_UNKNOWN`. The accompanying `sniffFormatWithReason` variant returns the same identification PLUS a short human-readable description of which check succeeded — useful for debug logging and the `ARCHIVE_FORMAT_MISMATCH` warning emitted by `openArchiveStorage`.
## Features
- Six formats: ZIP, RAR4, RAR5, 7z, GZIP, POSIX/USTAR tar.
- Magic-byte match plus structural validation (ZIP requires EOCD locatable in the trailing 64 KiB; tar requires the USTAR magic at offset 257 AND the octal checksum at bytes 148-156 to match the block sum).
- ZIP detection accepts both the local-file-header signature `PK\x03\x04` and the split-marker `PK\x07\x08` so a split-zip's first volume is identified correctly even without an EOCD.
- RAR5 is tested before RAR4 because the 5-byte prefix is shared.
## Usage: Basic Example
```ts
import { sniffFormat } from "@suaveplan/drive-storage-archive-core";
const fmt = sniffFormat(bytes);
if (fmt === null) {
console.log("unknown format");
} else {
console.log(`detected ${fmt}`);
}
```
## Usage: Advanced Example
```ts
import { sniffFormatWithReason } from "@suaveplan/drive-storage-archive-core";
const { format, reason } = sniffFormatWithReason(bytes);
if (format === null) {
console.warn(`sniff failed: ${reason}`);
} else {
console.log(`format=${format} via ${reason}`);
}
```
## API Reference
- `sniffFormat(bytes: Uint8Array): ArchiveFormat | null` — returns the detected format or `null`.
- `sniffFormatWithReason(bytes: Uint8Array): SniffResult` — `{ format, reason }`.
- `interface SniffResult { readonly format: ArchiveFormat | null; readonly reason: string }`.
## Implementation Notes
ZIP detection runs a backward scan from end-of-buffer for the 4-byte EOCD signature `PK\x05\x06`, bounded at 65 557 bytes (22-byte EOCD record + max 0xFFFF comment length). When the magic matches but the EOCD is not located, the sniff still returns `"zip"` with a reason indicating the buffer is likely a non-final split volume — the per-format adapter will reject it with a clearer error if it cannot reconstruct the central directory. The tar checksum routine sums every byte of the 512-byte block treating bytes 148-156 as ASCII spaces (0x20) per RFC. The function MUST NOT mutate the input buffer — callers pass `subarray(0, 512)` views routinely. Empty buffers short-circuit to `{ format: null, reason: "empty buffer" }` so test harnesses can probe the negative path without allocating a one-byte sentinel.

View File

@@ -0,0 +1,84 @@
# run-archive-conformance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `runArchiveConformance(adapterFactory, opts)` — the cross-format conformance harness every per-format archive adapter MUST pass. The harness exercises the behaviours every adapter shares: open + list + stat + read of a plaintext entry, mode enforcement (`read-only` rejects every mutation with `ARCHIVE_MODE_READ_ONLY`), optional password failure path (`ARCHIVE_PASSWORD_REQUIRED` when no hook), optional split-volume reassembly, and an optional round-trip of one entry through write + flush + read for writable adapters. The harness imports `describe`/`it`/`expect` from `@suaveplan/testing/runner` only (rule 11).
## Features
- Per-format packages call the harness from INSIDE their own `describe` block so the behaviours appear as nested `it`s under the caller's description — the harness does NOT call `describe` at the top level.
- Optional fixtures (`encrypted`, `split`, `writable`) let formats opt-in to the surfaces they actually support. Tar can skip `encrypted`; gz can skip `split`; rar can declare `readOnlyOnly: true`.
- Asserts against the public typed errors (`ARCHIVE_CODES.MODE_READ_ONLY`, `ARCHIVE_CODES.PASSWORD_REQUIRED`) so adapter implementations stay decoupled from the harness internals.
## Usage: Basic Example
```ts
import { describe } from "@suaveplan/testing/runner";
import { runArchiveConformance } from "@suaveplan/drive-storage-archive-core/testing";
describe("ZipArchiveStorage conformance", () => {
runArchiveConformance(
async (fixture, mode) => {
// construct a real ZipArchiveStorage(...)
return adapter;
},
{
plaintext: {
bytes: zipBytes,
entryPath: "hello.txt",
entryBytes: new TextEncoder().encode("hello"),
},
},
);
});
```
## Usage: Advanced Example
```ts
import { describe } from "@suaveplan/testing/runner";
import { runArchiveConformance } from "@suaveplan/drive-storage-archive-core/testing";
describe("RarArchiveStorage conformance", () => {
runArchiveConformance(
async (fixture, mode) => new RarArchiveStorage(fixture.bytes, { mode }),
{
plaintext: {
bytes: rarBytes,
entryPath: "data/file.bin",
entryBytes: blob,
},
encrypted: {
bytes: rarBytesEncrypted,
entryPath: "secret.txt",
correctPassword: "correct horse battery staple",
},
split: {
primary: part1,
siblings: new Map([["movie.part2.rar", part2]]),
basename: "movie",
entryPath: "movie.mp4",
entryBytes: movieBlob,
},
readOnlyOnly: true, // RAR is read-only
},
);
});
```
## API Reference
- `runArchiveConformance(adapterFactory, opts): void` — main entry point.
- `interface ConformanceAdapter` — structural minimum surface every adapter satisfies.
- `type ConformanceAdapterFactory = (fixture, mode) => Promise<ConformanceAdapter>`.
- `interface ConformanceFixtures` — `plaintext` required; `encrypted`, `split`, `writable` optional.
- `interface RunArchiveConformanceOptions extends ConformanceFixtures` — adds `writableFactory` and `readOnlyOnly`.
- Re-exports `ARCHIVE_CODES` and `ArchiveError` for caller convenience.
## Implementation Notes
The harness imports `describe`/`it`/`expect` from `@suaveplan/testing/runner` exclusively (CLAUDE.md rule 11). It deliberately avoids `vi.mock`/`jest.mock`/etc. — all adapters under test are real implementations exercising real bytes (rule 2). Per-format suites can layer their own format-specific `describe` blocks above the call to `runArchiveConformance`; the harness's own `describe("archive conformance")` nests cleanly inside. The optional fixtures are checked with `if (opts.encrypted !== undefined)` rather than truthy-check so a fixture with explicit `bytes: new Uint8Array(0)` is still honoured.

View File

@@ -0,0 +1,61 @@
# archive-capabilities
## Purpose
Declares the `ArchiveCapabilities` record shape that every per-format archive adapter MUST expose so consumers (and the core's mode-validator) can introspect what an adapter is willing to do BEFORE attempting to construct it or call an operation that would throw. This is the static-discovery counterpart to runtime mode enforcement: an adapter that refuses `rewrite` mode declares `modes: ["read-only", "copy-on-write"]` here, and `validateMode` short-circuits with a typed error instead of letting the adapter throw deep inside its write path.
## Features
- Static discovery: read once at adapter construction, not on every call.
- Includes a `format` field so a flat list of capabilities can be filtered by format without re-keying the structure.
- Splits read-encrypted from write-encrypted so AES-read / AES-write-deferred adapters (the ZIP plan) can model the asymmetry honestly.
## Usage: Basic Example
```ts
import type { ArchiveCapabilities } from "@suaveplan/drive-storage-archive-core";
const zipCaps: ArchiveCapabilities = {
format: "zip",
modes: ["read-only", "copy-on-write", "rewrite"],
readsEncrypted: true,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
};
```
## Usage: Advanced Example
```ts
import {
type ArchiveCapabilities,
type ArchiveMode,
validateMode,
} from "@suaveplan/drive-storage-archive-core";
function pickMode(caps: ArchiveCapabilities, desired: ArchiveMode): ArchiveMode {
try {
validateMode(desired, caps);
return desired;
} catch {
// Fall back to the strongest mode the adapter supports.
if (caps.modes.includes("copy-on-write")) return "copy-on-write";
return "read-only";
}
}
```
## API Reference
- `interface ArchiveCapabilities`
- `readonly format: ArchiveFormat`
- `readonly modes: ReadonlyArray<ArchiveMode>`
- `readonly readsEncrypted: boolean`
- `readonly writesEncrypted: boolean`
- `readonly supportsSplitVolumes: boolean`
- `readonly streaming: boolean`
## Implementation Notes
Capabilities are static for the life of an adapter instance — they do not vary by entry. An adapter that supports `readsEncrypted` MUST honour any encryption surface its format defines even if a particular archive instance has none. The `streaming` flag is true if the adapter overrides `createReadStream` with a true streaming implementation; for adapters that buffer the full entry then enqueue once, `streaming` is false. The `modes` array MUST be ordered with `read-only` first to match `ARCHIVE_MODES`. The `format` field is duplicated from the package name to let callers carry a single capability record across packages without an external lookup table.

View File

@@ -0,0 +1,61 @@
# archive-codec
## Purpose
Declares the `ArchiveCodec` interface — the contract a single byte-level compression codec (gzip, bzip2, xz, zstd…) MUST satisfy when plugged into a composed archive adapter such as `drive-storage-tgz`. The codec is intentionally minimal — a stable string identifier, a decode function, and an encode function — so individual format packages can ship their own codec exports (`gzipCodec`, `xzCodec`) without dragging the core into a codec implementation. The composed adapter is unaware of the underlying compression algorithm; it only knows how to call `decode(buf)` and `encode(buf)`.
## Features
- Algorithm-agnostic — works for gzip, bzip2, xz, zstd, lz4, or any future codec.
- Accepts either synchronous or asynchronous codecs (the consumer awaits the result either way).
- Carries a stable identifier so error messages and registry lookups can name the codec without exposing its internals.
## Usage: Basic Example
```ts
import type { ArchiveCodec } from "@suaveplan/drive-storage-archive-core";
const passthroughCodec: ArchiveCodec = {
id: "passthrough",
decode: (buf) => buf,
encode: (buf) => buf,
};
const out = await Promise.resolve(passthroughCodec.decode(new Uint8Array([1, 2, 3])));
console.log(out.byteLength);
```
## Usage: Advanced Example
```ts
import type { ArchiveCodec } from "@suaveplan/drive-storage-archive-core";
function wrapWithLogging(inner: ArchiveCodec, log: (msg: string) => void): ArchiveCodec {
return {
id: `${inner.id}+log`,
decode: async (buf) => {
const start = performance.now();
const out = await inner.decode(buf);
log(`decode ${buf.byteLength}->${out.byteLength} in ${performance.now() - start}ms`);
return out;
},
encode: async (buf) => {
const start = performance.now();
const out = await inner.encode(buf);
log(`encode ${buf.byteLength}->${out.byteLength} in ${performance.now() - start}ms`);
return out;
},
};
}
```
## API Reference
- `interface ArchiveCodec`
- `readonly id: string` — stable identifier (e.g. `"gzip"`).
- `decode(input: Uint8Array): Promise<Uint8Array> | Uint8Array` — decompress.
- `encode(input: Uint8Array): Promise<Uint8Array> | Uint8Array` — compress.
## Implementation Notes
Codecs MUST be pure functions over their input — no captured mutable state, no I/O. Statefulness would break the rewrite-on-flush invariant in `drive-storage-tgz`, which assumes that re-encoding the same bytes produces identical output. The signature accepts `Promise<Uint8Array> | Uint8Array` so a wasm-backed gzip implementation can return synchronously while a node-streams-backed xz codec can return a promise — consumers await either form uniformly. The codec's `id` is consumed by `ARCHIVE_CODEC_MISSING` error messages emitted by the factory when a composed adapter is constructed without a codec slot filled in.

View File

@@ -0,0 +1,53 @@
# archive-format
## Purpose
Declares the canonical `ArchiveFormat` string-literal union that names every archive container format the Suaveplan drive can mount: `zip`, `tar`, `gz`, `tgz`, `rar`, and `7z`. Also declares the `ArchiveFormatOrAuto` extension which adds the `"auto"` sentinel consumed by `openArchiveStorage` to request magic-byte detection. The frozen `ARCHIVE_FORMATS` constant exposes the same set as a runtime array so call sites can iterate or validate against it without re-declaring the list.
## Features
- Single source of truth for valid archive-format identifiers across the per-format packages.
- Exhaustive: every adapter package's name suffix matches one of these strings (`drive-storage-zip` ↔ `"zip"`, etc.) so dynamic dispatch in `openArchiveStorage` is a string concat away.
- Includes the `auto` sentinel only on the option-input type, never on adapter-side enums, so adapters can never be constructed for a non-existent codec.
## Usage: Basic Example
```ts
import { ARCHIVE_FORMATS, type ArchiveFormat } from "@suaveplan/drive-storage-archive-core";
function describe(fmt: ArchiveFormat): string {
return `mounted as ${fmt}`;
}
for (const fmt of ARCHIVE_FORMATS) {
console.log(describe(fmt));
}
```
## Usage: Advanced Example
```ts
import type {
ArchiveFormat,
ArchiveFormatOrAuto,
} from "@suaveplan/drive-storage-archive-core";
function isConcrete(value: ArchiveFormatOrAuto): value is ArchiveFormat {
return value !== "auto";
}
function pickAdapter(fmt: ArchiveFormatOrAuto): ArchiveFormat | "detect" {
if (isConcrete(fmt)) return fmt;
return "detect";
}
```
## API Reference
- `type ArchiveFormat` — `"zip" | "tar" | "gz" | "tgz" | "rar" | "7z"`.
- `type ArchiveFormatOrAuto` — `ArchiveFormat | "auto"`.
- `const ARCHIVE_FORMATS: readonly ArchiveFormat[]` — frozen iteration order.
## Implementation Notes
The `auto` sentinel is intentionally NOT a member of `ArchiveFormat`. Adapter constructors accept only concrete formats — letting `"auto"` leak into them would force every adapter to handle an impossible value. The factory resolves `"auto"` via `sniffFormat` and constructs the adapter with the concrete result. The frozen array uses `Object.freeze` so an accidental mutation throws under strict mode rather than silently corrupting downstream iteration. The order matches the package directory order under `packages/drive/drive-storage-*` for predictable docs and test layout.

View File

@@ -0,0 +1,49 @@
# archive-mode
## Purpose
Declares the `ArchiveMode` string-literal union (`read-only`, `copy-on-write`, `rewrite`) that every per-format archive-storage adapter accepts at construction. The mode governs how mutations are buffered, materialised, and validated, and it is the single contract every adapter shares for write semantics. Mode enforcement is mandatory: a `read-only` adapter MUST throw `ARCHIVE_MODE_READ_ONLY` on any mutation; a `copy-on-write` adapter MUST defer materialisation until `flush()`; a `rewrite` adapter MUST re-encode synchronously.
## Features
- Single shared mode vocabulary so callers can swap one adapter for another with no semantic surprise.
- Exposes a runtime constant `ARCHIVE_MODES` for iteration / validation in tests.
- Ships a type guard `isArchiveMode` for input validation at adapter boundaries.
## Usage: Basic Example
```ts
import { isArchiveMode, type ArchiveMode } from "@suaveplan/drive-storage-archive-core";
const mode: ArchiveMode = "copy-on-write";
if (!isArchiveMode(mode)) throw new Error("invalid mode");
```
## Usage: Advanced Example
```ts
import {
ARCHIVE_MODES,
isArchiveMode,
} from "@suaveplan/drive-storage-archive-core";
function parseMode(input: string): ArchiveMode {
if (isArchiveMode(input)) return input;
const allowed = ARCHIVE_MODES.join(", ");
throw new Error(`unknown mode '${input}'; expected one of ${allowed}`);
}
for (const m of ARCHIVE_MODES) {
parseMode(m); // round-trip valid for every member
}
```
## API Reference
- `type ArchiveMode` — `"read-only" | "copy-on-write" | "rewrite"`.
- `const ARCHIVE_MODES: readonly ArchiveMode[]` — frozen iteration order.
- `isArchiveMode(value): value is ArchiveMode` — type guard.
## Implementation Notes
The order in `ARCHIVE_MODES` ascends in mutability: read-only is the safest default, copy-on-write trades a memory overlay for batchable mutations, rewrite trades CPU/IO cost for in-place semantics. Adapters that cannot safely implement a stronger mode (e.g. RAR is patent-encumbered for writes) MUST reject the constructor argument at type level or via an explicit `ARCHIVE_UNSUPPORTED_FEATURE` throw. The guard is intentionally a plain equality check rather than a Set lookup — the union is small enough that v8's branch predictor outperforms a Set probe.

View File

@@ -0,0 +1,61 @@
# entry-reader
## Purpose
Declares the `EntryReader` interface that every per-format archive adapter implements internally to surface a single entry's bytes. The reader is the streaming-friendly counterpart to `DriveStorage.get` — adapters expose `EntryReader.open()` so callers (including the conformance harness and the chunks plugin) can pipeline decode + decrypt + CRC-verify without first materialising the entry in memory. `readAll()` is the convenience wrapper that consumes the stream and concatenates into a `Uint8Array`.
## Features
- Streaming-first: `open()` returns a Web `ReadableStream<Uint8Array>` so multi-GB entries never need to fit in memory.
- Convenience wrapper `readAll()` for callers that genuinely need the bytes contiguously.
- Carries the entry path and (optional) size so callers can pre-allocate buffers when the size is known.
## Usage: Basic Example
```ts
import type { EntryReader } from "@suaveplan/drive-storage-archive-core";
async function dump(reader: EntryReader): Promise<void> {
const bytes = await reader.readAll();
console.log(`${reader.path}: ${bytes.byteLength} bytes`);
}
```
## Usage: Advanced Example
```ts
import type { EntryReader } from "@suaveplan/drive-storage-archive-core";
async function hash(reader: EntryReader, algorithm: "SHA-256"): Promise<ArrayBuffer> {
// Stream-hash without ever buffering the full entry.
const stream = reader.open();
const chunks: Uint8Array[] = [];
const writer = new WritableStream<Uint8Array>({
write(chunk) {
chunks.push(chunk);
},
});
await stream.pipeTo(writer);
let total = 0;
for (const c of chunks) total += c.byteLength;
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.byteLength;
}
return crypto.subtle.digest(algorithm, merged);
}
```
## API Reference
- `interface EntryReader`
- `readonly path: string` — entry path inside the archive.
- `readonly size: number | null` — uncompressed byte length, or `null` when unknown (tar streaming, gz with no ISIZE trailer).
- `open(): ReadableStream<Uint8Array>` — open a Web Stream.
- `readAll(): Promise<Uint8Array>` — read fully into memory.
## Implementation Notes
`size` is `number | null` because tar entries are encountered sequentially and the size in the header is the only source of truth; some legacy tar variants pad the size field with whitespace and the reader cannot trust it without re-reading the data. `null` lets the adapter signal "I don't know yet" without lying. The `open()` method MUST be callable more than once if the underlying format supports random access (zip / 7z with a known central directory); for sequential formats (tar / gz) the adapter MAY raise on the second open. `readAll()` is the default fallback path for `DriveStorage.get` and MUST be implemented as `await new Response(this.open()).arrayBuffer()` or equivalent to keep the streaming code path the single source of truth.

View File

@@ -0,0 +1,60 @@
# open-archive-storage-options
## Purpose
Declares the option bag passed to `openArchiveStorage(source, options)` plus the supporting types used by the dynamic-import dispatch path: `ArchiveAdapterFormatRegistry` (used by tests to inject a synchronous registry instead of loading real per-format packages), `ArchiveAdapterFactory` (the signature each per-format package exports), `ResolvedAdapterOptions` (the subset of options the factory hands to a concrete adapter once `auto` has been resolved), and `ArchiveStorageHandle` (the minimum surface every adapter MUST expose back to the factory).
## Features
- Every option is optional — sensible defaults (`format: "auto"`, `mode: "read-only"`) cover the common case.
- `declaredExtension` lets callers feed the factory the original filename's extension so the `ARCHIVE_FORMAT_MISMATCH` warning can fire WITHOUT making the factory parse filenames.
- `__formatRegistry` is an explicit test-injection seam — production code never sets it; conformance tests use it to supply fake adapters.
- `codec` slot exists at the core's option layer so composed adapters (tgz over gzipCodec) inherit the option shape directly.
## Usage: Basic Example
```ts
import type { OpenArchiveStorageOptions } from "@suaveplan/drive-storage-archive-core";
const opts: OpenArchiveStorageOptions = {
format: "auto",
mode: "read-only",
};
```
## Usage: Advanced Example
```ts
import type {
ArchiveAdapterFormatRegistry,
OpenArchiveStorageOptions,
} from "@suaveplan/drive-storage-archive-core";
const registry: ArchiveAdapterFormatRegistry = {
zip: async (_source, opts) => ({
mode: opts.mode,
async flush() {
/* test stub */
},
}),
};
const opts: OpenArchiveStorageOptions = {
format: "zip",
mode: "copy-on-write",
declaredExtension: "zip",
__formatRegistry: registry,
};
```
## API Reference
- `interface OpenArchiveStorageOptions` — factory option bag.
- `type ArchiveAdapterFormatRegistry` — `Partial<Record<ArchiveFormat, ArchiveAdapterFactory>>` for test injection.
- `type ArchiveAdapterFactory` — `(source, ResolvedAdapterOptions) => Promise<ArchiveStorageHandle>`.
- `interface ResolvedAdapterOptions` — the post-resolve subset (no `format`, no `declaredExtension`).
- `interface ArchiveStorageHandle` — `{ mode, flush() }`.
## Implementation Notes
The `__formatRegistry` name uses a leading double-underscore to signal "private / test-only" without forcing a TypeScript private modifier (which would make it unusable from a test file via type narrowing). The factory must check the registry FIRST and only dynamic-import when the slot is unset, so test runs that inject a registry never touch the real per-format packages — that matters because the real ZIP / TAR / 7z packages do not yet exist in this project when this package's tests run. `ResolvedAdapterOptions` deliberately omits `format` because the factory has already chosen the adapter by the time it builds the resolved options — leaving `format` in would invite an adapter to second-guess the dispatcher.

View File

@@ -0,0 +1,49 @@
# password-hook
## Purpose
Declares the `PasswordHook` callback signature used by every archive adapter to fetch passwords lazily for encrypted entries. The hook is invoked only when an encrypted entry is actually read — listing or stat operations against encrypted archives never trigger a password prompt. The contract intentionally treats the password as a transient secret: adapters MUST NOT cache the returned value on the adapter instance, and the `wrapPasswordHook` helper enforces this by holding the value only inside a using-disposable scope that zeroizes on disposal.
## Features
- Lazy: hook is never called on non-encrypted reads.
- Async: lets the host prompt a user, fetch from a vault, or call a remote KMS.
- Tied to a single decrypt operation; the password is dropped as soon as the active read completes.
## Usage: Basic Example
```ts
import type { PasswordHook } from "@suaveplan/drive-storage-archive-core";
const hook: PasswordHook = async () => process.env.ARCHIVE_PW ?? "";
const pw = await hook();
console.log(pw.length, "chars");
```
## Usage: Advanced Example
```ts
import type { PasswordHook } from "@suaveplan/drive-storage-archive-core";
function fromVault(unlock: () => Promise<string>): PasswordHook {
let inflight: Promise<string> | undefined;
return async () => {
// Coalesce concurrent reads of encrypted entries onto one unlock.
if (inflight !== undefined) return inflight;
inflight = unlock().finally(() => {
// Discard the cached Promise after settlement so the next encrypted
// read triggers a fresh unlock.
inflight = undefined;
});
return inflight;
};
}
```
## API Reference
- `type PasswordHook = () => Promise<string>` — host callback returning a password.
## Implementation Notes
The hook returns a `Promise<string>` rather than `string | Promise<string>` to keep the calling site's await contract uniform — adapters never need to detect sync-vs-async and shape their decryption pipeline accordingly. Adapters MUST treat the returned string as untrusted input: they pass it directly to the format-specific KDF (PBKDF2 for ZIP AES, AES-CTR-derived key for 7zAES, RAR4's modified SHA-1 pipeline) and discard the original string immediately after derivation. The accompanying `wrapPasswordHook` utility in `src/password/` enforces no-retention by holding the value only inside a `using` block, then overwriting the string-backed buffer on disposal so a heap snapshot cannot recover the plaintext.

View File

@@ -0,0 +1,49 @@
# resolve-sibling
## Purpose
Declares the `ResolveSibling` callback contract that per-format archive adapters use to fetch additional split-volume bytes from the host application. The host is the single source of truth for sibling discovery — adapters never assume a filesystem layout, an HTTP URL pattern, or an IndexedDB row scheme. This contract lets the same adapter mount a split archive whether the volumes live on disk, on a CDN, in a P2P drive, or inside a single ZIP container nested in another.
## Features
- Host-owned discovery: the adapter passes a basename + zero-based index and the host returns bytes.
- Stream-friendly: returns `Uint8Array` or a `ReadableStream<Uint8Array>` so the host can lazy-fetch multi-GB volumes.
- Explicit absence: returning `null` triggers `ARCHIVE_SPLIT_MISSING_VOLUME`, distinguishing "this volume is missing" from "this volume is empty".
## Usage: Basic Example
```ts
import type { ResolveSibling } from "@suaveplan/drive-storage-archive-core";
const resolver: ResolveSibling = async (basename, index) => {
const filename = `${basename}.z${String(index + 1).padStart(2, "0")}`;
const res = await fetch(`/archives/${filename}`);
if (res.status === 404) return null;
return new Uint8Array(await res.arrayBuffer());
};
```
## Usage: Advanced Example
```ts
import type { ResolveSibling } from "@suaveplan/drive-storage-archive-core";
function cachedResolver(inner: ResolveSibling): ResolveSibling {
const cache = new Map<string, Uint8Array | ReadableStream<Uint8Array> | null>();
return async (basename, index) => {
const key = `${basename}#${index}`;
if (cache.has(key)) return cache.get(key) ?? null;
const out = await inner(basename, index);
cache.set(key, out);
return out;
};
}
```
## API Reference
- `type ResolveSibling = (basename: string, index: number) => Promise<Uint8Array | ReadableStream<Uint8Array> | null>` — host callback for sibling-volume fetch.
## Implementation Notes
The callback returns a Promise so the host can do async I/O without blocking the adapter. The `Uint8Array | ReadableStream` union is intentional — small volumes (e.g. `.z01` of 1 MiB) are cheaper to return as a fully-realised buffer, while multi-GB final volumes benefit from streaming. The basename is the primary volume's filename WITHOUT the trailing extension; the adapter reconstructs the per-format naming convention (`name.z01..zNN`, `name.partN.rar`, `name.7z.NNN`) from `basename` + `index`. Adapters MUST validate the returned bytes' volume header matches the expected `index` and raise `ARCHIVE_SPLIT_MISSING_VOLUME` on mismatch so partial fetches can't masquerade as the wrong volume.

View File

@@ -0,0 +1,38 @@
# @suaveplan/drive-storage-zip - ZIP archive adapter for @suaveplan/drive: parses EOCD/ZIP64, central directory, local headers; supports store + deflate compression, WinZip AE-2 AES-256 decryption, split-volume reassembly, and read-only / copy-on-write / rewrite mutation modes via the drive-storage-archive-core conformance harness.
ZIP archive adapter for `@suaveplan/drive`. Reads and writes standards-compliant zip archives through the structural `DriveStorage` view; supports ZIP64, split volumes, WinZip AE-2 AES-256 decryption (read-only in v0.1.0), and three explicit mutation modes (`read-only`, `copy-on-write`, `rewrite`).
```ts
import { ZipArchiveStorage } from "@suaveplan/drive-storage-zip";
const storage = await ZipArchiveStorage.open({
source: archiveBytes,
mode: "read-only",
});
const payload = await storage.get("docs/readme.md");
```
See the package source for full surface area details.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/error-codes.md) — Defines the stable, machine-readable error code registry for the `@suaveplan/drive-storage-zip` package. Every failure…
- [error-codes](./src/errors/error-codes.md) — Defines the stable, machine-readable error code registry for the `@suaveplan/drive-storage-zip` package. Every failure…
- [parse-cdr-entry](./src/parser/central-directory/parse-cdr-entry.md) — Decodes one Central Directory File Header record (signature `0x02014b50`) plus its three variable-length tails (filenam…
- [walk-cdr](./src/parser/central-directory/walk-cdr.md) — Iterates the central directory sequentially and returns every parsed `CdrEntry`. Built on top of `parseCdrEntry`, it se…
- [compress](./src/parser/compression/compress.md) — Encodes one entry payload for inclusion in a zip archive. Picks between method 0 (STORE) and method 8 (DEFLATE) accordi…
- [decompress](./src/parser/compression/decompress.md) — Materialises an entry's plaintext bytes from its compressed payload. This module owns the dispatch on PKZIP compression…
- [aes-decrypt](./src/parser/crypto/aes/aes-decrypt.md) — Implements WinZip AE-2 (and AE-1) AES decryption for ZIP entries that advertise the AE extra field (`0x9901`). Performs…
- [aes-write-block](./src/parser/crypto/aes/aes-write-block.md) — Hosts the stub for the v0.2.0 WinZip AE-2 write path. The v0.1.0 adapter is deliberately read-only for AES-encrypted en…
- [find-eocd](./src/parser/eocd/find-eocd.md) — Locates the End-Of-Central-Directory signature within the trailing window of a ZIP buffer. ZIP archives terminate with…
- [parse-eocd](./src/parser/eocd/parse-eocd.md) — Decodes the 22-byte End-Of-Central-Directory record at a known offset in the archive buffer. The function performs stri…
- [parse-eocd64](./src/parser/eocd/parse-eocd64.md) — Provides decoders for the ZIP64 End-Of-Central-Directory locator and record plus a merge helper that overlays the 64-bi…
- [parse-local-header](./src/parser/local-header/parse-local-header.md) — Decodes a Local File Header — the per-entry preamble written immediately before each compressed payload. The CDR carrie…
- [read-entry-bytes](./src/parser/local-header/read-entry-bytes.md) — Given a parsed `CdrEntry` and the underlying archive buffer, returns the raw (still-compressed, still-encrypted) payloa…
- [crc32](./src/parser/crc32.md) — Computes the IEEE 802.3 CRC-32 (polynomial `0xEDB88320`, little-endian reflected) used by every ZIP entry. The CRC stor…
- [signatures](./src/parser/signatures.md) — Central registry of every fixed-width magic number, extra-field tag, and bit-flag bit position the ZIP parser and encod…
- [encode-zip](./src/storage/encode-zip.md) — Encodes a list of plaintext entries into a complete, standards-compliant ZIP byte buffer. Used by `ZipArchiveStorage.fl…
- [normalize-path](./src/storage/normalize-path.md) — Validates and canonicalises an entry path before it enters the ZIP write pipeline. The function defends against the cla…
- [zip-archive-storage](./src/storage/zip-archive-storage.md) — Implements the `ZipArchiveStorage` class — a `DriveStorage`-compatible adapter that exposes the entries of a ZIP archiv…
<!-- end:auto-docs -->

View File

@@ -0,0 +1,52 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, machine-readable error code registry for the `@suaveplan/drive-storage-zip` package. Every failure inside the ZIP parser, the AES-256 decrypt path, the split-volume reassembly logic, the write-mode encoder, and the path-normalisation guard raises a `ZipError` carrying one of ten `ZIP.*` codes. Codes are produced through `defineCodes` from `@suaveplan/error`, which guarantees compile-time uniqueness and a stable `"<prefix>.<name>"` string identifier suitable for log search, error-rate dashboards, and cross-package error-chain inspection.
## Features
- 10 stable `ZIP.*` codes spanning structural, cryptographic, and write-path failures.
- `ZipError` class extending `SuaveplanError` so consumers may `instanceof` narrow without losing the typed `code` discriminant.
- Redundant `zipCode` field on the error instance to enable concise `toMatchObject({ zipCode })` assertions in test suites.
- Optional `SuaveplanErrorOptions` (cause chain, context map, redaction tags) flow through to the parent class without modification.
## Usage: Basic
```ts
import { ZIP_CODES, ZipError } from "@suaveplan/drive-storage-zip/errors";
throw new ZipError(
ZIP_CODES.EOCD_NOT_FOUND,
"no end-of-central-directory record in the last 65557 bytes"
);
```
## Usage: Advanced
```ts
import { ZIP_CODES, ZipError } from "@suaveplan/drive-storage-zip/errors";
try {
await archive.get("inside/notes.txt");
} catch (err) {
if (err instanceof ZipError && err.zipCode === ZIP_CODES.CRC_MISMATCH) {
logger.warn({ entry: "inside/notes.txt" }, "decompression failed");
}
throw err;
}
```
## API Reference
- `ZIP_CODES` — frozen registry of code names produced by `defineCodes("ZIP", [...])`.
- `ZipErrorCode` — string literal union narrowing all valid code values.
- `ZipError` — constructor `(code: ZipErrorCode, message: string, options?: SuaveplanErrorOptions)`; exposes `code` and the convenience-mirror `zipCode`.
## Implementation Notes
The redundant `zipCode` assignment keeps the constructor body non-trivial so the Biome `noUselessConstructor` lint never flags this class even when the upstream `SuaveplanError` constructor signature evolves. Codes are deliberately broad-grained: structural parse failures collapse under `CENTRAL_DIR_CORRUPT`, allowing the carried message string to distinguish "EOCD truncated" from "ZIP64 locator missing magic", whereas separate codes are reserved for ergonomic test/assertion paths (`CRC_MISMATCH`, `AES_AUTH_FAILED`, `UNSUPPORTED_COMPRESSION`, `PATH_INVALID`) where consumers typically branch on the code.

Some files were not shown because too many files have changed in this diff Show More