first commit
This commit is contained in:
139
docs/foundation/data/packer/README.md
Normal file
139
docs/foundation/data/packer/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# @suaveplan/packer - Binary packing/unpacking: whole-file (gzip + AEAD + ed25519) and chunked (locked v1 byte format, streaming pipeline, server-signed manifests, peer-verifiable).
|
||||
|
||||
## Summary
|
||||
|
||||
Binary packing/unpacking utilities. Two modes:
|
||||
|
||||
- **Whole-file** (`@suaveplan/packer`) — gzip + AEAD (XChaCha20-Poly1305) + ed25519 sign in one shot, schema-driven via `@suaveplan/bitpacker`. Self-contained pack-and-go for small assets.
|
||||
- **Chunked** (`@suaveplan/packer/chunked`) — chunk-addressed streaming pack/unpack/verify emitting the locked v1 byte format. Used by `@suaveplan/drive-plugin-chunks` for P2P-distributable file chunks.
|
||||
|
||||
Status: scaffolding. See the design document for the in-flight port from `../suaveplan/packages/universal/packer`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/packer
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { Packer, Unpacker } from "@suaveplan/packer";
|
||||
|
||||
const p = new Packer();
|
||||
const u = new Unpacker();
|
||||
const packed = await p.pack(new Uint8Array([1, 2, 3]));
|
||||
const result = await u.unpack(packed);
|
||||
// result.metadata.integrity carries BLAKE3 entryDigest + Merkle root
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [integrity/integrity](./src/integrity/integrity.md) — BLAKE3 per-entry digest, bundle-level Merkle root, `computeWholeFileIntegrity`, `bytesEqual`.
|
||||
- [observability/observability](./src/observability/observability.md) — OpenTelemetry counters, histograms, and spans; `createPackerObservability`, `withPackerSpan`, `withPackerSpanSync`.
|
||||
- [i18n/messages](./src/i18n/messages.md) — `PACKER_MESSAGES` English-defaults catalog and `defaultPackerResolver` / `resolvePackerMessage` for `MessageResolver` injection.
|
||||
- [packer/packer](./src/packer/packer.md) — whole-file `Packer.pack` (gzip + AEAD + ed25519 + BLAKE3 integrity).
|
||||
- [unpacker/unpacker](./src/unpacker/unpacker.md) — whole-file `Unpacker.unpack` with mandatory BLAKE3 integrity verification.
|
||||
- [chunked/packer/packer](./src/chunked/packer/packer.md) — streaming v1 chunked pack pipeline.
|
||||
- [chunked/unpacker/unpacker](./src/chunked/unpacker/unpacker.md) — chunked unpack (`unpackChunks`, `unpackChunksWithManifest`).
|
||||
- [chunked/manifest/manifest](./src/chunked/manifest/manifest.md) — server-signed manifest builder/verifier carrying `entryDigest` + `merkleRoot`.
|
||||
- [chunked/codec/chunk](./src/chunked/codec/chunk.md) and [chunked/codec/header](./src/chunked/codec/header.md) — v1 byte-layout encoder/parser.
|
||||
- [chunked/crypto/aead](./src/chunked/crypto/aead.md) — XChaCha20-Poly1305 wrapper + per-chunk nonce derivation.
|
||||
- [chunked/verifier/chunk-verifier](./src/chunked/verifier/chunk-verifier.md) — server-pubkey-only verifier for peer-mesh consumers.
|
||||
- [streaming/normalize-source](./src/streaming/normalize-source.md) — source-shape normaliser.
|
||||
- [error-codes](./src/error-codes.md) — `PACKER_CODES` registry, including `PACKER.INTEGRITY_FAILED`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [abstract](./src/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [abstract](./src/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [chunk](./src/chunked/codec/chunk.md) — Encodes and parses the wire-format v1 chunk in its entirety: `metadata(100) || sig(64) || ciphertext(payloadLen)`. Comp…
|
||||
- [header](./src/chunked/codec/header.md) — Encodes and parses the 100-byte metadata portion of a v1 chunk on its own (without sig or ciphertext). Every chunk on t…
|
||||
- [crypto](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [aead](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [manifest](./src/chunked/manifest/manifest.md) — Builds, signs, and verifies the v1 manifest — the server-signed object that maps a `fileId` to its full chunk list, enc…
|
||||
- [manifest](./src/chunked/manifest/manifest.md) — Builds, signs, and verifies the v1 manifest — the server-signed object that maps a `fileId` to its full chunk list, enc…
|
||||
- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [chunk-verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [flags](./src/flags/flags.md) — `flags.ts` wires `@suaveplan/packer` into the `@suaveplan/feature-flags` ecosystem so future wire-format versions can b…
|
||||
- [flags](./src/flags/flags.md) — `flags.ts` wires `@suaveplan/packer` into the `@suaveplan/feature-flags` ecosystem so future wire-format versions can b…
|
||||
- [i18n](./src/i18n/messages.md) — `@suaveplan/packer/i18n` carries the per-package message catalog every error thrown by `@suaveplan/packer` flows throug…
|
||||
- [messages](./src/i18n/messages.md) — `@suaveplan/packer/i18n` carries the per-package message catalog every error thrown by `@suaveplan/packer` flows throug…
|
||||
- [idempotency](./src/idempotency/idempotency.md) — Repeat packs over identical inputs are a guaranteed no-op: the second call returns the cached `PackedData` without re-r…
|
||||
- [idempotency](./src/idempotency/idempotency.md) — Repeat packs over identical inputs are a guaranteed no-op: the second call returns the cached `PackedData` without re-r…
|
||||
- [integrity](./src/integrity/integrity.md) — `@suaveplan/packer/integrity` provides the content-addressable integrity layer the rest of the package uses to detect a…
|
||||
- [integrity](./src/integrity/integrity.md) — `@suaveplan/packer/integrity` provides the content-addressable integrity layer the rest of the package uses to detect a…
|
||||
- [observability](./src/observability/observability.md) — `@suaveplan/packer/observability` centralises every OpenTelemetry counter, histogram, and span the package emits, so a…
|
||||
- [observability](./src/observability/observability.md) — `@suaveplan/packer/observability` centralises every OpenTelemetry counter, histogram, and span the package emits, so a…
|
||||
- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugins](./src/plugins/plugins.md) — `plugins.ts` exposes a small, opinionated extension surface that lets downstream packages contribute behaviour to `Pack…
|
||||
- [plugins](./src/plugins/plugins.md) — `plugins.ts` exposes a small, opinionated extension surface that lets downstream packages contribute behaviour to `Pack…
|
||||
- [retry](./src/retry/retryable-read.md) — Wraps `ReadableStream` reads with the retry primitives from `@suaveplan/retry` so transient IO failures (network blip,…
|
||||
- [retryable-read](./src/retry/retryable-read.md) — Wraps `ReadableStream` reads with the retry primitives from `@suaveplan/retry` so transient IO failures (network blip,…
|
||||
- [runtime](./src/runtime/runtime.md) — Every production code path in `@suaveplan/packer` reads wall-clock time and randomness through an injected `PackerRunti…
|
||||
- [runtime](./src/runtime/runtime.md) — Every production code path in `@suaveplan/packer` reads wall-clock time and randomness through an injected `PackerRunti…
|
||||
- [schemas](./src/schemas/registration.md) — `registration.ts` wires every Zod schema exported from `packer.schemas.ts` and a canonical `packer-chunk-format` wire-f…
|
||||
- [registration](./src/schemas/registration.md) — `registration.ts` wires every Zod schema exported from `packer.schemas.ts` and a canonical `packer-chunk-format` wire-f…
|
||||
- [streaming](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [normalize-source](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Defines the stable error code registry for `@suaveplan/packer`. Every failure surfaced by the package — invalid inputs,…
|
||||
<!-- end:auto-docs --> - [abstract](./src/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk](./src/chunked/codec/chunk.md) — Encodes and parses the wire-format v1 chunk in its entirety: `metadata(100) || sig(64) || ciphertext(payloadLen)`. Comp…
|
||||
- [header](./src/chunked/codec/header.md) — Encodes and parses the 100-byte metadata portion of a v1 chunk on its own (without sig or ciphertext). Every chunk on t…
|
||||
- [crypto](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [streaming](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize-source](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error code registry for `@suaveplan/packer`. Every failure surfaced by the package — invalid inputs,…
|
||||
- [abstract](./src/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk](./src/chunked/codec/chunk.md) — Encodes and parses the wire-format v1 chunk in its entirety: `metadata(100) || sig(64) || ciphertext(payloadLen)`. Comp…
|
||||
- [header](./src/chunked/codec/header.md) — Encodes and parses the 100-byte metadata portion of a v1 chunk on its own (without sig or ciphertext). Every chunk on t…
|
||||
- [crypto](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [streaming](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize-source](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error code registry for `@suaveplan/packer`. Every failure surfaced by the package — invalid inputs,…
|
||||
- [abstract](./src/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk](./src/chunked/codec/chunk.md) — Encodes and parses the wire-format v1 chunk in its entirety: `metadata(100) || sig(64) || ciphertext(payloadLen)`. Comp…
|
||||
- [header](./src/chunked/codec/header.md) — Encodes and parses the 100-byte metadata portion of a v1 chunk on its own (without sig or ciphertext). Every chunk on t…
|
||||
- [crypto](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [streaming](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize-source](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error code registry for `@suaveplan/packer`. Every failure surfaced by the package — invalid inputs,…
|
||||
- [abstract](./src/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk](./src/chunked/codec/chunk.md) — Encodes and parses the wire-format v1 chunk in its entirety: `metadata(100) || sig(64) || ciphertext(payloadLen)`. Comp…
|
||||
- [header](./src/chunked/codec/header.md) — Encodes and parses the 100-byte metadata portion of a v1 chunk on its own (without sig or ciphertext). Every chunk on t…
|
||||
- [crypto](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_ - [aead](./src/chunked/crypto/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ - [manifest](./src/chunked/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [packer](./src/chunked/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [unpacker](./src/chunked/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chunk-verifier](./src/chunked/verifier/chunk-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [packer](./src/packer/packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [streaming](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_ - [normalize-source](./src/streaming/normalize-source.md) — <!-- begin:auto-docs --> _(no submodules)_- [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [unpacker](./src/unpacker/unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error code registry for `@suaveplan/packer`. Every failure surfaced by the package — invalid inputs,…
|
||||
|
||||
## Subpaths
|
||||
|
||||
| Subpath | Surface |
|
||||
| --- | --- |
|
||||
| `@suaveplan/packer` | `Packer`, `Unpacker`, whole-file types |
|
||||
| `@suaveplan/packer/chunked` | `ChunkedPacker`, `ChunkedUnpacker`, `ChunkVerifier`, manifest helpers |
|
||||
| `@suaveplan/packer/streaming` | `normalizeSource(...)` source-normalization helper |
|
||||
| `@suaveplan/packer/errors` | `PACKER_CODES`, `PackerError` |
|
||||
| `@suaveplan/packer/flags` | `defaultPackerFlags`, `resolvePackerFlags`, `PACKER_FLAG_KEYS` |
|
||||
| `@suaveplan/packer/plugins` | `PackerPlugin`, `PluginPipeline` |
|
||||
| `@suaveplan/packer/schemas` | `registerPackerSchemas`, `getPackerSchemaRegistry`, `PACKER_CHUNK_FORMAT_V1` |
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Baseline numbers collected on bun 1.3.14 (x64-linux) — Intel(R) Xeon(R)
|
||||
CPU E5-2696 v4 @ 2.20GHz, WSL2. Run locally with `bun run benchmark`.
|
||||
|
||||
| Case | Throughput (avg) | Notes |
|
||||
| --- | --- | --- |
|
||||
| whole-file pack — 1 KiB | ~254 µs/iter | gzip+sha256+blake3 hot path |
|
||||
| whole-file unpack — 1 KiB | ~47 µs/iter | integrity verify + gunzip |
|
||||
| chunked pack — single chunk | ~654 µs/iter | XChaCha20-Poly1305 + ed25519 |
|
||||
| chunked pack — 1000 chunks | ~562 ms/iter | 1 MiB streamed in 1 KiB chunks |
|
||||
| integrity verify — 1 KiB | ~21 µs/iter | BLAKE3 entry + Merkle root |
|
||||
| plugin overhead — pack + noop plugin | ~147 µs/iter | observed over the 1 KiB pack baseline |
|
||||
|
||||
The benchmark suite is advisory: `bun run benchmark` runs `mitata` and
|
||||
emits the numbers above. The suite is intentionally excluded from the
|
||||
test gate (vitest excludes `src/__benchmarks__/**`) — regression
|
||||
tracking is opt-in for local profiling.
|
||||
|
||||
## Chunk binary format — v1
|
||||
|
||||
```text
|
||||
metadata(100 B) ‖ sig(64 B) ‖ ciphertext(payloadLen B)
|
||||
```
|
||||
|
||||
See the specification for the byte-exact field offsets pinned in `PACKER-CHUNK-01`.
|
||||
|
||||
56
docs/foundation/data/packer/src/abstract/abstract.md
Normal file
56
docs/foundation/data/packer/src/abstract/abstract.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# AbstractPacker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Base class for whole-file packer implementations (`Packer`, `Unpacker`). Holds resolved options (with sensible defaults applied), a typed `EventBus<PackerEventMap>` for observability, and shared helpers for SHA-256 checksum, MIME detection, and metadata assembly. Subclasses implement the `pack` pipeline; `packStream` is provided for free by collecting the source into a buffer first (whole-file mode is not streaming-native — use `@suaveplan/packer/chunked` for true streaming).
|
||||
|
||||
## Features
|
||||
|
||||
- `EventBus<PackerEventMap>` typed events: `pack:start`, `pack:compressed`, `pack:encrypted`, `pack:signed`, `pack:complete`, plus matching `unpack:*`.
|
||||
- `protected checksum(data)` — SHA-256 hex.
|
||||
- `protected mimeFor(filename?)` — MIME detection via `@suaveplan/mime`.
|
||||
- `protected buildMetadata(...)` — assembles the metadata object with timestamp, checksum, optional MIME, optional `signed`, and any caller-supplied `custom` fields.
|
||||
- `packStream(source, filename?)` — buffers any `PackSource` and delegates to `pack`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
|
||||
class MyPacker extends Packer {
|
||||
// override hooks if needed
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
|
||||
const packer = new Packer({
|
||||
compressionLevel: 9,
|
||||
encryption: { algorithm: "xchacha20-poly1305", key: fileKey },
|
||||
signing: { key: serverSignKey },
|
||||
metadata: { author: "alice" },
|
||||
});
|
||||
packer.on("pack:compressed", (env) => console.log("size", env.payload.compressedSize));
|
||||
const result = await packer.pack(bytes, "report.pdf");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`abstract class AbstractPacker extends EventBus<PackerEventMap>`**
|
||||
- `constructor(options?: PackerOptions)` — applies defaults: `compressionLevel: 6`, empty `metadata`.
|
||||
- `abstract pack(data: Uint8Array, filename?: string): Promise<PackedData>`.
|
||||
- `packStream(source: PackSource, filename?: string): Promise<PackedData>` — buffers source and delegates.
|
||||
- `protected checksum(data: Uint8Array): string` — SHA-256 hex.
|
||||
- `protected mimeFor(filename?: string): string | undefined`.
|
||||
- `protected buildMetadata(input): PackedDataMetadata`.
|
||||
- **`interface ResolvedPackerOptions`** — internal shape of `this.options`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`packStream`'s buffering is intentional — whole-file mode produces a single envelope with one checksum and one optional signature, which requires the full byte stream to be in hand before the pipeline can complete. The streaming-native pipeline lives in `@suaveplan/packer/chunked`. The collector handles all four `PackSource` shapes (`Uint8Array`, `Blob`, `ReadableStream`, `AsyncIterable`) and falls back from async-iteration to `getReader` for `ReadableStream` instances that lack the iterator protocol.
|
||||
58
docs/foundation/data/packer/src/chunked/codec/chunk.md
Normal file
58
docs/foundation/data/packer/src/chunked/codec/chunk.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# chunk
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and parses the wire-format v1 chunk in its entirety: `metadata(100) || sig(64) || ciphertext(payloadLen)`. Composes `encodeHeader` / `parseHeader` with byte-level concatenation, length validation, and the canonical signing-payload helper. Every receive path in the packer (chunked unpacker, chunk verifier, mesh transport) calls `parseChunk` before any cryptographic work; every send path calls `encodeChunk` once metadata, signature, and ciphertext are ready.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeChunk({ metadata, sig, ciphertext })` returns a single `Uint8Array` of `164 + payloadLen` bytes.
|
||||
- `parseChunk(buf)` returns `{ metadata, sig, ciphertext }` with all three fields as zero-copy subarrays of `buf`.
|
||||
- `signingPayload(metadataBytes, ciphertext)` returns the canonical bytes the server signs / receivers verify.
|
||||
- Length and structural validation lifted from `parseHeader`; chunk-level validation adds the total-length check.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encodeChunk, parseChunk } from "@suaveplan/packer/chunked";
|
||||
|
||||
const wire = encodeChunk({
|
||||
metadata, // ChunkV1Metadata
|
||||
sig: new Uint8Array(64), // ed25519 signature
|
||||
ciphertext: new Uint8Array(payloadLen),
|
||||
});
|
||||
|
||||
const back = parseChunk(wire);
|
||||
// back.metadata, back.sig, back.ciphertext
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encodeChunk, parseChunk, signingPayload, encodeHeader } from "@suaveplan/packer/chunked";
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
|
||||
// Producer:
|
||||
const headerBytes = encodeHeader(metadata);
|
||||
const signed = signingPayload(headerBytes, ciphertext);
|
||||
const sig = ed25519.sign(signed, serverPrivKey);
|
||||
const wire = encodeChunk({ metadata, sig, ciphertext });
|
||||
|
||||
// Receiver:
|
||||
const { metadata: m, sig: s, ciphertext: c } = parseChunk(wire);
|
||||
const signedAgain = signingPayload(encodeHeader(m), c);
|
||||
if (!ed25519.verify(s, signedAgain, serverPubKey)) {
|
||||
throw new Error("bad sig");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`interface EncodeChunkInput { metadata: ChunkV1Metadata; sig: Uint8Array; ciphertext: Uint8Array }`**
|
||||
- **`encodeChunk(input: EncodeChunkInput): Uint8Array`** — total length `164 + payloadLen`. Throws `PackerError(PACKER.INVALID_FORMAT)` on `sig.byteLength !== 64` and `PackerError(PACKER.PAYLOAD_LEN_INVALID)` on ciphertext-length mismatch.
|
||||
- **`parseChunk(buf: Uint8Array): ParsedChunkV1`** — validates total length matches `164 + metadata.payloadLen`. Throws on header-parse failures and on length mismatch.
|
||||
- **`signingPayload(metadataBytes: Uint8Array, ciphertext: Uint8Array): Uint8Array`** — concatenates the two for ed25519 sign/verify.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`encodeChunk` performs `metadata` field-length validation indirectly via `encodeHeader`, then writes a single fresh `Uint8Array(164 + payloadLen)` with three `set` calls — no intermediate copy of the metadata or ciphertext beyond the final placement. `parseChunk` re-uses `parseHeader`'s zero-copy subarray pattern; the returned struct shares its underlying buffer with the input, so callers must clone if they intend to mutate either side independently. `signingPayload` allocates a fresh buffer because the AEAD-side callers must not mutate the wire bytes after sig generation.
|
||||
62
docs/foundation/data/packer/src/chunked/codec/header.md
Normal file
62
docs/foundation/data/packer/src/chunked/codec/header.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# header
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and parses the 100-byte metadata portion of a v1 chunk on its own (without sig or ciphertext). Every chunk on the wire begins with this exact byte layout; receivers parse the header first, validate structural invariants, and only then proceed to signature verification and AEAD decryption. The parser enforces every PACKER-CHUNK-* requirement that can be checked from the header alone — version pin, reserved-flags zero, payloadLen non-final/final invariants, in-range chunkId, sane chunkSize.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeHeader(meta)` writes a fresh 100-byte big-endian buffer at the locked field offsets.
|
||||
- `parseHeader(buf)` reads the first 100 bytes and validates structural invariants.
|
||||
- Returns metadata fields as zero-copy `Uint8Array.subarray` views into the input buffer.
|
||||
- Throws `PackerError` with stable codes on every failure mode (`INVALID_FORMAT`, `VERSION_UNSUPPORTED`, `FLAGS_RESERVED_NONZERO`, `PAYLOAD_LEN_INVALID`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encodeHeader, parseHeader, CHUNK_VERSION_V1 } from "@suaveplan/packer/chunked";
|
||||
|
||||
const meta = {
|
||||
version: CHUNK_VERSION_V1,
|
||||
fileId: new Uint8Array(32),
|
||||
chunkId: 0,
|
||||
totalChunks: 4,
|
||||
fileSize: 4_000_000n,
|
||||
chunkSize: 1_048_576,
|
||||
payloadLen: 1_048_576 - 164,
|
||||
flags: 0,
|
||||
nonce: new Uint8Array(24),
|
||||
aeadTag: new Uint8Array(16),
|
||||
};
|
||||
const bytes = encodeHeader(meta);
|
||||
const round = parseHeader(bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseHeader, PackerError, PACKER_CODES } from "@suaveplan/packer/chunked";
|
||||
|
||||
function safeParse(buf: Uint8Array) {
|
||||
try { return { ok: true as const, meta: parseHeader(buf) }; }
|
||||
catch (err) {
|
||||
if (err instanceof PackerError) return { ok: false as const, code: err.code };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject v2 chunks early without engaging the verify path:
|
||||
const result = safeParse(receivedBytes);
|
||||
if (!result.ok && result.code === PACKER_CODES.VERSION_UNSUPPORTED) {
|
||||
metrics.versionRejects.inc();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`encodeHeader(meta: ChunkV1Metadata): Uint8Array`** — writes 100 bytes at locked offsets. Throws `PackerError(PACKER.INVALID_FORMAT)` on `fileId.byteLength !== 32`, `nonce.byteLength !== 24`, or `aeadTag.byteLength !== 16`.
|
||||
- **`parseHeader(buf: Uint8Array): ChunkV1Metadata`** — validates length >= 100, `version === 0x0001`, `flags === 0x0000`, `totalChunks >= 1`, `chunkId < totalChunks`, `chunkSize > 164`, and the non-final / final `payloadLen` invariants. Returns metadata; field byte ranges are zero-copy subarrays of `buf`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`DataView` is used for all multi-byte numeric reads/writes with `littleEndian: false` to enforce big-endian wire format. `Uint8Array.subarray` (not `slice`) is used for byte-range fields so the parser is zero-allocation in the happy path. The payloadLen invariant uses `BigInt` arithmetic for the final-chunk derivation because `fileSize` is `u64` and may exceed `Number.MAX_SAFE_INTEGER`. The non-final case stays in `number` arithmetic (chunkSize is `u32`).
|
||||
54
docs/foundation/data/packer/src/chunked/crypto/aead.md
Normal file
54
docs/foundation/data/packer/src/chunked/crypto/aead.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# aead
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/crypto.aeadEncrypt` / `aeadDecrypt` (XChaCha20-Poly1305) into helpers that match the v1 chunk wire format, where the ciphertext and Poly1305 tag are stored in separate fields rather than as the noble-style concatenated `ciphertext || tag` buffer. Also provides the deterministic per-chunk nonce derivation used by the chunked packer.
|
||||
|
||||
## Features
|
||||
|
||||
- `aeadSeal(key, nonce, plaintext)` — encrypts and returns `{ ciphertext, aeadTag }` as detached `Uint8Array` instances.
|
||||
- `aeadOpen(key, nonce, ciphertext, aeadTag)` — re-joins the two and decrypts; throws on tag mismatch.
|
||||
- `deriveChunkNonce(baseNonce, chunkId)` — `baseNonce[0..4] XOR chunkId-as-u32-BE`, deterministic and unique per chunk.
|
||||
- All inputs validated; failures wrapped as `PackerError(PACKER.ENCRYPTION_FAILED)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { aeadSeal, aeadOpen, deriveChunkNonce } from "@suaveplan/packer/chunked";
|
||||
|
||||
const fileKey = new Uint8Array(32); // caller-supplied
|
||||
const baseNonce = crypto.getRandomValues(new Uint8Array(24));
|
||||
const nonce = deriveChunkNonce(baseNonce, 0);
|
||||
const { ciphertext, aeadTag } = aeadSeal(fileKey, nonce, plaintext);
|
||||
const back = aeadOpen(fileKey, nonce, ciphertext, aeadTag);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { aeadSeal, deriveChunkNonce } from "@suaveplan/packer/chunked";
|
||||
|
||||
// Seal each chunk with a derived nonce so the manifest only stores baseNonce.
|
||||
function sealAll(plaintexts: Uint8Array[], fileKey: Uint8Array, baseNonce: Uint8Array) {
|
||||
return plaintexts.map((p, i) => {
|
||||
const nonce = deriveChunkNonce(baseNonce, i);
|
||||
const { ciphertext, aeadTag } = aeadSeal(fileKey, nonce, p);
|
||||
return { ciphertext, aeadTag, nonce };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`AeadSealResult`** — `{ ciphertext: Uint8Array; aeadTag: Uint8Array }`.
|
||||
- **`aeadSeal(key, nonce, plaintext): AeadSealResult`** — `key` 32 B, `nonce` 24 B. Returns ciphertext (same length as plaintext) and 16-byte tag.
|
||||
- **`aeadOpen(key, nonce, ciphertext, aeadTag): Uint8Array`** — returns plaintext. Throws `PackerError(PACKER.ENCRYPTION_FAILED)` on tag mismatch or invalid lengths.
|
||||
- **`deriveChunkNonce(baseNonce, chunkId): Uint8Array`** — 24-byte nonce, deterministic per `(baseNonce, chunkId)` pair.
|
||||
- **Constants:** `AEAD_KEY_LEN = 32`, `AEAD_NONCE_LEN = 24`, `AEAD_TAG_LEN = 16`, `FILE_ID_LEN = 32`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seal helper detaches the returned `ciphertext` and `aeadTag` from the underlying combined buffer with `new Uint8Array(view)` so callers can mutate one without aliasing the other. Nonce derivation XORs only the first 32 bits to keep the rest of `baseNonce` intact — the manifest's `baseNonce` plus a `chunkId` is sufficient to reconstruct any chunk's nonce. The tag length is fixed at 16 (Poly1305); `AEAD_TAG_LEN` is exported for callers writing their own decoders.
|
||||
116
docs/foundation/data/packer/src/chunked/manifest/manifest.md
Normal file
116
docs/foundation/data/packer/src/chunked/manifest/manifest.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# manifest
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds, signs, and verifies the v1 manifest — the server-signed object that
|
||||
maps a `fileId` to its full chunk list, encryption parameters (wrapped file
|
||||
key, base nonce), the per-chunk BLAKE3 entry digests, the bundle-level
|
||||
Merkle root, and any free-form metadata (filename, mime, timestamp). The
|
||||
manifest is the single source of truth for "this file is N chunks of M bytes
|
||||
total" and is itself distributed either as a server-signed chunk
|
||||
(`manifest:<fileId>`) or via a server endpoint per the chunks-plugin's
|
||||
`manifestSource` config. The Merkle root and per-chunk `entryDigest` form
|
||||
the integrity layer asserted by `unpackChunksWithManifest`.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildManifest(input, signKey)` — assembles the manifest, computes the
|
||||
Merkle root from sorted per-chunk `entryDigest` values, then ed25519-signs
|
||||
the canonical encoding (sig covers `merkleRoot` and every `entryDigest`).
|
||||
- `verifyManifest(manifest, serverPubKey, options?)` — recomputes the
|
||||
canonical encoding (sans `sig`) and verifies the signature; returns
|
||||
`boolean`.
|
||||
- `chunkEntryFromWire(chunkId, wireBytes, payloadLen)` — builds a
|
||||
`ManifestChunkEntry` with both `hash = sha256(wireBytes)` (for legacy
|
||||
set-reconciliation) and `entryDigest = blake3(wireBytes)` (for integrity).
|
||||
- Deterministic JSON-style canonical encoding: sorted keys, hex-encoded byte
|
||||
fields, decimal-stringified bigints. Identical inputs produce
|
||||
byte-identical canonical bytes across runs and platforms.
|
||||
- `MessageResolver` injection — all thrown messages flow through the i18n
|
||||
catalog.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildManifest, verifyManifest, chunkEntryFromWire } from "@suaveplan/packer/chunked";
|
||||
|
||||
const chunks = packedChunks.map((c) =>
|
||||
chunkEntryFromWire(c.chunkId, c.bytes, c.metadata.payloadLen),
|
||||
);
|
||||
const manifest = buildManifest({
|
||||
fileId, fileSize, chunkSize,
|
||||
encryption: { algo: "xchacha20-poly1305", fileKeyWrapped, baseNonce },
|
||||
chunks,
|
||||
}, signKey);
|
||||
|
||||
console.log(manifest.merkleRoot.byteLength); // 32
|
||||
const ok = verifyManifest(manifest, serverPubKey); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildManifest,
|
||||
verifyManifest,
|
||||
MANIFEST_VERSION_V1,
|
||||
} from "@suaveplan/packer/chunked";
|
||||
import { merkleRootBlake3 } from "@suaveplan/packer/integrity";
|
||||
|
||||
function freezeManifest(m: Manifest): Manifest {
|
||||
if (m.version !== MANIFEST_VERSION_V1) throw new Error("unsupported version");
|
||||
if (!verifyManifest(m, knownServerPubKey)) throw new Error("manifest signature invalid");
|
||||
// Re-derive the Merkle root and cross-check the manifest's recorded value:
|
||||
const sorted = [...m.chunks].sort((a, b) => a.chunkId - b.chunkId);
|
||||
const recomputed = merkleRootBlake3(sorted.map((c) => c.entryDigest));
|
||||
if (!recomputed.every((b, i) => b === m.merkleRoot[i])) {
|
||||
throw new Error("manifest merkleRoot inconsistent with chunk entryDigests");
|
||||
}
|
||||
Object.freeze(m);
|
||||
Object.freeze(m.encryption);
|
||||
Object.freeze(m.chunks);
|
||||
return m;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`interface BuildManifestInput`** — `fileId`, `fileSize`, `chunkSize`,
|
||||
`encryption: BuildManifestEncryption`, `chunks: readonly ManifestChunkEntry[]`,
|
||||
`meta?: BuildManifestMeta`, `messageResolver?: MessageResolver`.
|
||||
- **`buildManifest(input, signKey): Manifest`** — `signKey` must be 32 B.
|
||||
Every `chunks[i].entryDigest` must be 32 B; otherwise throws
|
||||
`PackerError(PACKER.INVALID_FORMAT)`. Computes
|
||||
`merkleRoot = blake3(concat(sort_by_chunkId(entryDigests)))` before signing.
|
||||
- **`verifyManifest(manifest, serverPubKey, options?): boolean`** —
|
||||
`serverPubKey` must be 32 B. Returns `true` iff the canonical encoding
|
||||
(sans `sig`) verifies. The signature covers `merkleRoot` and every
|
||||
`entryDigest` since both are in the canonical encoding.
|
||||
- **`chunkEntryFromWire(chunkId, wireBytes, payloadLen): ManifestChunkEntry`** —
|
||||
computes `hash = sha256(wireBytes)` and `entryDigest = blake3(wireBytes)`.
|
||||
- **`canonicalEncode(value): Uint8Array`** — exported for testing /
|
||||
debugging.
|
||||
- **`MANIFEST_VERSION_V1 = 1`**.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical encoder is hand-rolled because `JSON.stringify` is
|
||||
non-deterministic over object key order and lacks first-class encoding for
|
||||
`Uint8Array` and `BigInt`. The encoder sorts keys ascending lexicographically,
|
||||
omits `undefined`, recurses into arrays in declared order, and emits hex
|
||||
(lowercase) for any `Uint8Array`. The output is UTF-8-encoded JSON-shaped
|
||||
bytes; any compliant JSON parser can read it. Manifests round-trip through
|
||||
this encoder exactly: `verifyManifest(buildManifest(...), pub)` is `true` for
|
||||
any input that doesn't contain `undefined` or non-serializable values.
|
||||
|
||||
The Merkle root is computed once on `buildManifest` from the already-sorted
|
||||
chunk list and recorded as `manifest.merkleRoot`. The signature covers it
|
||||
along with every `entryDigest`, so a tampered `entryDigest` or a tampered
|
||||
`merkleRoot` fails signature verification *and* the explicit per-chunk
|
||||
integrity check inside `unpackChunksWithManifest`. The two checks are
|
||||
intentionally redundant — signing covers the publisher's identity, BLAKE3
|
||||
covers the wire bytes themselves.
|
||||
66
docs/foundation/data/packer/src/chunked/packer/packer.md
Normal file
66
docs/foundation/data/packer/src/chunked/packer/packer.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# packer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Streaming v1 chunked packer: reads plaintext from a `PackSource`, encrypts with XChaCha20-Poly1305 (per-chunk nonce derived from `baseNonce ⊕ chunkId`), server-signs each chunk with ed25519, and yields wire-format chunks one at a time. Used by drive's chunks-plugin during `ingest(path)` and by anything else that needs to produce the v1 chunk format. End-to-end streaming — never materialises the whole plaintext in memory.
|
||||
|
||||
## Features
|
||||
|
||||
- Streaming pipeline: `normalizeSource → accumulate → encrypt → sign → emit`.
|
||||
- Non-final chunks are exactly `chunkSize` bytes (default 1 MiB); final chunk is naturally short.
|
||||
- Deterministic chunks: same `(fileKey, fileId, baseNonce, source)` produces byte-identical output across runs (essential for content-addressing and dedup).
|
||||
- Caller supplies `fileSize` and `fileId` up-front; packer validates `actual == declared` at end.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { packChunks } from "@suaveplan/packer/chunked";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
|
||||
const plaintext = await Bun.file("/data/photo.jpg").bytes();
|
||||
const fileId = sha256(plaintext);
|
||||
const fileKey = crypto.getRandomValues(new Uint8Array(32));
|
||||
const baseNonce = crypto.getRandomValues(new Uint8Array(24));
|
||||
|
||||
for await (const { chunkId, bytes } of packChunks(plaintext, {
|
||||
fileKey,
|
||||
signKey: serverSignKey,
|
||||
fileId,
|
||||
fileSize: BigInt(plaintext.byteLength),
|
||||
baseNonce,
|
||||
})) {
|
||||
await storage.put(`chunks/${toHex(fileId)}/${chunkId}`, bytes);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { packChunks } from "@suaveplan/packer/chunked";
|
||||
|
||||
async function packStream(stream: ReadableStream<Uint8Array>, fileSize: bigint, sigKey: Uint8Array) {
|
||||
const baseNonce = crypto.getRandomValues(new Uint8Array(24));
|
||||
const fileKey = crypto.getRandomValues(new Uint8Array(32));
|
||||
const fileId = crypto.getRandomValues(new Uint8Array(32)); // random fileId
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of packChunks(stream, {
|
||||
fileKey, signKey: sigKey, fileId, fileSize, baseNonce, chunkSize: 256 * 1024,
|
||||
})) {
|
||||
chunks.push(chunk.bytes);
|
||||
}
|
||||
return { chunks, fileKey, baseNonce, fileId };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`interface PackChunkedOptions`** — `fileKey: Uint8Array(32)`, `signKey: Uint8Array(32)`, `fileId: Uint8Array(32)`, `fileSize: bigint`, `baseNonce: Uint8Array(24)`, `chunkSize?: number` (default 1 MiB).
|
||||
- **`interface PackedChunk`** — `chunkId: number`, `bytes: Uint8Array` (wire bytes, 164 + payloadLen), `metadata: ChunkV1Metadata`.
|
||||
- **`packChunks(source, opts): AsyncIterable<PackedChunk>`** — yields `chunkId`-ascending. Throws `PackerError` on input-length mismatches, on `actual != declared fileSize`, or on signing/AEAD failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The accumulator is a plain `Uint8Array` that grows by concat as bytes arrive and shrinks via `slice` after each emit. Memory is bounded at `2 * payloadPer` in the worst case (one chunk's worth in flight + one accumulating). For very large `chunkSize`, a ring-buffer would be more efficient; v1 stays with the simpler model. The "actual size matches declared `fileSize`" invariant is checked twice: incrementally (`totalRead > fileSize` rejects overshooting mid-stream) and at end (`totalRead == fileSize`). Empty files (`fileSize === 0n`) yield zero chunks — the caller handles that case explicitly.
|
||||
69
docs/foundation/data/packer/src/chunked/unpacker/unpacker.md
Normal file
69
docs/foundation/data/packer/src/chunked/unpacker/unpacker.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# unpacker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Streaming receive-side primitive for the v1 chunked format. Consumes an `AsyncIterable<Uint8Array>` of chunk-on-the-wire bytes and emits an `AsyncIterable<Uint8Array>` of decrypted plaintext slices. Performs the full receive-side gate per chunk: structural parse → ed25519 server-signature verify → AEAD decrypt. Used by drive's chunks-plugin read middleware to reassemble files on the fly without materialising the whole plaintext in memory.
|
||||
|
||||
## Features
|
||||
|
||||
- Streaming: never buffers the whole file.
|
||||
- Per-chunk verification before any AEAD work — forged chunks are rejected before key material touches them.
|
||||
- Order check (`chunkId === prevChunkId + 1`) — out-of-order delivery is the caller's responsibility to fix before passing to `unpackChunks`.
|
||||
- AbortSignal honoured between chunks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { unpackChunks } from "@suaveplan/packer/chunked";
|
||||
|
||||
async function* receive() {
|
||||
yield wire0;
|
||||
yield wire1;
|
||||
yield wire2;
|
||||
}
|
||||
|
||||
for await (const slice of unpackChunks(receive(), { fileKey, serverPubKey })) {
|
||||
process.stdout.write(slice);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { unpackChunks } from "@suaveplan/packer/chunked";
|
||||
|
||||
async function fileFromMesh(fileId: Uint8Array, mesh: ChunkSource) {
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 30_000);
|
||||
const orderedChunks = mesh.streamChunksInOrder(fileId);
|
||||
const out: Uint8Array[] = [];
|
||||
try {
|
||||
for await (const slice of unpackChunks(orderedChunks, {
|
||||
fileKey,
|
||||
serverPubKey,
|
||||
signal: ac.signal,
|
||||
})) {
|
||||
out.push(slice);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(ac as unknown as number);
|
||||
}
|
||||
return concatBytes(out);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`interface UnpackChunkedOptions { fileKey: Uint8Array; serverPubKey: Uint8Array; signal?: AbortSignal }`**
|
||||
- **`unpackChunks(chunks, opts): AsyncIterable<Uint8Array>`**
|
||||
- `chunks` MUST yield in `chunkId` ascending order; out-of-order yields throw `PackerError(PACKER.INVALID_FORMAT)`.
|
||||
- Each yielded plaintext slice is exactly `metadata.payloadLen` bytes for that chunk.
|
||||
- On any failure (parse, sig, AEAD, signal aborted) the iterator throws and stops.
|
||||
- `fileKey` and `serverPubKey` MUST both be 32 bytes — wrong-length keys throw `PackerError(PACKER.INVALID_FORMAT)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`unpackChunks` re-builds the canonical signing-payload bytes for each chunk via `encodeHeader` + `signingPayload`. This is intentionally redundant with what the producer signed: the receiver MUST NOT trust the bytes-as-received to be canonical (a malicious peer could swap fields and append padding), only the field values extracted by `parseChunk`. After signature verification, the AEAD decryption step is what enforces ciphertext integrity at the byte level (Poly1305 covers the same range as the ed25519 sig). Out-of-order delivery is rejected aggressively because reordering inside `unpackChunks` would require buffering — that policy belongs to the caller (typically mesh's scheduler).
|
||||
@@ -0,0 +1,61 @@
|
||||
# chunk-verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Verifies the structural validity and ed25519 server signature of a v1 chunk **without** the file decryption key. Used by mesh peers that forward chunks they cannot themselves read, by drive's chunks-plugin during the receive-side gate (sig-verify before persistence), and by any consumer that wants to reject forged chunks before engaging the AEAD path.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChunkVerifier(serverPubKey)` — single-server-key instance; reusable across many chunks.
|
||||
- `verify(chunkBytes): VerifyResult` — returns `{ ok: true, metadata }` or `{ ok: false, code, message }`. Never throws on verifiable-but-bad input; only throws on programmer error (wrong-length pubkey).
|
||||
- Performs the full PACKER-CHUNK-* invariant chain (version, flags, payloadLen) before signature check.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ChunkVerifier } from "@suaveplan/packer/chunked";
|
||||
|
||||
const verifier = new ChunkVerifier(serverPubKey);
|
||||
const result = verifier.verify(receivedChunkBytes);
|
||||
if (!result.ok) {
|
||||
console.warn("rejected chunk:", result.code, result.message);
|
||||
} else {
|
||||
persistChunk(receivedChunkBytes, result.metadata);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ChunkVerifier, PACKER_CODES } from "@suaveplan/packer/chunked";
|
||||
|
||||
const verifier = new ChunkVerifier(serverPubKey);
|
||||
async function gate(stream: AsyncIterable<Uint8Array>) {
|
||||
let valid = 0;
|
||||
let badSig = 0;
|
||||
let badFormat = 0;
|
||||
for await (const raw of stream) {
|
||||
const r = verifier.verify(raw);
|
||||
if (r.ok) { valid += 1; continue; }
|
||||
if (r.code === PACKER_CODES.VERIFICATION_FAILED) badSig += 1;
|
||||
else badFormat += 1;
|
||||
}
|
||||
return { valid, badSig, badFormat };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`interface VerifyOk { ok: true; metadata: ChunkV1Metadata }`**
|
||||
- **`interface VerifyFail { ok: false; code: PackerErrorCode; message: string }`**
|
||||
- **`type VerifyResult = VerifyOk | VerifyFail`**
|
||||
- **`class ChunkVerifier`**
|
||||
- `constructor(serverPubKey: Uint8Array)` — throws `PackerError(PACKER.INVALID_FORMAT)` if `serverPubKey.byteLength !== 32`.
|
||||
- `verify(chunkBytes: Uint8Array): VerifyResult` — never throws on bad chunks; rejected chunks come back as `VerifyFail`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`verify` first runs `parseChunk`, which surfaces structural failures (`INVALID_FORMAT`, `VERSION_UNSUPPORTED`, `FLAGS_RESERVED_NONZERO`, `PAYLOAD_LEN_INVALID`) as `PackerError` — these are caught and folded into `VerifyFail`. Only after the structural gate is the ed25519 signature checked against the canonical signing payload `metadata[0..100] ‖ ciphertext[0..payloadLen]`. Cost: one `parseChunk` (zero-copy), one 100-byte `encodeHeader` allocation, one signing-payload concatenation (allocates `100 + payloadLen` bytes), one `ed25519.verify`.
|
||||
52
docs/foundation/data/packer/src/error-codes.md
Normal file
52
docs/foundation/data/packer/src/error-codes.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error code registry for `@suaveplan/packer`. Every failure surfaced by the package — invalid inputs, AEAD failures, signature verification failures, format violations on the chunk wire — is wrapped in a `PackerError` carrying one of the codes declared here. Consumers route on the code, never on the message text.
|
||||
|
||||
## Features
|
||||
|
||||
- `PACKER_CODES`: frozen object of namespaced code strings (`PACKER.*`).
|
||||
- `PackerErrorCode`: union of every code value, derived from `PACKER_CODES`.
|
||||
- `PACKER_ERROR_METADATA`: per-code HTTP status, severity, retryability, and human description.
|
||||
- `PackerError`: base class extending `@suaveplan/error.SuaveplanError`, name `"PackerError"`.
|
||||
- `PACKER.INTEGRITY_FAILED`: raised when the BLAKE3 per-entry digest or the bundle-level Merkle root recomputed at unpack time does not match the value recorded by `Packer.pack` / `buildManifest`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PackerError, PACKER_CODES } from "@suaveplan/packer/errors";
|
||||
|
||||
if (!validateSource(input)) {
|
||||
throw new PackerError(PACKER_CODES.INVALID_SOURCE, "expected Uint8Array | Blob | ReadableStream");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PACKER_CODES, PACKER_ERROR_METADATA, PackerError } from "@suaveplan/packer/errors";
|
||||
|
||||
function toHttpResponse(err: unknown) {
|
||||
if (err instanceof PackerError) {
|
||||
const meta = PACKER_ERROR_METADATA[err.code];
|
||||
return {
|
||||
status: meta.httpStatus,
|
||||
body: { code: err.code, message: err.message, retryable: meta.retryable },
|
||||
};
|
||||
}
|
||||
return { status: 500, body: { code: "UNKNOWN" } };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PACKER_CODES: Readonly<Record<Reason, "PACKER.${Reason}">>` — frozen via `defineCodes`.
|
||||
- `type PackerErrorCode = typeof PACKER_CODES[keyof typeof PACKER_CODES]`.
|
||||
- `interface PackerCodeMetadata { httpStatus: number; severity: "info" | "warning" | "error"; retryable: boolean; description: string; }`.
|
||||
- `PACKER_ERROR_METADATA: Readonly<Record<PackerErrorCode, PackerCodeMetadata>>`.
|
||||
- `class PackerError<TCode extends PackerErrorCode = PackerErrorCode> extends SuaveplanError<TCode>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built via `defineCodes("PACKER", [...] as const)` so every code value is a template literal `PACKER.${Reason}` and the return type is derived without manual annotation. The metadata table is `Object.freeze`d so consumers can rely on referential stability when used as Map keys. `PackerError` does not override `cause` chain handling — `SuaveplanError` already covers that via `SuaveplanErrorOptions`.
|
||||
107
docs/foundation/data/packer/src/flags/flags.md
Normal file
107
docs/foundation/data/packer/src/flags/flags.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# `flags/flags` — feature-flagged format-version rollouts
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`flags.ts` wires `@suaveplan/packer` into the `@suaveplan/feature-flags`
|
||||
ecosystem so future wire-format versions can be rolled out behind
|
||||
runtime flags without forcing every consumer to upgrade in lockstep.
|
||||
Today the package writes V1 unconditionally; the flag surface is the
|
||||
contract that a future V2 codec slots into. The exported
|
||||
`defaultPackerFlags()` provider is a no-op that returns the V1 default
|
||||
for every flag, so callers who don't care about rollouts never have to
|
||||
configure anything.
|
||||
|
||||
## Features
|
||||
|
||||
- `PACKER_FLAG_KEYS.FORMAT_V2 = "packer.format.v2"` — single source of
|
||||
truth for the V2 rollout flag key.
|
||||
- `defaultPackerFlags()` — no-op provider; every flag resolves to its
|
||||
V1 default (`false` for `packer.format.v2`).
|
||||
- `resolvePackerFlags(opts)` — adapter that accepts either a raw
|
||||
`PackerFeatureFlags` provider or a `{ evaluator, context }` bundle
|
||||
pointing at a `@suaveplan/feature-flags` `Evaluator`.
|
||||
- `enforceFormatVersionGate(flags, resolver)` — production write-path
|
||||
helper that throws `PACKER.FORMAT_VERSION_UNSUPPORTED` when V2 is
|
||||
enabled (V2 codec is a future change).
|
||||
- Boolean flags only — the package's flag surface is intentionally
|
||||
small; semver versions are records under `@suaveplan/schema-registry`,
|
||||
not under feature flags.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
|
||||
// No `flags` option — V1 default behaviour.
|
||||
const packer = new Packer({ encryption: { algorithm: "aes-256-gcm", key } });
|
||||
const packed = await packer.pack(payload);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
Evaluator,
|
||||
FlagRegistry,
|
||||
MemoryProvider,
|
||||
UserTargetStrategy,
|
||||
} from "@suaveplan/feature-flags";
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
import { PACKER_FLAG_KEYS } from "@suaveplan/packer/flags";
|
||||
|
||||
// Roll out V2 to a 1% canary via a feature-flags Evaluator.
|
||||
const provider = new MemoryProvider();
|
||||
const registry = new FlagRegistry({ provider });
|
||||
registry.register({
|
||||
key: PACKER_FLAG_KEYS.FORMAT_V2,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
});
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [
|
||||
new UserTargetStrategy({ users: ["canary-1"], value: true }),
|
||||
],
|
||||
});
|
||||
|
||||
const packer = new Packer({
|
||||
encryption: { algorithm: "aes-256-gcm", key },
|
||||
flags: { evaluator, context: { userId: "canary-1" } },
|
||||
});
|
||||
|
||||
// In this canary build the call would throw FORMAT_VERSION_UNSUPPORTED
|
||||
// until V2 ships.
|
||||
await packer.pack(payload).catch((err) => {
|
||||
console.log(err.code); // "PACKER.FORMAT_VERSION_UNSUPPORTED"
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultPackerFlags(): PackerFeatureFlags`
|
||||
- No-op provider returning V1 defaults for every flag.
|
||||
- `resolvePackerFlags(opts?: PackerFlagsOptions): PackerFeatureFlags`
|
||||
- Adapts either `{ evaluator, context? }` or `{ provider }` into the
|
||||
internal `PackerFeatureFlags` surface; defaults to
|
||||
`defaultPackerFlags()` when `opts` is undefined.
|
||||
- `enforceFormatVersionGate(flags, resolver): void`
|
||||
- Throws `PackerError(PACKER.FORMAT_VERSION_UNSUPPORTED)` when
|
||||
`packer.format.v2` resolves to `true`.
|
||||
- `PACKER_FLAG_KEYS`
|
||||
- Frozen object exposing every flag key used by the package.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Evaluator` adapter constructs a `FlagDefinition` on the fly from
|
||||
the internal `FLAG_DEFINITIONS` table — this keeps the public surface
|
||||
flat (callers don't have to register flags themselves) while still
|
||||
flowing through the canonical evaluation pipeline (strategies, default
|
||||
reasons, OTel counters wired up by `@suaveplan/feature-flags`). The
|
||||
provider surface is boolean-only by design; multi-valued flags would be
|
||||
a different abstraction. The V2 gate is invoked once per `Packer.pack`
|
||||
call, after option validation and idempotency lookup but before any
|
||||
expensive pipeline work, so an enabled V2 flag fails fast without
|
||||
producing partial side effects.
|
||||
94
docs/foundation/data/packer/src/i18n/messages.md
Normal file
94
docs/foundation/data/packer/src/i18n/messages.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `i18n/messages` — i18n-keyed packer error catalog
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/packer/i18n` carries the per-package message catalog every error
|
||||
thrown by `@suaveplan/packer` flows through. It exists to remove every
|
||||
hard-coded English string from `src/` and route them via the
|
||||
`MessageResolver` contract from `@suaveplan/types`, so consumers running in
|
||||
non-English locales can swap in their own resolver without forking the
|
||||
package. The catalog also doubles as a registry of every distinguishable
|
||||
error scenario, giving documentation and on-call dashboards a stable index
|
||||
of failure shapes.
|
||||
|
||||
## Features
|
||||
|
||||
- English-defaults catalog keyed by `packer.error.<CODE>.<scenario>` covering
|
||||
every throw site in `src/`.
|
||||
- `defaultPackerResolver` — pass-through that interpolates `${name}` tokens
|
||||
from the params object; returns the bare key on miss (never reached in
|
||||
production paths, asserted by the conformance test).
|
||||
- `resolvePackerMessage(resolver, key, params)` — single call shape used at
|
||||
every throw site; falls back to the default resolver when none is supplied
|
||||
via constructor options.
|
||||
- Catalog is `Object.freeze`d to prevent runtime mutation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PACKER_MESSAGES, defaultPackerResolver } from "@suaveplan/packer/i18n";
|
||||
|
||||
const msg = defaultPackerResolver(
|
||||
"packer.error.ENCRYPTION_FAILED.key-wrong-length",
|
||||
{ got: 31 },
|
||||
);
|
||||
console.log(msg); // "encryption.key must be 32 bytes, got 31."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject a custom resolver into a `Packer` instance to localise every error:
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
import type { MessageResolver } from "@suaveplan/packer/i18n";
|
||||
import { translations } from "./my-catalog.js"; // app-supplied
|
||||
|
||||
const fr: MessageResolver = (key, params) => {
|
||||
const tpl = translations.fr[key] ?? translations.en[key] ?? key;
|
||||
return tpl.replace(/\$\{(\w+)\}/g, (_, n) =>
|
||||
params?.[n] === undefined ? "" : String(params[n]),
|
||||
);
|
||||
};
|
||||
|
||||
const p = new Packer({ messageResolver: fr });
|
||||
try {
|
||||
await p.pack(new Uint8Array(0), undefined);
|
||||
} catch (err) {
|
||||
// err.message is the French translation of the catalog entry
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PACKER_MESSAGES: PackerMessageCatalog` — frozen catalog of English defaults
|
||||
keyed by `packer.error.<CODE>.<scenario>`. Every key is reachable from a
|
||||
throw site in `src/` (enforced by `messages.spec-conformance.test.ts`).
|
||||
- `defaultPackerResolver: MessageResolver` — looks up the catalog and
|
||||
interpolates `${name}` tokens from params. Returns the key verbatim when
|
||||
the catalog has no entry (used only as a defensive fallback).
|
||||
- `resolvePackerMessage(resolver, key, params): string` — entry point at
|
||||
every `throw new PackerError(...)` site. Accepts an optional caller-supplied
|
||||
resolver and falls back to `defaultPackerResolver` when undefined.
|
||||
- `MessageResolver` — re-exported from `@suaveplan/types` for ergonomics.
|
||||
- `PackerMessageCatalog` — type alias for the catalog shape
|
||||
(`Readonly<Record<string, string>>`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `MessageResolver` is the canonical contract from `@suaveplan/types`. The
|
||||
package does not depend on `@suaveplan/i18n` (which would pull the full
|
||||
ICU pipeline); per-package catalogs are sufficient for stable error
|
||||
messaging and avoid coupling every library to the i18n runtime.
|
||||
- Placeholder interpolation uses a deliberately tiny `${name}` syntax instead
|
||||
of ICU MessageFormat. Errors are simple `key: value` substitutions and
|
||||
shipping an ICU parser inside `@suaveplan/packer` would be overkill.
|
||||
- The catalog is frozen at module load. Tests assert `Object.isFrozen` and
|
||||
reject mutations.
|
||||
- Adding a new throw site MUST add a matching catalog key in the same
|
||||
change-set; the conformance test fails on either a key without a referencing
|
||||
throw or a throw without a key.
|
||||
116
docs/foundation/data/packer/src/idempotency/idempotency.md
Normal file
116
docs/foundation/data/packer/src/idempotency/idempotency.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# `@suaveplan/packer/idempotency` — Idempotent repeat-pack cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Repeat packs over identical inputs are a guaranteed no-op: the second call
|
||||
returns the cached `PackedData` without re-running the gzip / AEAD /
|
||||
ed25519 / BLAKE3-integrity pipeline. Cluster #1 already paid the cost of
|
||||
computing a content-addressable BLAKE3 digest per pack — this module turns
|
||||
that digest into the cache key for an opt-in idempotency layer backed by
|
||||
`@suaveplan/idempotency`. The audit line in `enterprise/packer.md` (L47)
|
||||
mandates "**Idempotency** — repeat packs over the same inputs are a no-op".
|
||||
|
||||
The layer is opt-in: callers that do not pass `PackerOptions.idempotency`
|
||||
see the legacy non-cached behaviour. Callers that opt in get a content-
|
||||
addressable cache backed by any `IdempotencyStoreAdapter` —
|
||||
`MemoryIdempotencyStore` for single-process, Redis / database adapters for
|
||||
distributed deployments.
|
||||
|
||||
## Features
|
||||
|
||||
- `PackIdempotencyGuard` — wraps an `IdempotencyStoreAdapter<PackedData>`
|
||||
into a `lookup(key)` / `record(key, packed)` pair tailored for the pack
|
||||
pipeline.
|
||||
- `computePackDedupKey(data, fingerprint)` — derives the cache key from
|
||||
`blake3(data)` plus the pipeline fingerprint (compression level,
|
||||
encryption algorithm, signing-enabled bit, filename). Two packs with
|
||||
identical bytes but different pipeline parameters do not collide.
|
||||
- Default `MemoryIdempotencyStore<PackedData>` when no store is supplied
|
||||
but the guard is enabled, so callers can opt in without choosing a
|
||||
backend up-front.
|
||||
- Configurable TTL (default 24 h) and an `enabled` toggle for runtime
|
||||
feature-flag style off-switches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
import { MemoryIdempotencyStore } from "@suaveplan/idempotency";
|
||||
|
||||
const store = new MemoryIdempotencyStore();
|
||||
const packer = new Packer({ idempotency: { store } });
|
||||
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
const a = await packer.pack(data);
|
||||
const b = await packer.pack(data); // cache hit — no pipeline work
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
import {
|
||||
computePackDedupKey,
|
||||
PackIdempotencyGuard,
|
||||
} from "@suaveplan/packer/idempotency";
|
||||
import { RedisIdempotencyStore } from "@suaveplan/idempotency";
|
||||
|
||||
// Distributed deployment with explicit cache key inspection.
|
||||
const store = new RedisIdempotencyStore<PackedData>(/* config */);
|
||||
const packer = new Packer({
|
||||
idempotency: { store, ttlMs: 3_600_000 }, // 1 hour
|
||||
encryption: { algorithm: "xchacha20-poly1305", key: aeadKey },
|
||||
signing: { key: signingKey },
|
||||
});
|
||||
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
const key = computePackDedupKey(data, {
|
||||
compressionLevel: 6,
|
||||
encrypted: true,
|
||||
encryptionAlgorithm: "xchacha20-poly1305",
|
||||
signed: true,
|
||||
filename: undefined,
|
||||
});
|
||||
console.log("dedup key:", key);
|
||||
const bundle = await packer.pack(data);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PackIdempotencyGuard.constructor(opts, clock)` — wraps an injected
|
||||
`IdempotencyStoreAdapter<PackedData>`. Uses the supplied `Clock` for TTL
|
||||
computation so determinism is preserved when callers inject a
|
||||
`FakeClock`-backed value.
|
||||
- `PackIdempotencyGuard.enabled: boolean` — reflects `opts.enabled` (true
|
||||
by default).
|
||||
- `PackIdempotencyGuard.lookup(key): Promise<PackedData | undefined>` —
|
||||
returns a cached `PackedData` on hit, `undefined` on miss / when
|
||||
disabled / when the record is not `completed`.
|
||||
- `PackIdempotencyGuard.record(key, packed): Promise<void>` — writes a
|
||||
completed record under `key` with the configured TTL. No-op when
|
||||
disabled.
|
||||
- `computePackDedupKey(data, fingerprint): string` — derives the cache key
|
||||
from `blake3(data)` + stringified pipeline fingerprint.
|
||||
- `PackPipelineFingerprint` — `{ compressionLevel, encrypted,
|
||||
encryptionAlgorithm, signed, filename }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cache value is the full `PackedData` (bytes + metadata). For
|
||||
encrypted bundles, the AEAD nonce is captured on the metadata so the
|
||||
cached envelope decrypts deterministically without needing the original
|
||||
RNG state.
|
||||
- `lookup` checks `record.status === "completed"`; pending and failed
|
||||
records are treated as misses so an interrupted pack from a different
|
||||
caller doesn't poison the cache.
|
||||
- The dedup key is human-readable hex (not just bytes) so operators can
|
||||
inspect Redis / DB rows without a decoder layer. The BLAKE3 output is
|
||||
256 bits so cache-key collision probability is negligible (2^-256).
|
||||
- The guard does not own the store — callers wire and tear down the
|
||||
store. A default `MemoryIdempotencyStore` is constructed only when no
|
||||
store is supplied but the guard is otherwise enabled; in that mode the
|
||||
cache lives for the lifetime of the `Packer` instance.
|
||||
108
docs/foundation/data/packer/src/integrity/integrity.md
Normal file
108
docs/foundation/data/packer/src/integrity/integrity.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `integrity/integrity` — BLAKE3 per-entry digest and bundle-level Merkle root
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/packer/integrity` provides the content-addressable integrity layer
|
||||
the rest of the package uses to detect any tampering or accidental corruption
|
||||
of packed bundles. It computes a per-entry BLAKE3-256 digest and a
|
||||
bundle-level Merkle root over the sorted entry digests; both are recorded in
|
||||
the produced envelope (whole-file `PackedDataMetadata.integrity`, chunked
|
||||
`Manifest.merkleRoot` plus per-entry `ManifestChunkEntry.entryDigest`) and
|
||||
verified on read. Verification coexists with the existing ed25519 signing
|
||||
pipeline — neither replaces the other — because BLAKE3 is fast enough to run
|
||||
on every chunk without measurable overhead while ed25519 still anchors the
|
||||
publisher's identity.
|
||||
|
||||
## Features
|
||||
|
||||
- BLAKE3-256 one-shot digest of arbitrary bytes via `@noble/hashes/blake3`.
|
||||
- `merkleRootBlake3(digests)` over already-sorted entry digests.
|
||||
- `computeWholeFileIntegrity(payload)` convenience producing the `{ entryDigest, merkleRoot }`
|
||||
pair stored on `PackedDataMetadata.integrity`.
|
||||
- Constant-time `bytesEqual` for use in verify paths.
|
||||
- Strict input validation: every digest must be exactly 32 bytes; mismatches
|
||||
raise `TypeError` (programmer error — not a packer error code).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
entryDigestBlake3,
|
||||
merkleRootBlake3,
|
||||
computeWholeFileIntegrity,
|
||||
} from "@suaveplan/packer/integrity";
|
||||
|
||||
const payload = new TextEncoder().encode("hello world");
|
||||
const digest = entryDigestBlake3(payload);
|
||||
console.log(digest.byteLength); // 32
|
||||
|
||||
const integrity = computeWholeFileIntegrity(payload);
|
||||
console.log(integrity.entryDigest.byteLength); // 32
|
||||
console.log(integrity.merkleRoot.byteLength); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Build a Merkle root over a chunked bundle's entries, sorted by `chunkId`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
entryDigestBlake3,
|
||||
merkleRootBlake3,
|
||||
bytesEqual,
|
||||
} from "@suaveplan/packer/integrity";
|
||||
|
||||
const chunks = [/* wire bytes from packChunks(...) */];
|
||||
const digests = chunks
|
||||
.map((c) => ({ id: c.chunkId, d: entryDigestBlake3(c.bytes) }))
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((x) => x.d);
|
||||
const root = merkleRootBlake3(digests);
|
||||
|
||||
// Later, on read:
|
||||
const recomputed = merkleRootBlake3(digests);
|
||||
if (!bytesEqual(root, recomputed)) {
|
||||
throw new Error("Merkle root drifted between pack and verify");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `entryDigestBlake3(data: Uint8Array): Uint8Array` — BLAKE3-256 of `data`.
|
||||
Returns a fresh 32-byte array.
|
||||
- `merkleRootBlake3(digests: readonly Uint8Array[]): Uint8Array` — BLAKE3-256
|
||||
of the byte concatenation of `digests` (caller supplies sort order).
|
||||
Empty input maps to `blake3("")`. Each digest must be 32 bytes;
|
||||
mismatches throw `TypeError`.
|
||||
- `computeWholeFileIntegrity(payload): PackedDataIntegrity` — single-entry
|
||||
convenience returning `{ entryDigest: blake3(payload), merkleRoot: blake3(blake3(payload)) }`.
|
||||
- `bytesEqual(a, b): boolean` — constant-time byte-equality test on two
|
||||
`Uint8Array`s.
|
||||
- `BLAKE3_DIGEST_LEN: 32` — exported constant for callers that need the
|
||||
digest length without recomputing.
|
||||
- `PackedDataIntegrity` — `{ entryDigest, merkleRoot }` shape stored on
|
||||
`PackedDataMetadata.integrity`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- BLAKE3 is sourced from `@noble/hashes/blake3`. The package adds
|
||||
`@noble/hashes` as a direct dependency (catalog `crypto`) rather than going
|
||||
through `@suaveplan/crypto-hash`, which does not yet surface BLAKE3 —
|
||||
exposing it there is tracked separately and is out of scope for this change.
|
||||
- The Merkle root scheme is intentionally flat (single-level hash of sorted
|
||||
concatenated leaves) rather than a binary tree. The bundle-level root's job
|
||||
is to detect any leaf substitution, which a flat root accomplishes; binary
|
||||
Merkle proofs are not in the public surface and adding them later does not
|
||||
invalidate the flat root.
|
||||
- Domain separation: the whole-file Merkle root wraps the lone entry digest
|
||||
in a second BLAKE3 call so that a verifier never confuses a leaf digest
|
||||
with a root. The same applies to chunked manifests — `merkleRoot` is
|
||||
always `blake3(concat(...))`, never the bare concatenation.
|
||||
- `bytesEqual` is intentionally constant-time. Integrity verification is not
|
||||
a cryptographic primitive (the signature does that), but the verify path
|
||||
sits on the request hot path and constant-time comparisons keep the
|
||||
semantics consistent with the rest of the package.
|
||||
122
docs/foundation/data/packer/src/observability/observability.md
Normal file
122
docs/foundation/data/packer/src/observability/observability.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# `observability/observability` — OpenTelemetry instruments and spans for `@suaveplan/packer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/packer/observability` centralises every OpenTelemetry counter,
|
||||
histogram, and span the package emits, so a single injection point on the
|
||||
public entrypoints (`Packer.pack`, `Unpacker.unpack`, `packChunks`,
|
||||
`unpackChunks`, `unpackChunksWithManifest`) wires the full telemetry surface.
|
||||
The defaults pull the global meter and tracer from `@opentelemetry/api`, so
|
||||
packages running outside a wired-up OTel pipeline pay zero cost and emit
|
||||
no-op telemetry while still letting tests inject a real in-memory
|
||||
`MeterProvider` and `BasicTracerProvider` to assert behavior.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy creation of every named instrument the package emits via
|
||||
`createCounter` / `createHistogram` from `@suaveplan/opentelemetry`.
|
||||
- `withPackerSpan` async helper that wraps `fn` in a child span, records
|
||||
exceptions, and sets `SpanStatusCode.ERROR` on failure.
|
||||
- `withPackerSpanSync` for synchronous hot paths (per-entry decode).
|
||||
- Caller can pass a fully-formed `PackerObservability` (e.g. shared across an
|
||||
app), individual `Meter` / `Tracer` instances, or nothing — defaults to the
|
||||
global no-op providers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createPackerObservability } from "@suaveplan/packer/observability";
|
||||
|
||||
const obs = createPackerObservability(); // uses global no-op meter / tracer
|
||||
obs.counters.packTotal.add(1);
|
||||
obs.histograms.packDurationMs.record(12.3);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire a real in-memory `MeterProvider` and `BasicTracerProvider` for tests:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { createPackerObservability, withPackerSpan } from "@suaveplan/packer/observability";
|
||||
|
||||
const metricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE,
|
||||
);
|
||||
const meterProvider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: metricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const spanExporter = new InMemorySpanExporter();
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
|
||||
});
|
||||
|
||||
const obs = createPackerObservability({
|
||||
meter: meterProvider.getMeter("test"),
|
||||
tracer: tracerProvider.getTracer("test"),
|
||||
});
|
||||
|
||||
await withPackerSpan(obs, "demo", async (span) => {
|
||||
obs.counters.packTotal.add(1, { algorithm: "demo" });
|
||||
obs.histograms.packDurationMs.record(7.5);
|
||||
span.setAttribute("demo", true);
|
||||
});
|
||||
|
||||
await meterProvider.forceFlush();
|
||||
const collected = metricExporter.getMetrics();
|
||||
const spans = spanExporter.getFinishedSpans();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPackerObservability(opts?): PackerObservability` — produces the
|
||||
instrument bundle. Accepts `{ meter?, tracer?, observability? }`.
|
||||
When `observability` is supplied it is returned directly (caller already
|
||||
built the bundle); otherwise instruments are created lazily on
|
||||
`opts.meter` and spans open on `opts.tracer`.
|
||||
- `withPackerSpan(obs, name, fn, attributes?): Promise<T>` — wraps `fn` in a
|
||||
child span, ends the span in a `finally`-shaped scope, and propagates the
|
||||
span's exception/status on throw.
|
||||
- `withPackerSpanSync(obs, name, fn, attributes?): T` — synchronous variant
|
||||
for per-entry decode loops in the chunked unpacker.
|
||||
- `PackerObservability` — `{ meter, tracer, counters, histograms }`.
|
||||
- `PackerCounters` — `{ bundleSizeBytes, entryCount, packTotal, unpackTotal, integrityFailureTotal }`.
|
||||
- `PackerHistograms` — `{ packDurationMs, unpackDurationMs, entryDecodeMs }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The no-op meter / tracer from `@opentelemetry/api` are safe to call: their
|
||||
`.add` / `.record` / `startActiveSpan` implementations are inert. The
|
||||
default path therefore has zero observable cost when telemetry is
|
||||
disabled, which is the right contract for a library shipped to consumers
|
||||
who may or may not wire OTel.
|
||||
- `withPackerSpan` deliberately uses a `Promise` constructor over async/await
|
||||
inside `startActiveSpan` so the span is opened in the correct call-context
|
||||
before any async resumption boundary. The OTel API expects the callback to
|
||||
return the value rather than expose the span via a `using` block.
|
||||
- Span names follow the convention `packer.<entrypoint>` (`packer.pack`,
|
||||
`packer.unpack`, `packer.pack_chunks`, `packer.unpack_chunks`,
|
||||
`packer.unpack_chunks_with_manifest`). Counters / histograms follow the
|
||||
snake-case OTel convention used elsewhere in the suaveplan ecosystem.
|
||||
- Instrument descriptions are intentionally short and consumer-facing — they
|
||||
surface verbatim in dashboards.
|
||||
59
docs/foundation/data/packer/src/packer/packer.md
Normal file
59
docs/foundation/data/packer/src/packer/packer.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Packer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Whole-file packer: takes a `Uint8Array` of arbitrary size and produces a `PackedData = { data, metadata }` envelope after optional gzip compression, optional AEAD encryption (XChaCha20-Poly1305 / ChaCha20-Poly1305 / AES-256-GCM), and optional ed25519 signing. Used for self-contained pack-and-go: small assets, encrypted archives, signed bundles. The whole plaintext lives in memory during pack — for streaming P2P-distributable chunks use `@suaveplan/packer/chunked`.
|
||||
|
||||
## Features
|
||||
|
||||
- gzip compression (level 0–9; 0 disables) via Node's `zlib.gzipSync`.
|
||||
- Three AEAD algorithms via `@suaveplan/crypto.aeadEncrypt`.
|
||||
- Optional ed25519 server signing via `@suaveplan/crypto.ed25519.sign`.
|
||||
- SHA-256 checksum of original (pre-transform) bytes for integrity verification at unpack time.
|
||||
- Typed events: `pack:start`, `pack:compressed`, `pack:encrypted`, `pack:signed`, `pack:complete`.
|
||||
- MIME detection from filename via `@suaveplan/mime`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
|
||||
const packer = new Packer({ compressionLevel: 6 });
|
||||
const { data, metadata } = await packer.pack(bytes, "report.pdf");
|
||||
console.log(metadata.compressedSize, metadata.checksum);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
|
||||
const fileKey = crypto.getRandomValues(new Uint8Array(32));
|
||||
const signKey = ed25519.randomPrivateKey();
|
||||
const packer = new Packer({
|
||||
compressionLevel: 9,
|
||||
encryption: { algorithm: "xchacha20-poly1305", key: fileKey },
|
||||
signing: { key: signKey },
|
||||
metadata: { author: "alice", version: 2 },
|
||||
});
|
||||
|
||||
packer.on("pack:compressed", (e) => console.log("compressed:", e.payload.compressedSize));
|
||||
packer.on("pack:complete", (e) => console.log("done. signed:", e.payload.signed));
|
||||
|
||||
const packed = await packer.pack(htmlBytes, "page.html");
|
||||
// packed.data = ciphertext; packed.metadata.{nonce, aeadTag, signature, ...}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`class Packer extends AbstractPacker`**
|
||||
- Inherits constructor `(options?: PackerOptions)`.
|
||||
- `pack(data: Uint8Array, filename?: string): Promise<PackedData>` — runs the configured pipeline. Throws `PackerError(PACKER.ENCRYPTION_FAILED)` on `encryption.key.byteLength !== 32` and `PackerError(PACKER.SIGNATURE_FAILED)` on `signing.key.byteLength !== 32`.
|
||||
- Inherits `packStream(source, filename?)` from `AbstractPacker`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The signature is computed over the post-encryption ciphertext (`payload`), not the plaintext. This means `Unpacker` can verify the signature without holding the AEAD key — useful for trustless forwarding. Random nonces are pulled from `crypto.getRandomValues`. The AEAD output (combined ciphertext+tag from noble) is split into separate `nonce`/`aeadTag` fields so the metadata is self-describing without callers needing to know about noble's concat convention.
|
||||
112
docs/foundation/data/packer/src/plugins/plugins.md
Normal file
112
docs/foundation/data/packer/src/plugins/plugins.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# `plugins/plugins` — plugin / extension interface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`plugins.ts` exposes a small, opinionated extension surface that lets
|
||||
downstream packages contribute behaviour to `Packer` / `Unpacker`
|
||||
without forking. Plugins are arbitrary objects implementing one or
|
||||
more of six lifecycle hooks; the `PluginPipeline` invokes them in
|
||||
declaration order around the packer's pipeline yield points and wraps
|
||||
any thrown error as `PackerError(PACKER.PLUGIN_FAILED)` with the
|
||||
original error preserved on `cause`. The interface is intentionally
|
||||
narrow — plugins observe and contribute sidecars, they do not replace
|
||||
the gzip / AEAD / sign / integrity pipeline itself.
|
||||
|
||||
## Features
|
||||
|
||||
- Six lifecycle hooks (`onPackStart`, `onEntry`, `onPackEnd`,
|
||||
`onUnpackStart`, `onEntryDecoded`, `onUnpackEnd`).
|
||||
- Every hook is optional — a plugin only declares the hooks it cares
|
||||
about.
|
||||
- Declaration order is the invocation order; deterministic across runs.
|
||||
- Async hooks are awaited; sync hooks return immediately.
|
||||
- Exceptions from any hook surface as
|
||||
`PackerError(PACKER.PLUGIN_FAILED)` with the original error on
|
||||
`cause` and a catalog-resolved message keyed by hook name.
|
||||
- `PluginPipeline.empty` lets hot paths short-circuit when no plugins
|
||||
are registered — zero overhead for callers that don't use plugins.
|
||||
- Plugin-supplied integrity sidecars (e.g. SHA-256 alongside the
|
||||
built-in BLAKE3) can ride on `onEntry`/`onEntryDecoded` to round-trip
|
||||
custom digests through the bundle's `metadata.custom`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Packer, type PackerPlugin } from "@suaveplan/packer";
|
||||
|
||||
const logger: PackerPlugin = {
|
||||
name: "noop-logger",
|
||||
onPackStart(ctx) {
|
||||
console.log("pack started", ctx.originalSize);
|
||||
},
|
||||
onPackEnd(ctx) {
|
||||
console.log("pack done", ctx.result.data.byteLength);
|
||||
},
|
||||
};
|
||||
|
||||
const packer = new Packer({ plugins: [logger] });
|
||||
await packer.pack(payload);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { Packer, type PackerPlugin, Unpacker } from "@suaveplan/packer";
|
||||
|
||||
// A "double integrity" sidecar that contributes SHA-256 alongside the
|
||||
// built-in BLAKE3, then asserts equality on the unpack path.
|
||||
const observed = new Map<number, Uint8Array>();
|
||||
const doubleIntegrity: PackerPlugin = {
|
||||
name: "double-integrity",
|
||||
onEntry(ctx) {
|
||||
observed.set(ctx.entryIndex, new Uint8Array(sha256(ctx.payload)));
|
||||
},
|
||||
onEntryDecoded(ctx) {
|
||||
// Decoded entry plaintext does NOT match the post-pipeline payload
|
||||
// SHA — sidecars typically stash the digest into custom metadata
|
||||
// and compare round-trips through there. This example just shows
|
||||
// the hook fires symmetrically.
|
||||
if (!observed.has(ctx.entryIndex)) {
|
||||
throw new Error("sidecar missing for entry " + ctx.entryIndex);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const packer = new Packer({ plugins: [doubleIntegrity] });
|
||||
const packed = await packer.pack(payload);
|
||||
const unpacker = new Unpacker({ plugins: [doubleIntegrity] });
|
||||
await unpacker.unpack(packed);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface PackerPlugin`
|
||||
- `readonly name: string` — required identifier.
|
||||
- `onPackStart?(ctx: PluginPackStartContext)` — pre-pipeline hook.
|
||||
- `onEntry?(ctx: PluginEntryContext)` — per-entry hook.
|
||||
- `onPackEnd?(ctx: PluginPackEndContext)` — post-result hook.
|
||||
- `onUnpackStart?(ctx: PluginUnpackStartContext)` — pre-unpack hook.
|
||||
- `onEntryDecoded?(ctx: PluginEntryDecodedContext)` — per-decoded-entry hook.
|
||||
- `onUnpackEnd?(ctx: PluginUnpackEndContext)` — post-decode hook.
|
||||
- `class PluginPipeline`
|
||||
- `constructor(plugins, messageResolver)`
|
||||
- `get empty(): boolean`
|
||||
- `async runHook<K>(name: K, ctx: HookContextMap[K]): Promise<void>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pipeline is intentionally `for-of` + `await` rather than
|
||||
`Promise.all` so plugin ordering is observable and deterministic — a
|
||||
plugin that depends on a prior plugin's side effect can rely on the
|
||||
earlier hook resolving first. Plugin errors are wrapped, not
|
||||
swallowed: `PACKER.PLUGIN_FAILED` carries the original error on
|
||||
`cause` and a catalog message keyed by hook name so observability
|
||||
sinks can attribute the failure to a specific plugin. The `empty`
|
||||
short-circuit means callers who don't register plugins pay nothing —
|
||||
no array iteration, no try/catch entry. Plugins MUST NOT mutate the
|
||||
payload bytes they receive; the contract is observe-and-emit-side-effects.
|
||||
99
docs/foundation/data/packer/src/retry/retryable-read.md
Normal file
99
docs/foundation/data/packer/src/retry/retryable-read.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# `@suaveplan/packer/retry` — Retry on IO-backed source reads
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `ReadableStream` reads with the retry primitives from
|
||||
`@suaveplan/retry` so transient IO failures (network blip, slow filesystem,
|
||||
remote stream restart) don't fail an entire pack or unpack operation. This
|
||||
module is the single funnel every production `reader.read()` call in
|
||||
`@suaveplan/packer` flows through; `normalizeSource` and the abstract
|
||||
`collect` helper both consume it. The audit line in `enterprise/packer.md`
|
||||
(L47) mandates "**retry** primitives for IO-backed sources".
|
||||
|
||||
The helper is opt-in: when the caller does not pass a `retryConfig`, the
|
||||
default policy is a single attempt with zero delay — wire behaviour
|
||||
identical to a bare `reader.read()`. Callers who opt in get the configured
|
||||
backoff / jitter / max-attempts strategy plus first-class `AbortSignal`
|
||||
support: cancellation aborts the retry loop immediately.
|
||||
|
||||
## Features
|
||||
|
||||
- `retryRead(reader, opts)` — single-chunk read with optional `withRetry`
|
||||
envelope. Generic over the chunk payload type.
|
||||
- Default policy: `maxAttempts: 1` (no retries). Backwards-compatible.
|
||||
- `AbortSignal`-aware: cancellation aborts the loop mid-backoff and
|
||||
surfaces `RetryAbortedError` from `@suaveplan/retry`.
|
||||
- Generic `RetryableReader<T>` interface — works against any
|
||||
`read()`-shaped object (real `ReadableStreamDefaultReader`, test double,
|
||||
third-party polyfill).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { retryRead } from "@suaveplan/packer/retry";
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>(/* ... */);
|
||||
const reader = stream.getReader();
|
||||
const { value, done } = await retryRead(reader);
|
||||
if (!done && value) {
|
||||
/* consume the chunk */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { retryRead } from "@suaveplan/packer/retry";
|
||||
import { createExponentialBackoff } from "@suaveplan/retry";
|
||||
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 200);
|
||||
|
||||
const reader = remoteStream.getReader();
|
||||
const result = await retryRead(reader, {
|
||||
retryConfig: {
|
||||
maxAttempts: 4,
|
||||
delay: createExponentialBackoff({
|
||||
initial: 25,
|
||||
base: 2,
|
||||
max: 1000,
|
||||
jitter: "equal",
|
||||
}),
|
||||
},
|
||||
signal: ac.signal,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `retryRead<T = Uint8Array>(reader, opts?): Promise<{ value: T | undefined; done: boolean }>`
|
||||
— performs one chunk read, optionally retrying per `opts.retryConfig`.
|
||||
- `RetryReadOptions.retryConfig?: RetryConfig` — full `@suaveplan/retry`
|
||||
config bundle (`maxAttempts`, `delay`, `shouldRetry`, `clock`, `timers`).
|
||||
- `RetryReadOptions.signal?: AbortSignal` — cancellation signal. When
|
||||
fired, the retry loop aborts immediately and rejects with
|
||||
`RetryAbortedError`.
|
||||
- `RetryableReader<T>` — `{ read(): Promise<{ value: T | undefined; done: boolean }> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default policy (`{ maxAttempts: 1, delay: () => 0 }`) keeps cluster
|
||||
#1 byte-output identical for callers that don't pass `retryConfig` — the
|
||||
retry layer is a single-attempt passthrough.
|
||||
- `withRetry` calls the wrapped function with its own `signal` parameter
|
||||
that races against `signal.aborted`; the inner function rechecks
|
||||
`signal.aborted` before delegating to `reader.read()` so a signal that
|
||||
fires between the retry loop's check and the next read still cancels
|
||||
before any reader work happens.
|
||||
- `RetryableReader<T>` is structural — anything with a matching `read()`
|
||||
promise shape composes. Real `ReadableStreamDefaultReader<T>` satisfies
|
||||
it directly; test doubles need only return the same `{ value, done }`
|
||||
promise.
|
||||
- The helper does not classify errors — every rejection is retried up to
|
||||
`maxAttempts`. Callers needing fine-grained "retry only on network
|
||||
error" classification supply a `shouldRetry` predicate on the
|
||||
`RetryConfig`.
|
||||
99
docs/foundation/data/packer/src/runtime/runtime.md
Normal file
99
docs/foundation/data/packer/src/runtime/runtime.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# `@suaveplan/packer/runtime` — Clock + Rng injection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every production code path in `@suaveplan/packer` reads wall-clock time and
|
||||
randomness through an injected `PackerRuntime` bundle instead of calling
|
||||
`Date.now()`, `performance.now()`, or `crypto.getRandomValues` directly.
|
||||
This module hosts the platform defaults plus the `resolveRuntime(opts)`
|
||||
factory that every option bundle (`PackerOptions`, `PackChunkedOptions`,
|
||||
`UnpackChunkedOptions`) consumes when it constructs its resolved option
|
||||
bundle. The audit line in `enterprise/packer.md` (L43) makes this a hard
|
||||
requirement: "no `Math.random()` / `Date.now()` at module scope; injectable
|
||||
clock and RNG".
|
||||
|
||||
The contracts (`Clock`, `MonotonicClock`, `Rng`) are imported from
|
||||
`@suaveplan/types` — they are the shared cross-package interface every
|
||||
Suaveplan library agrees on for time and randomness. Defaults wire to real
|
||||
platform implementations; tests pass in `FakeClock`-backed values and a
|
||||
seeded `Rng` from `@suaveplan/testing` to drive deterministic pack output.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultClock` — `() => Date.now()`; wall-clock source for `metadata.timestamp`.
|
||||
- `defaultMonotonicClock` — `performance.now()` cast to nanosecond `bigint`;
|
||||
used for histogram observations.
|
||||
- `defaultRng` — wraps `crypto.getRandomValues` and exposes the full
|
||||
`Rng` triplet (`nextUint32`, `nextFloat`, `nextBytes`).
|
||||
- `resolveRuntime(opts?)` — produces a `PackerRuntime` bundle, falling back
|
||||
to defaults for absent fields. Upgrades a seeded `Rng` lacking
|
||||
`nextBytes` by deriving bytes from `nextUint32`.
|
||||
- `elapsedMs(monotonicClock, startNanos)` — converts a monotonic-clock
|
||||
delta into milliseconds for histogram observations.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
|
||||
// Default — no options needed; defaults pull from the platform.
|
||||
const packer = new Packer();
|
||||
const bundle = await packer.pack(new Uint8Array([1, 2, 3]));
|
||||
console.log(bundle.metadata.timestamp); // current Date.now() value
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Packer } from "@suaveplan/packer";
|
||||
import { FakeClock, seededRng } from "@suaveplan/testing";
|
||||
|
||||
// Deterministic pack: byte-identical output for byte-identical input.
|
||||
const fake = new FakeClock({ now: 1_700_000_000_000 });
|
||||
const rng = seededRng(0xdeadbeef);
|
||||
const packer = new Packer({
|
||||
clock: () => fake.now,
|
||||
monotonicClock: () => BigInt(fake.now) * 1_000_000n,
|
||||
rng,
|
||||
encryption: {
|
||||
algorithm: "xchacha20-poly1305",
|
||||
key: new Uint8Array(32),
|
||||
},
|
||||
});
|
||||
const a = await packer.pack(new Uint8Array([1, 2, 3]));
|
||||
const b = await packer.pack(new Uint8Array([1, 2, 3]));
|
||||
// a.data === b.data byte-by-byte because the RNG is seeded and the clock is fixed.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultClock: Clock` — `() => Date.now()`.
|
||||
- `defaultMonotonicClock: MonotonicClock` — `performance.now()` × 1e6 as
|
||||
`bigint`.
|
||||
- `defaultRng: Required<Pick<Rng, "nextBytes">> & Rng` — CSPRNG-backed
|
||||
`Rng` with mandatory `nextBytes`.
|
||||
- `resolveRuntime(opts?: RuntimeOptions): PackerRuntime` — produces the
|
||||
resolved bundle; `opts.clock`, `opts.monotonicClock`, `opts.rng` each
|
||||
default to the matching platform default.
|
||||
- `elapsedMs(monotonicClock, startNanos): number` — millisecond delta from
|
||||
the monotonic-clock sample at `startNanos`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defaultMonotonicClock` rounds `performance.now()` to the nearest
|
||||
nanosecond integer; the float→bigint cast is lossless for any process
|
||||
uptime under ~584 years.
|
||||
- `ensureNextBytes` (internal) extracts four bytes per `nextUint32` call,
|
||||
big-endian, when the injected `Rng` lacks `nextBytes`. This keeps the
|
||||
AEAD-nonce derivation deterministic under any seeded RNG.
|
||||
- The runtime carries no state between calls — the bundle is a plain
|
||||
object holding the three injected functions. Re-resolving with the same
|
||||
inputs is cheap and side-effect-free.
|
||||
- `resolveRuntime` is the single funnel every production option bundle in
|
||||
the package consumes. The grep audit pinned by PACKER-25's fourth
|
||||
scenario verifies that no other production file references
|
||||
`Date.now` / `Math.random` / `crypto.getRandomValues` / `performance.now`.
|
||||
106
docs/foundation/data/packer/src/schemas/registration.md
Normal file
106
docs/foundation/data/packer/src/schemas/registration.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# `schemas/registration` — `@suaveplan/schema-registry` integration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`registration.ts` wires every Zod schema exported from
|
||||
`packer.schemas.ts` and a canonical `packer-chunk-format` wire-format
|
||||
descriptor into a `@suaveplan/schema-registry` `SchemaRegistry`. The
|
||||
registry is the single source of truth for the package's contract
|
||||
surface: option shapes, manifest shape, and the byte-level wire-format
|
||||
version. Storing them in the registry lets consumers introspect the
|
||||
shipping contract (`getLatest("PackerOptions")`), reason about evolution
|
||||
(`registry.checkCompatibility(...)`), and prepares the ground for a V2
|
||||
wire format to land as a `BACKWARD`-compatible follow-up registration
|
||||
under the same subject.
|
||||
|
||||
## Features
|
||||
|
||||
- `registerPackerSchemas(registry)` registers all five subjects against
|
||||
any caller-supplied `SchemaRegistry` instance.
|
||||
- `getPackerSchemaRegistry()` returns a process-singleton registry,
|
||||
populated lazily on first call.
|
||||
- `getPackerSchemaRegistration()` returns the typed
|
||||
`PackerSchemaRegistration` record from the singleton without
|
||||
re-querying every subject.
|
||||
- Canonical V1 wire-format descriptor `PACKER_CHUNK_FORMAT_V1` exposes
|
||||
the structural invariants of the V1 chunk header (164-byte header,
|
||||
XChaCha20-Poly1305 AEAD, ed25519 signing).
|
||||
- Every Zod schema is registered as format `"zod"`; the chunk-format
|
||||
descriptor is registered as format `"custom"`.
|
||||
- Singleton is process-scoped; tests requiring isolation create a fresh
|
||||
registry via `createRegistry()` and pass it explicitly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getPackerSchemaRegistry } from "@suaveplan/packer/schemas";
|
||||
|
||||
const registry = getPackerSchemaRegistry();
|
||||
const chunkFormat = registry.getLatest("packer-chunk-format");
|
||||
console.log(chunkFormat.metadata?.formatVersion); // "1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRegistry,
|
||||
registerPackerSchemas,
|
||||
} from "@suaveplan/packer/schemas";
|
||||
|
||||
// Cross-package registry where multiple capabilities share a registry
|
||||
// instance. The packer subjects coexist with whatever else the caller
|
||||
// already registered.
|
||||
const sharedRegistry = createRegistry({ defaultCompatibility: "BACKWARD" });
|
||||
const { Manifest, chunkFormat } = registerPackerSchemas(sharedRegistry);
|
||||
console.log(Manifest.version); // 1
|
||||
console.log(chunkFormat.metadata?.encoding); // "binary"
|
||||
|
||||
// Probe whether a hypothetical V2 candidate would be backward-compatible
|
||||
// against the registered V1 descriptor.
|
||||
const v2Candidate = {
|
||||
formatVersion: 2,
|
||||
headerSize: 200,
|
||||
aead: "xchacha20-poly1305",
|
||||
signature: "ed25519",
|
||||
description: "v2 — TBD",
|
||||
};
|
||||
const result = sharedRegistry.checkCompatibility(
|
||||
"packer-chunk-format",
|
||||
v2Candidate,
|
||||
"custom"
|
||||
);
|
||||
console.log(result.compatible); // depends on registry's checker
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerPackerSchemas(registry: SchemaRegistry): PackerSchemaRegistration`
|
||||
- Registers every packer subject into `registry`.
|
||||
- Returns the freshly-minted `SchemaDefinition`s keyed by subject.
|
||||
- `getPackerSchemaRegistry(): SchemaRegistry`
|
||||
- Returns the process-singleton registry, populating it on first call.
|
||||
- `getPackerSchemaRegistration(): PackerSchemaRegistration`
|
||||
- Returns the record produced when the singleton was populated.
|
||||
- `PACKER_CHUNK_FORMAT_V1: PackerChunkFormatV1`
|
||||
- Frozen V1 wire-format descriptor body.
|
||||
- `PACKER_SUBJECTS: readonly PackerSubject[]`
|
||||
- Canonical list of every subject name registered by the package.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registration order matters: `packer-chunk-format` is registered
|
||||
first so its V1 metadata is the canonical entry — a future V2 codec
|
||||
landing through a follow-up change will register V2 against the same
|
||||
subject and the registry's `BACKWARD` compatibility check enforces that
|
||||
the wire-shape evolution stays consumer-safe. The Zod option schemas
|
||||
are registered under format `"zod"` so consumers can disambiguate them
|
||||
from the JSON-schema-shaped wire descriptor. The singleton is lazy and
|
||||
test isolation is achieved by passing a caller-owned registry to
|
||||
`registerPackerSchemas` — tests never share state through the global
|
||||
singleton, which is documented behaviour callers can rely on for
|
||||
fixture isolation.
|
||||
@@ -0,0 +1,57 @@
|
||||
# normalizeSource
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Converts the four shapes accepted as a packer source — `Uint8Array`, `Blob`, `ReadableStream<Uint8Array>`, `AsyncIterable<Uint8Array>` — into a single uniform `AsyncIterable<Uint8Array>` so downstream pipelines (compressor, AEAD encrypt-stream, chunk chopper) can iterate without branching on input type. Buffered sources are sliced to `chunkSize` boundaries; already-streaming sources preserve their inbound chunk boundaries.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts buffered: `Uint8Array`, `Blob`.
|
||||
- Accepts streaming: `ReadableStream<Uint8Array>`, `AsyncIterable<Uint8Array>`.
|
||||
- Single unified output: `AsyncIterable<Uint8Array>`.
|
||||
- Releases `ReadableStream` reader lock in `finally` to prevent leaks on early termination.
|
||||
- Rejects unsupported inputs with `PackerError(PACKER.INVALID_SOURCE)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { normalizeSource } from "@suaveplan/packer/streaming";
|
||||
|
||||
const source = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
for await (const chunk of normalizeSource(source, 2)) {
|
||||
console.log(chunk); // Uint8Array(2)[1,2], (2)[3,4], (1)[5]
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { normalizeSource } from "@suaveplan/packer/streaming";
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
const fh = await open("/large/file.bin");
|
||||
const stream = fh.readableWebStream() as ReadableStream<Uint8Array>;
|
||||
|
||||
let totalBytes = 0;
|
||||
for await (const chunk of normalizeSource(stream, 64 * 1024)) {
|
||||
totalBytes += chunk.byteLength;
|
||||
// pass chunk through next stage of the pipeline
|
||||
}
|
||||
await fh.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`normalizeSource(source: PackSource, chunkSize: number): AsyncIterable<Uint8Array>`
|
||||
|
||||
- **`source`** — `Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>`. The input bytes.
|
||||
- **`chunkSize`** — positive integer. Slice boundary for buffered inputs. Ignored for streaming inputs (their boundaries pass through unchanged).
|
||||
- **Returns** — `AsyncIterable<Uint8Array>`.
|
||||
- **Throws** — `PackerError(PACKER.INVALID_SOURCE)` if `source` is none of the supported types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Uint8Array.subarray` is used for buffered slicing (zero-copy view, not a copy). `Blob` paths recurse through `Blob.stream()`, which itself yields the streaming branch. The `Symbol.asyncIterator in source` check precedes the `getReader` branch because modern `ReadableStream` instances satisfy both protocols and async iteration is the simpler path. The reader-based fallback exists for environments where `ReadableStream` lacks async-iteration support; the `try/finally` ensures the lock is released even when the consumer aborts mid-stream.
|
||||
55
docs/foundation/data/packer/src/unpacker/unpacker.md
Normal file
55
docs/foundation/data/packer/src/unpacker/unpacker.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Unpacker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Whole-file unpacker. Reverses the `Packer` pipeline — optionally verifies the ed25519 server signature, AEAD-decrypts, gunzips, optionally verifies the SHA-256 checksum — and returns `{ data: Uint8Array, metadata: PackedDataMetadata }`. The metadata that arrived with the packed envelope is passed through unchanged so callers retain the timestamp, mime, custom fields, etc.
|
||||
|
||||
## Features
|
||||
|
||||
- ed25519 signature verification (off by default; opt-in via `verifySignature: true`).
|
||||
- AEAD decryption with algorithm picked from `metadata.encryptionAlgorithm`.
|
||||
- gzip decompression triggered by `metadata.compressionAlgorithm === "gzip"`.
|
||||
- SHA-256 checksum verification (opt-in via `verifyChecksum: true`).
|
||||
- Typed events `unpack:start`, `unpack:verified`, `unpack:decrypted`, `unpack:decompressed`, `unpack:complete`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Unpacker } from "@suaveplan/packer";
|
||||
|
||||
const unpacker = new Unpacker();
|
||||
const { data } = await unpacker.unpack(packedEnvelope);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Unpacker } from "@suaveplan/packer";
|
||||
|
||||
const unpacker = new Unpacker();
|
||||
const result = await unpacker.unpack(packedEnvelope, {
|
||||
key: fileKey,
|
||||
verifySignature: true,
|
||||
verifyKey: serverPubKey,
|
||||
verifyChecksum: true,
|
||||
});
|
||||
// result.data === original plaintext, byte-identical
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`class Unpacker extends AbstractPacker`**
|
||||
- `unpack(packed: PackedData, opts?: UnpackOptions): Promise<UnpackResult>`.
|
||||
- `pack()` — inherited contract; throws `PackerError(PACKER.INVALID_FORMAT)` (use `Packer` for packing).
|
||||
|
||||
### Failure modes
|
||||
|
||||
- `PACKER.VERIFICATION_FAILED` — signature did not verify; `verifyKey` not supplied while `verifySignature: true`; `verifyKey` of wrong length; checksum mismatch.
|
||||
- `PACKER.INVALID_FORMAT` — `metadata.signed` set but `signature` missing; `metadata.encrypted` set but `key`/`nonce`/`aeadTag`/`encryptionAlgorithm` missing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The order of operations on the receive path is the reverse of the send path: `verify → decrypt → decompress → checksum`. Signature verification runs against the post-encryption ciphertext (matching how `Packer` signs), so it works without the AEAD key — a peer can forward signed-but-encrypted packed bundles and recipients verify provenance without ever decrypting. Checksum verification runs against the post-decompression plaintext, matching what `Packer.checksum(...)` computed pre-transform — a true integrity round-trip.
|
||||
Reference in New Issue
Block a user