Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
# `bitpacker`
Bit-level binary packing, schemas, compression, and streaming codecs.
<!-- begin:auto-docs -->
## Packages in `bitpacker`
10 packages.
| Package | Description |
|---|---|
| [`@suaveplan/bitpacker`](./bitpacker/README.md) | Core bit-level binary packing library. Provides `BitWriter`, `BitReader`, `BitPacker`, and a pluggable strategy system for schema-driven serialization. |
| [`@suaveplan/bitpacker-compressed`](./bitpacker-compressed/README.md) | Compression pipeline integration for bitpacker. |
| [`@suaveplan/bitpacker-crypto`](./bitpacker-crypto/README.md) | Bit-packer-aware crypto primitives. The shared interfaces (`Authority`, `DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`; the canonical… |
| [`@suaveplan/bitpacker-delta`](./bitpacker-delta/README.md) | Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values. |
| [`@suaveplan/bitpacker-game`](./bitpacker-game/README.md) | Game state utilities: quantization, prediction, interpolation, and client-server reconciliation. |
| [`@suaveplan/bitpacker-network`](./bitpacker-network/README.md) | Network simulation utilities for testing bitpacker under realistic network conditions. |
| [`@suaveplan/bitpacker-schema`](./bitpacker-schema/README.md) | Schema registry and validation for bitpacker. |
| [`@suaveplan/bitpacker-strategies`](./bitpacker-strategies/README.md) | Built-in packing strategies for `@suaveplan/bitpacker`. Provides strategies for numbers, strings, booleans, objects, arrays, enums, optional fields, oneOf unio… |
| [`@suaveplan/bitpacker-streaming`](./bitpacker-streaming/README.md) | Streaming APIs for bitpacker including chunked packing/unpacking and true TransformStream support with framing. |
| [`@suaveplan/bitpacker-validation`](./bitpacker-validation/README.md) | Safety limits and validation reports for bitpacker schemas. |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Bit-level binary packing, schemas, compression, and streaming codecs.

View File

@@ -0,0 +1,91 @@
# @suaveplan/bitpacker-compressed - Bitpacker compressed plugin
## Summary
Compression pipeline integration for bitpacker.
<!-- begin:auto-docs -->
## Documentation tree
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
<!-- end:auto-docs --> - [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schemas for the public type comp…
- [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
- [deflate](./src/adapters/deflate/deflate.md) — <!-- begin:auto-docs --> _(no submodules)_- [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_ - [gzip](./src/adapters/gzip/gzip.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ - [none](./src/adapters/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_ - [api](./src/api/api.md) — <!-- begin:auto-docs --> _(no submodules)_- [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compressed-bit-packer](./src/compressed-bit-packer/compressed-bit-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compressed-bit-packer.schema](./src/compressed-bit-packer/compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rle](./src/rle/rle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rle.schema](./src/rle/rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types.schema](./src/types/types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [index](./src/index.md) — **Path:** `bitpacker-compressed/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `re…
## Install
```bash
bun add @suaveplan/bitpacker-compressed
```
## Usage
```typescript
import { compress, decompress } from "@suaveplan/bitpacker-compressed";
const data = new Uint8Array([1, 2, 3, 4, 5]);
const compressed = compress(data);
const restored = decompress(compressed);
```
## Modules
- `src/index.ts` — `compress`, `decompress`

View File

@@ -0,0 +1,61 @@
{
"name": "@suaveplan/bitpacker-compressed",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bitpacker compressed plugin",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./register-schemas": {
"import": {
"types": "./dist/register-schemas/index.d.ts",
"default": "./dist/register-schemas/index.js"
},
"require": {
"types": "./dist/register-schemas/index.d.ts",
"default": "./dist/register-schemas/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/schema-registry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/bitpacker-strategies": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,181 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DeflateCompressionAdapter } from "./deflate/deflate.js";
import { GzipCompressionAdapter } from "./gzip/gzip.js";
import { NoCompressionAdapter } from "./none/none.js";
describe("NoCompressionAdapter", () => {
it("compress passes bytes through unchanged", {
timeout: 5000,
}, async () => {
const adapter = new NoCompressionAdapter();
const input = new Uint8Array([1, 2, 3, 4]);
const out = await adapter.compress(input);
expect(out).toEqual(input);
});
it("decompress passes bytes through unchanged", {
timeout: 5000,
}, async () => {
const adapter = new NoCompressionAdapter();
const input = new Uint8Array([1, 2, 3, 4]);
const out = await adapter.decompress(input);
expect(out).toEqual(input);
});
it("name is 'none'", { timeout: 5000 }, () => {
expect(new NoCompressionAdapter().name).toBe("none");
});
});
const hasStreams = typeof CompressionStream !== "undefined";
describe.skipIf(!hasStreams)("GzipCompressionAdapter (stream-backed)", () => {
it("round-trip preserves bytes for 1 KiB of zeros", {
timeout: 5000,
}, async () => {
const adapter = new GzipCompressionAdapter();
const input = new Uint8Array(1024).fill(0);
const compressed = await adapter.compress(input);
// gzip on 1 KiB of zeros compresses dramatically:
expect(compressed.length).toBeLessThan(50);
const decompressed = await adapter.decompress(compressed);
expect(decompressed).toEqual(input);
});
it("round-trip preserves bytes for random 1 KiB", {
timeout: 5000,
}, async () => {
const adapter = new GzipCompressionAdapter();
const input = new Uint8Array(1024);
for (let i = 0; i < input.length; i++) {
input[i] = (i * 17 + 7) & 0xff;
}
const compressed = await adapter.compress(input);
const decompressed = await adapter.decompress(compressed);
expect(decompressed).toEqual(input);
});
it("decompress throws DECOMPRESSION_FAILED for truncated stream", {
timeout: 5000,
}, async () => {
const adapter = new GzipCompressionAdapter();
const original = new Uint8Array(1024).fill(42);
const compressed = await adapter.compress(original);
const truncated = compressed.slice(
0,
Math.floor(compressed.length / 2)
);
await expect(adapter.decompress(truncated)).rejects.toBeInstanceOf(
BitpackerError
);
try {
await adapter.decompress(truncated);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
}
});
});
describe.skipIf(!hasStreams)(
"DeflateCompressionAdapter (stream-backed)",
() => {
it("round-trip preserves bytes for 1 KiB", {
timeout: 5000,
}, async () => {
const adapter = new DeflateCompressionAdapter();
const input = new Uint8Array(1024);
for (let i = 0; i < input.length; i++) {
input[i] = (i * 31 + 11) & 0xff;
}
const compressed = await adapter.compress(input);
const decompressed = await adapter.decompress(compressed);
expect(decompressed).toEqual(input);
});
it("name is 'deflate'", { timeout: 5000 }, () => {
expect(new DeflateCompressionAdapter().name).toBe("deflate");
});
}
);
describe("GzipCompressionAdapter (no streams branch)", () => {
it("compress throws COMPRESSION_FAILED when CompressionStream is missing", {
timeout: 5000,
}, async () => {
const g = globalThis as unknown as {
CompressionStream?: typeof CompressionStream;
};
const saved = g.CompressionStream;
g.CompressionStream = undefined;
try {
const adapter = new GzipCompressionAdapter();
await adapter.compress(new Uint8Array([1, 2, 3]));
throw new Error("expected throw, did not throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
} finally {
g.CompressionStream = saved;
}
});
it("decompress throws DECOMPRESSION_FAILED when DecompressionStream is missing", {
timeout: 5000,
}, async () => {
const g = globalThis as unknown as {
DecompressionStream?: typeof DecompressionStream;
};
const saved = g.DecompressionStream;
g.DecompressionStream = undefined;
try {
const adapter = new GzipCompressionAdapter();
await adapter.decompress(new Uint8Array([1, 2, 3]));
throw new Error("expected throw, did not throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
} finally {
g.DecompressionStream = saved;
}
});
it("compress wraps thrown stream-constructor error as COMPRESSION_FAILED", {
timeout: 5000,
}, async () => {
// Force runStream's try-block to throw by stubbing CompressionStream
// with a constructor that always throws — exercises the
// direction === "compress" branch of the catch's BitpackerError code
// selection.
const g = globalThis as unknown as {
CompressionStream?: typeof CompressionStream;
};
const saved = g.CompressionStream;
class ThrowingStream {
constructor() {
throw new Error("constructor blew up");
}
}
g.CompressionStream =
ThrowingStream as unknown as typeof CompressionStream;
try {
const adapter = new GzipCompressionAdapter();
await adapter.compress(new Uint8Array([1, 2, 3]));
throw new Error("expected throw, did not throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
} finally {
g.CompressionStream = saved;
}
});
});

View File

@@ -0,0 +1,54 @@
# DeflateCompressionAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeflateCompressionAdapter` is the WHATWG Streams-backed deflate codec for `@suaveplan/bitpacker-compressed`. It mirrors `GzipCompressionAdapter` exactly but switches the underlying stream to `CompressionStream("deflate")` / `DecompressionStream("deflate")`. The chunk-concatenation pump is shared with the gzip adapter via `_runStreamForTests` so the two adapters do not duplicate the same loop. Use this when you need raw deflate (no gzip header) — typically for inter-op with consumers that expect that wire format (HTTP `Content-Encoding: deflate`, certain binary protocols, etc.).
## Features
- Drop-in `CompressionAdapter` implementation registered as `"deflate"` in the default registry.
- Re-uses the gzip adapter's streaming pump for chunk concatenation, eliminating duplicated logic.
- Same coded-error treatment as gzip: `COMPRESSION_FAILED` / `DECOMPRESSION_FAILED` with `cause`.
- Identical async signature to all other adapters so swapping codecs is a `registry.resolve(otherName)` change.
## Usage: Basic Example
```ts
import { DeflateCompressionAdapter } from "@suaveplan/bitpacker-compressed";
const deflate = new DeflateCompressionAdapter();
const compressed = await deflate.compress(payload);
const restored = await deflate.decompress(compressed);
```
## Usage: Advanced Example
```ts
import {
compressData,
decompressData,
} from "@suaveplan/bitpacker-compressed";
// HTTP body that arrived with Content-Encoding: deflate.
const body = await req.arrayBuffer();
const decoded = await decompressData(new Uint8Array(body), "deflate");
```
## API Reference
### `class DeflateCompressionAdapter implements CompressionAdapter`
- `readonly name: "deflate"`
- `async compress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
- `async decompress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
**Throws:** `BitpackerError(COMPRESSION_FAILED, { cause })` / `BitpackerError(DECOMPRESSION_FAILED, { cause })` on stream failure or environments without `globalThis.CompressionStream`.
## Implementation Notes
- "Deflate" here means raw RFC 1951 (no zlib header). If you need the zlib-wrapped variant, the platform `CompressionStream("deflate")` already produces it on most browsers — this is a known compatibility nuance and callers MUST be aware of which side they need.
- The shared `_runStreamForTests` helper is intentionally underscore-prefixed to mark it as adapter-internal; consumers should not import it directly.
- WHATWG `CompressionStream` ignores the `level` option for deflate the same way it does for gzip.

View File

@@ -0,0 +1,33 @@
/**
* `DeflateCompressionAdapter` — WHATWG Streams-backed deflate codec.
*
* Same shape as `GzipCompressionAdapter`; uses
* `globalThis.CompressionStream("deflate")` and the matching decompression
* stream. Re-uses the streaming pump from the gzip adapter to avoid
* duplicating the chunk-concatenation loop.
*/
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "../../types/types.js";
import { _runStreamForTests as runStream } from "../gzip/gzip.js";
export class DeflateCompressionAdapter implements CompressionAdapter {
public readonly name: BitPackerCompressionAlgorithm = "deflate";
public async compress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("deflate", "compress", data);
}
public async decompress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("deflate", "decompress", data);
}
}

View File

@@ -0,0 +1,56 @@
# GzipCompressionAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GzipCompressionAdapter` is the WHATWG Streams-backed gzip codec. It uses `globalThis.CompressionStream("gzip")` and `DecompressionStream("gzip")` — available in modern browsers, Bun, Deno, and Node 18+. The adapter exists so callers don't have to reach into Web Streams APIs directly: the pipeline framing, error coding, and chunk-concatenation loop are all handled here so `compressData(data, "gzip")` is a one-liner. If the host environment lacks `CompressionStream`, the registry's `defaultCompressionRegistry()` skips registering this adapter; constructing one directly and calling `compress` in such a host throws `BitpackerError(COMPRESSION_FAILED)` with a descriptive cause chain.
## Features
- Drop-in `CompressionAdapter` implementation registered as `"gzip"` in the default registry.
- Streaming pump under the hood — large payloads are chunked through `CompressionStream` rather than buffered whole.
- Coded errors (`COMPRESSION_FAILED` / `DECOMPRESSION_FAILED`) wrap any underlying stream exception with `cause`.
- Re-exported `_runStreamForTests` helper used by `DeflateCompressionAdapter` to share the chunk-concatenation loop.
## Usage: Basic Example
```ts
import { GzipCompressionAdapter } from "@suaveplan/bitpacker-compressed";
const gzip = new GzipCompressionAdapter();
const compressed = await gzip.compress(myBytes);
const restored = await gzip.decompress(compressed);
```
## Usage: Advanced Example
```ts
import { defaultCompressionRegistry } from "@suaveplan/bitpacker-compressed";
// Production code typically goes through the registry.
const registry = defaultCompressionRegistry();
const adapter = registry.resolve("gzip");
const out = await adapter.compress(payload, { level: 9 });
```
## API Reference
### `class GzipCompressionAdapter implements CompressionAdapter`
- `readonly name: "gzip"`
- `async compress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
- `async decompress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
**Throws:** `BitpackerError(COMPRESSION_FAILED, { cause })` / `BitpackerError(DECOMPRESSION_FAILED, { cause })` if the underlying `CompressionStream` rejects, including environments without `globalThis.CompressionStream`.
### `_runStreamForTests(stream, data): Promise<Uint8Array>` (internal)
Exposed for the deflate adapter to re-use the pump loop. Not part of the public API.
## Implementation Notes
- WHATWG `CompressionStream` does not honour the `level` option — gzip level is fixed at the platform default. The option is accepted for API symmetry with adapters that DO honour it (e.g. a future `BrotliAdapter`).
- The pump loop uses `ReadableStream.prototype.getReader()` and concatenates chunks into a single `Uint8Array` at the end. For very large payloads, callers should consider invoking `CompressionStream` directly to get backpressure-aware streaming output.
- Every async method awaits inside a try/catch and re-throws as a coded `BitpackerError`. There is no silent fallback — if gzip fails, the caller sees a fully-typed coded error.

View File

@@ -0,0 +1,111 @@
/**
* `GzipCompressionAdapter` — WHATWG Streams-backed gzip codec.
*
* Uses `globalThis.CompressionStream("gzip")` and `DecompressionStream("gzip")`
* — available in modern browsers, Bun, Deno, and Node 18+. If the host
* lacks `CompressionStream`, every method throws so the registry's
* `defaultCompressionRegistry()` factory only registers this adapter when
* the API is detected.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "../../types/types.js";
export class GzipCompressionAdapter implements CompressionAdapter {
public readonly name: BitPackerCompressionAlgorithm = "gzip";
public async compress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("gzip", "compress", data);
}
public async decompress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("gzip", "decompress", data);
}
}
/**
* Common pump that pushes `data` through a `CompressionStream` /
* `DecompressionStream` and concatenates the chunks. Threading a single
* `Uint8Array` chunk avoids the back-pressure considerations the streams
* API otherwise exposes (we have all the bytes in memory upfront, so a
* one-shot write + close is the right shape).
*/
async function runStream(
format: "gzip" | "deflate",
direction: "compress" | "decompress",
data: Uint8Array
): Promise<Uint8Array> {
const StreamCtor =
direction === "compress"
? (globalThis as { CompressionStream?: typeof CompressionStream })
.CompressionStream
: (
globalThis as {
DecompressionStream?: typeof DecompressionStream;
}
).DecompressionStream;
if (StreamCtor === undefined) {
throw new BitpackerError(
direction === "compress"
? BITPACKER_CODES.COMPRESSION_FAILED
: BITPACKER_CODES.DECOMPRESSION_FAILED,
`${format} ${direction} unavailable: host lacks CompressionStream/DecompressionStream`,
{ context: { algorithm: format } }
);
}
try {
const stream = new StreamCtor(format);
const writer = stream.writable.getWriter();
// Force the data to be backed by an ArrayBuffer (not SharedArrayBuffer)
// before passing to the streams API — required by the spec. Allocating
// a fresh `Uint8Array(data)` always produces an ArrayBuffer-backed view.
const payload = new Uint8Array(data) as Uint8Array<ArrayBuffer>;
await writer.write(payload);
await writer.close();
const chunks: Uint8Array[] = [];
const reader = stream.readable.getReader();
// Pump until the readable side reports done. Express the loop
// condition via the read result so the reader's flag drives the
// exit (avoids `while (true)` which biome flags as a constant
// condition).
for (
let result = await reader.read();
!result.done;
result = await reader.read()
) {
chunks.push(result.value);
}
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const out = new Uint8Array(totalLength);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
} catch (cause) {
throw new BitpackerError(
direction === "compress"
? BITPACKER_CODES.COMPRESSION_FAILED
: BITPACKER_CODES.DECOMPRESSION_FAILED,
`${format} ${direction} failed`,
{
cause: cause as Error,
context: { algorithm: format },
}
);
}
}
export { runStream as _runStreamForTests };

View File

@@ -0,0 +1,55 @@
# NoCompressionAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NoCompressionAdapter` is the pass-through codec used as the default when `CompressionConfig.algorithm` is `"none"` or omitted. It exists so that `compressData`, `decompressData`, and `CompressedBitPacker` can ALWAYS route through the compression pipeline without special-casing the "compression disabled" branch — the bytes flow through `compress` and `decompress` calls that resolve immediately with the input. This keeps the framing logic in `CompressedBitPacker` uniform: every payload has the same header shape regardless of whether real compression occurred.
## Features
- Pass-through `compress` / `decompress` — returns the input `Uint8Array` reference unchanged.
- Drop-in `CompressionAdapter` implementation registered as `"none"` in the default registry.
- Always available — does not depend on `CompressionStream` and never throws.
- Default fallback when no algorithm is specified.
## Usage: Basic Example
```ts
import { NoCompressionAdapter } from "@suaveplan/bitpacker-compressed";
const none = new NoCompressionAdapter();
const same = await none.compress(myBytes); // myBytes itself
```
## Usage: Advanced Example
```ts
import {
compressData,
defaultCompressionRegistry,
} from "@suaveplan/bitpacker-compressed";
// "none" routes through the pipeline so framing stays uniform with
// gzip / deflate. Useful in tests that want to assert framing is
// orthogonal to the codec.
const out = await compressData(payload, "none");
const adapter = defaultCompressionRegistry().resolve("none");
```
## API Reference
### `class NoCompressionAdapter implements CompressionAdapter`
- `readonly name: "none"`
- `async compress(data: Uint8Array): Promise<Uint8Array>` — returns `data` unchanged.
- `async decompress(data: Uint8Array): Promise<Uint8Array>` — returns `data` unchanged.
The `options` parameter is accepted for interface symmetry but ignored.
## Implementation Notes
- The returned reference is the SAME `Uint8Array` instance as the input — the adapter does not copy. Callers that need an independent buffer should `slice()` separately.
- This adapter is unconditionally registered in `defaultCompressionRegistry()`. The other two defaults (`"gzip"`, `"deflate"`) are gated on `CompressionStream` availability; "none" always works.
- Tests that need to prove "compression is opt-in" use this adapter to confirm the pipeline behaves identically when the caller chose to skip real compression.

View File

@@ -0,0 +1,30 @@
/**
* `NoCompressionAdapter` — pass-through. Lets `CompressedBitPacker` always
* route through the compression pipeline without special-casing the
* "compression disabled" branch. Used as the default when
* `CompressionConfig.algorithm` is `"none"` or omitted.
*/
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "../../types/types.js";
export class NoCompressionAdapter implements CompressionAdapter {
public readonly name: BitPackerCompressionAlgorithm = "none";
public async compress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return data;
}
public async decompress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return data;
}
}

View File

@@ -0,0 +1,62 @@
# compression API (`compressData` / `decompressData`)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`compressData` and `decompressData` are the primary public surface of `@suaveplan/bitpacker-compressed`. They are algorithm-by-name helpers that route through a `CompressionRegistry` (defaulting to the process-wide singleton) so callers can switch between `"gzip"`, `"deflate"`, `"none"`, and any custom registered adapter without touching adapter classes directly. They are the layer that hot-path consumers like `CompressedBitPacker` use; it is also the layer that wraps codec failures in `BitpackerError` codes so application code never has to handle a raw `DOMException` or platform-specific stream error.
## Features
- Algorithm-by-name routing through a pluggable `CompressionRegistry`.
- Coded errors: unknown algorithm → `UNKNOWN_COMPRESSION_ALGORITHM`, codec failure → `COMPRESSION_FAILED` / `DECOMPRESSION_FAILED` with the underlying error chained.
- Optional registry parameter — pass a custom `CompressionRegistry` for isolation in tests.
- Symmetric: `compressData(data, "gzip")` round-trips through `decompressData(bytes, "gzip")`.
## Usage: Basic Example
```ts
import { compressData, decompressData } from "@suaveplan/bitpacker-compressed";
const data = new TextEncoder().encode("hello world".repeat(100));
const compressed = await compressData(data, "gzip");
const restored = await decompressData(compressed, "gzip");
```
## Usage: Advanced Example
```ts
import {
compressData,
createCompressionRegistry,
} from "@suaveplan/bitpacker-compressed";
// Test isolation: create a fresh registry that ONLY has the "none" adapter
// registered, then prove the unknown-algorithm path throws coded errors.
const registry = createCompressionRegistry({ withDefaults: false });
try {
await compressData(data, "gzip", { registry });
} catch (err) {
// BitpackerError with code BITPACKER.UNKNOWN_COMPRESSION_ALGORITHM
}
```
## API Reference
### `compressData(data, algorithm, options?): Promise<Uint8Array>`
- `data: Uint8Array` — bytes to compress.
- `algorithm: string` — registry key (e.g. `"gzip"`, `"deflate"`, `"none"`).
- `options?: { registry?, level? }` — `registry` overrides the default singleton; `level` is forwarded to the adapter.
- **Throws:** `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` or `BitpackerError(COMPRESSION_FAILED, { cause })`.
### `decompressData(data, algorithm, options?): Promise<Uint8Array>`
- Inverse of `compressData`. Throws `BitpackerError(DECOMPRESSION_FAILED, { cause })` on codec failure.
## Implementation Notes
- The default singleton is initialized lazily; it registers `"none"`, `"gzip"`, `"deflate"` only if the host supports `CompressionStream`.
- Adapters are async because `CompressionStream` is async; `NoCompressionAdapter` returns immediately.
- Hot-path callers should resolve the adapter once and call it directly to avoid the per-call registry lookup.

View File

@@ -0,0 +1,268 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import {
type CompressionRegistry,
createCompressionRegistry,
} from "../registry/registry.js";
import type { CompressionAdapter } from "../types/types.js";
import { compressData, decompressData } from "./api.js";
describe("compressData / decompressData", () => {
it("round-trip via 'none' returns original bytes", {
timeout: 5000,
}, async () => {
const reg = createCompressionRegistry();
const input = new Uint8Array([1, 2, 3, 4, 5]);
const c = await compressData(input, "none", undefined, reg);
const d = await decompressData(c, "none", undefined, reg);
expect(d).toEqual(input);
});
it("compressData throws UNKNOWN_COMPRESSION_ALGORITHM for unregistered algorithm", {
timeout: 5000,
}, async () => {
const reg = createCompressionRegistry();
await expect(
compressData(new Uint8Array([1]), "zstd", undefined, reg)
).rejects.toBeInstanceOf(BitpackerError);
});
it("decompressData throws UNKNOWN_COMPRESSION_ALGORITHM for unregistered algorithm", {
timeout: 5000,
}, async () => {
const reg = createCompressionRegistry();
try {
await decompressData(new Uint8Array([1]), "zstd", undefined, reg);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM
);
}
});
it("compressData wraps non-Bitpacker codec errors as COMPRESSION_FAILED", {
timeout: 5000,
}, async () => {
// Custom adapter whose compress() throws a plain Error.
class AngryAdapter implements CompressionAdapter {
public readonly name = "angry";
public compress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom"));
}
public decompress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom-d"));
}
}
const reg: CompressionRegistry = createCompressionRegistry();
reg.register(new AngryAdapter());
try {
await compressData(
new Uint8Array([1, 2, 3]),
"angry",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("angry");
}
});
it("decompressData wraps non-Bitpacker codec errors as DECOMPRESSION_FAILED", {
timeout: 5000,
}, async () => {
class AngryAdapter implements CompressionAdapter {
public readonly name = "angry";
public compress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom"));
}
public decompress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom-d"));
}
}
const reg: CompressionRegistry = createCompressionRegistry();
reg.register(new AngryAdapter());
try {
await decompressData(
new Uint8Array([1, 2, 3]),
"angry",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
}
});
it("passes through BitpackerError codes from adapter without re-wrap", {
timeout: 5000,
}, async () => {
class CodedAdapter implements CompressionAdapter {
public readonly name = "coded";
public compress(): Promise<Uint8Array> {
return Promise.reject(
new BitpackerError(
BITPACKER_CODES.COMPRESSION_FAILED,
"preserved",
{ context: { sentinel: true } }
)
);
}
public decompress(): Promise<Uint8Array> {
return this.compress();
}
}
const reg = createCompressionRegistry();
reg.register(new CodedAdapter());
try {
await compressData(new Uint8Array([1]), "coded", undefined, reg);
} catch (e) {
expect((e as BitpackerError).context?.sentinel).toBe(true);
}
});
it("decompressData also passes BitpackerError through without re-wrap", {
timeout: 5000,
}, async () => {
class CodedAdapter implements CompressionAdapter {
public readonly name = "coded-d";
public compress(): Promise<Uint8Array> {
return Promise.reject(
new BitpackerError(
BITPACKER_CODES.DECOMPRESSION_FAILED,
"preserved-d",
{ context: { sentinel: "decompress" } }
)
);
}
public decompress(): Promise<Uint8Array> {
return this.compress();
}
}
const reg = createCompressionRegistry();
reg.register(new CodedAdapter());
try {
await decompressData(
new Uint8Array([1]),
"coded-d",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).context?.sentinel).toBe("decompress");
}
});
it("uses defaultCompressionRegistry when registry omitted", {
timeout: 5000,
}, async () => {
// Default registry always has 'none'.
const input = new Uint8Array([7, 7, 7]);
const out = await compressData(input, "none");
expect(out).toEqual(input);
});
it("decompressData also defaults the registry argument", {
timeout: 5000,
}, async () => {
const input = new Uint8Array([9, 9, 9]);
const out = await decompressData(input, "none");
expect(out).toEqual(input);
});
it("options arg is forwarded to adapter", { timeout: 5000 }, async () => {
let observed: unknown;
class ObservingAdapter implements CompressionAdapter {
public readonly name = "observe";
public async compress(
data: Uint8Array,
options?: Record<string, unknown>
): Promise<Uint8Array> {
observed = options;
return data;
}
public async decompress(data: Uint8Array): Promise<Uint8Array> {
return data;
}
}
const reg = createCompressionRegistry();
reg.register(new ObservingAdapter());
await compressData(new Uint8Array([1]), "observe", { level: 9 }, reg);
expect(observed).toEqual({ level: 9 });
});
it("smoke-tests NoCompressionAdapter", { timeout: 5000 }, () => {
// Coverage anchor for the NoCompressionAdapter constructor.
const a = new NoCompressionAdapter();
expect(a.name).toBe("none");
});
it("compressData wraps non-Error throws as COMPRESSION_FAILED with String() cause", {
timeout: 5000,
}, async () => {
// Adapter rejects with a non-Error value (string). The api wrapper
// must coerce via `new Error(String(cause))` to satisfy the
// BitpackerError cause contract.
class StringThrower implements CompressionAdapter {
public readonly name = "string-thrower";
public compress(): Promise<Uint8Array> {
return Promise.reject("boom-string" as unknown as Error);
}
public decompress(): Promise<Uint8Array> {
return Promise.reject("boom-string-d" as unknown as Error);
}
}
const reg = createCompressionRegistry();
reg.register(new StringThrower());
try {
await compressData(
new Uint8Array([1]),
"string-thrower",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).cause).toBeInstanceOf(Error);
expect(((e as BitpackerError).cause as Error).message).toBe(
"boom-string"
);
}
});
it("decompressData wraps non-Error throws as DECOMPRESSION_FAILED with String() cause", {
timeout: 5000,
}, async () => {
class StringThrower implements CompressionAdapter {
public readonly name = "string-thrower-d";
public compress(): Promise<Uint8Array> {
return Promise.reject("boom-string" as unknown as Error);
}
public decompress(): Promise<Uint8Array> {
return Promise.reject(42 as unknown as Error);
}
}
const reg = createCompressionRegistry();
reg.register(new StringThrower());
try {
await decompressData(
new Uint8Array([1]),
"string-thrower-d",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
expect(((e as BitpackerError).cause as Error).message).toBe("42");
}
});
});

View File

@@ -0,0 +1,72 @@
/**
* `compressData` / `decompressData` — algorithm-by-name helpers backed by a
* `CompressionRegistry`. Mirrors upstream `compression.ts::compressData` /
* `decompressData` but routes through coded errors and a caller-supplied
* registry (defaults to the process-wide singleton).
*
* Hot-path consumers (`CompressedBitPacker`) use these directly. They
* throw `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` on missing adapter
* and wrap codec failures as `BitpackerError(COMPRESSION_FAILED)` /
* `BitpackerError(DECOMPRESSION_FAILED)` with the underlying error chained.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import {
type CompressionRegistry,
defaultCompressionRegistry,
} from "../registry/registry.js";
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
} from "../types/types.js";
export async function compressData(
data: Uint8Array,
algorithm: BitPackerCompressionAlgorithm,
options?: BitPackerCompressionOptions,
registry: CompressionRegistry = defaultCompressionRegistry()
): Promise<Uint8Array> {
const adapter = registry.resolve(algorithm);
try {
return await adapter.compress(data, options);
} catch (cause) {
if (cause instanceof BitpackerError) {
// Adapter already raised a coded error (e.g. unsupported host).
throw cause;
}
throw new BitpackerError(
BITPACKER_CODES.COMPRESSION_FAILED,
`Compression with algorithm "${algorithm}" failed`,
{
cause:
cause instanceof Error ? cause : new Error(String(cause)),
context: { algorithm },
}
);
}
}
export async function decompressData(
data: Uint8Array,
algorithm: BitPackerCompressionAlgorithm,
options?: BitPackerCompressionOptions,
registry: CompressionRegistry = defaultCompressionRegistry()
): Promise<Uint8Array> {
const adapter = registry.resolve(algorithm);
try {
return await adapter.decompress(data, options);
} catch (cause) {
if (cause instanceof BitpackerError) {
throw cause;
}
throw new BitpackerError(
BITPACKER_CODES.DECOMPRESSION_FAILED,
`Decompression with algorithm "${algorithm}" failed`,
{
cause:
cause instanceof Error ? cause : new Error(String(cause)),
context: { algorithm },
}
);
}
}

View File

@@ -0,0 +1,122 @@
# CompressedBitPacker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- compressed-bit-packer.schema — Zod schema for `Comp…
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
## Purpose
`CompressedBitPacker<T>` extends `@suaveplan/bitpacker::BitPacker<T>` to add a configurable compression stage between bit-packing and the wire bytes. The compressed bytes are framed with a self-describing algorithm header so a receiver can decompress without knowing the sender's algorithm choice in advance — the byte stream itself names the codec.
This is the port of upstream `bitpacker/src/compressed/bitpacker.ts`. The port keeps the upstream wire shape (`[headerLen: u8][header: ASCII algorithm][compressed payload]`) so any consumer carrying upstream-encoded bytes round-trips unchanged. Hardening over upstream:
- Plain `Error` throws → `BitpackerError` with stable codes (`UNKNOWN_COMPRESSION_ALGORITHM`, `COMPRESSION_FAILED`, `DECOMPRESSION_FAILED`, `COMPRESSED_BAD_HEADER`).
- The synchronous `pack`/`unpack` overrides from upstream — which threw when compression was enabled — are dropped. Consumers call the explicit async `packWithCompression` / `unpackWithDecompression` methods. This separates the sync no-compression hot path from the async compressed path cleanly.
- Adapter resolution goes through the injected `CompressionRegistry`, not a private static singleton.
- No `EventEmitter`. suaveplan's `BitPacker` does not extend an emitter; observability is OTel-driven via `bitpacker-compressed.*` spans.
## Features
- `packWithCompression(data, schema?)` — bit-pack, compress, frame.
- `unpackWithDecompression(data, schema?)` — read frame, decompress, bit-unpack.
- Configurable algorithm + options + enabled flag via constructor `compression` config or runtime accessors.
- Self-describing wire format: receiver dispatches to the named algorithm without prior knowledge.
- Synchronous `pack`/`unpack` from `BitPacker` remain for the no-compression case (no header added — bytes are exactly what `BitPacker.pack` produces).
- Pluggable `CompressionRegistry` via constructor option (defaults to the process-wide singleton).
## Usage: Basic Example
```typescript
import { CompressedBitPacker } from "@suaveplan/bitpacker-compressed";
const schema = { x: 255, y: 255 } as const;
const packer = new CompressedBitPacker(schema);
// Compression disabled (default) — frames with "none" header
const bytes = await packer.packWithCompression({ x: 42, y: 99 });
const decoded = await packer.unpackWithDecompression(bytes);
// → { x: 42, y: 99 }
```
## Usage: Advanced Example
```typescript
import {
CompressedBitPacker,
createCompressionRegistry,
} from "@suaveplan/bitpacker-compressed";
// Custom registry isolated from process-wide singleton, useful for tests
// or multi-tenant servers where adapter sets must not leak.
const registry = createCompressionRegistry();
// Register a custom adapter (e.g. brotli) into this isolated registry.
registry.register(new MyBrotliAdapter());
const packer = new CompressedBitPacker(
{
position: { x: "f32", y: "f32", z: "f32" },
velocity: { x: "f32", y: "f32", z: "f32" },
},
{
compression: {
algorithm: "brotli",
enabled: true,
options: { quality: 11 },
},
compressionRegistry: registry,
}
);
const wireBytes = await packer.packWithCompression({
position: { x: 1, y: 2, z: 3 },
velocity: { x: 0.1, y: 0, z: 0 },
});
// wireBytes is [headerLen=6][b][r][o][t][l][i][brotli-compressed bit-packed payload]
```
## API Reference
### `new CompressedBitPacker<T>(schema, options?)`
`schema` — a `BitPackerSchema` describing the data shape.
`options.compression?` — `{ algorithm?, options?, enabled? }`. Default is `{ algorithm: "none", options: {}, enabled: false }`.
`options.compressionRegistry?` — optional `CompressionRegistry` override. Defaults to `defaultCompressionRegistry()`.
### `packWithCompression(data, schema?) → Promise<Uint8Array>`
Bit-packs `data`, runs the bytes through the configured compression adapter (or `NoCompressionAdapter` if `enabled` is false or `algorithm` is `"none"`), and frames the result with `[headerLen: u8][header: ASCII algorithm name][payload]`. Returns the framed wire bytes.
### `unpackWithDecompression(data?, schema?) → Promise<unknown>`
Reverse of `packWithCompression`. Reads the framing header, dispatches to the named adapter for decompression (or routes the body through unchanged if the header is `"none"`), then bit-unpacks the result. If `data` is `undefined` or empty, falls back to `super.unpack(empty)` for parity with upstream.
### Accessors
`compressionAlgorithm` / `compressionOptions` / `compressionEnabled` — getters and setters for the runtime configuration. Allow callers to switch algorithms or toggle compression mid-flight.
### Inherited from `BitPacker`
Synchronous `pack(data, schema?) → Uint8Array` and `unpack(bytes, schema?) → T` remain available. They produce/consume bytes that are NOT framed (no `[headerLen][header]` prefix) — use them when compression is genuinely off and you don't want the framing overhead.
## Implementation Notes
The wire format's `headerLen` byte limits algorithm names to 1–255 ASCII bytes. The four shipping defaults (`none`, `gzip`, `deflate`, plus future `brotli`/`zstd`) are all well under that. If a future custom algorithm name exceeds the limit, register the adapter under a shorter alias.
The synchronous `pack`/`unpack` from `BitPacker` are NOT overridden here. The upstream version overrode them but threw an error when compression was enabled — that mixed sync and async expectations confusingly. suaveplan splits cleanly: callers wanting compression call the async methods; callers OK with no compression use the inherited sync methods.
`CompressionRegistry` is injectable so tests can use isolated registries (`createCompressionRegistry()`) without polluting the process-wide singleton. The lazy registration of `gzip`/`deflate` on the singleton (when `CompressionStream` is available) means the singleton is environment-aware without forcing every consumer through the streams API path.
The `compressed-bit-packer.test.ts` test file uses `vitest.skipIf(!hasStreams)` to skip stream-backed tests in environments without `CompressionStream`. The frame/unframe helpers are tested directly via `_frameForTests` / `_unframeForTests` so the wire format is locked even in non-streaming environments.

View File

@@ -0,0 +1,65 @@
# Compressed BitPacker Options Schema
## Purpose
Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validates the shape at the package boundary so a typo in the algorithm name (`alogrithm`), a non-`CompressionRegistry` value passed as the registry (a plain object that "quacks like" one), or a stray unknown key all fail at parse time rather than producing a packer that silently runs without the consumer's intended compression configuration.
The schema composes `compressionConfigSchema` for the `compression` field and uses an `instanceof` check for `compressionRegistry`. Identity of the registry is preserved so the constructor receives the same instance the caller built.
## Features
- `compressedBitPackerOptionsSchema` — strict, two optional fields (`compression`, `compressionRegistry`)
- Composes `compressionConfigSchema` for nested validation
- `instanceof CompressionRegistry` check with identity preservation
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
## Usage: Basic Example
```ts
import {
CompressedBitPacker,
compressedBitPackerOptionsSchema,
} from "@suaveplan/bitpacker-compressed";
const opts = compressedBitPackerOptionsSchema.parse({
compression: { algorithm: "gzip", enabled: true },
});
const packer = new CompressedBitPacker({ x: 255 }, opts);
```
## Usage: Advanced Example
```ts
import {
CompressedBitPacker,
compressedBitPackerOptionsSchema,
createCompressionRegistry,
GzipCompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
const registry = createCompressionRegistry();
registry.register(new GzipCompressionAdapter());
const opts = compressedBitPackerOptionsSchema.parse({
compression: {
algorithm: "gzip",
options: { level: 9 },
enabled: true,
},
compressionRegistry: registry,
});
const packer = new CompressedBitPacker({ x: 255 }, opts);
```
## API Reference
### `compressedBitPackerOptionsSchema: z.ZodType<CompressedBitPackerOptions>`
Strict schema. `compression` is an optional `CompressionConfig` (validated by `compressionConfigSchema`). `compressionRegistry` is an optional `CompressionRegistry` instance (validated by `instanceof` with identity preservation).
## Implementation Notes
- **Composition over duplication.** The nested `compression` field is validated by `compressionConfigSchema` from the same package. Composing rather than inlining the shape keeps the per-field error messages consistent across all entry points that accept a `CompressionConfig`.
- **`instanceof` over duck-typing.** A duck-typed registry would compile but would not pass the prototype-aware checks the registry's lookup methods perform internally. Refusing duck-types prevents an entire class of "registry doesn't find my adapter" bugs.
- **`.strict()` is non-negotiable.** Same rationale as elsewhere in this change: typo'd flags would otherwise silently default and hide configuration bugs.

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defaultCompressionRegistry } from "../registry/registry.js";
import { compressedBitPackerOptionsSchema } from "./compressed-bit-packer.schema.js";
describe("compressedBitPackerOptionsSchema", () => {
it("accepts an empty options bundle", () => {
expect(compressedBitPackerOptionsSchema.parse({})).toEqual({});
});
it("accepts a populated compression config", () => {
const opts = {
compression: { algorithm: "gzip", enabled: true },
};
expect(compressedBitPackerOptionsSchema.parse(opts)).toEqual(opts);
});
it("preserves the registry identity", () => {
const registry = defaultCompressionRegistry();
const parsed = compressedBitPackerOptionsSchema.parse({
compressionRegistry: registry,
});
expect(parsed.compressionRegistry).toBe(registry);
});
it("rejects a non-CompressionRegistry value", () => {
expect(() =>
compressedBitPackerOptionsSchema.parse({
compressionRegistry: { register() {} },
})
).toThrow();
});
it("rejects unknown keys", () => {
expect(() =>
compressedBitPackerOptionsSchema.parse({ bogus: true })
).toThrow();
});
});

View File

@@ -0,0 +1,23 @@
import z from "zod";
import { CompressionRegistry } from "../registry/registry.js";
import { compressionConfigSchema } from "../types/types.schema.js";
import type { CompressedBitPackerOptions } from "./compressed-bit-packer.js";
const compressionRegistrySchema = z.custom<CompressionRegistry>(
(v): v is CompressionRegistry => v instanceof CompressionRegistry,
{ message: "compressionRegistry must be a CompressionRegistry instance" }
);
/**
* Strict Zod schema for {@link CompressedBitPackerOptions}.
*
* Validates the constructor options for `CompressedBitPacker`. Composes the
* `compressionConfigSchema` for the `compression` field and an `instanceof`
* check on the optional `compressionRegistry`.
*/
export const compressedBitPackerOptionsSchema = z
.object({
compression: compressionConfigSchema.optional(),
compressionRegistry: compressionRegistrySchema.optional(),
})
.strict() as unknown as z.ZodType<CompressedBitPackerOptions>;

View File

@@ -0,0 +1,202 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { registerDefaultStrategies } from "@suaveplan/bitpacker-strategies";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
_frameForTests,
_unframeForTests,
CompressedBitPacker,
} from "./compressed-bit-packer.js";
const SIMPLE_SCHEMA = { x: 255, y: 255 } as const;
type SimpleData = { x: number; y: number };
describe("CompressedBitPacker (compression disabled)", () => {
it("packWithCompression frames with 'none' header", {
timeout: 5000,
}, async () => {
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
registerDefaultStrategies(packer.strategyManager);
const bytes = await packer.packWithCompression({
x: 1,
y: 2,
} as SimpleData);
// Header: [4 (len)][n][o][n][e][...packed bytes]
expect(bytes[0]).toBe(4);
expect(new TextDecoder().decode(bytes.slice(1, 5))).toBe("none");
});
it("round-trip via packWithCompression / unpackWithDecompression", {
timeout: 5000,
}, async () => {
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
registerDefaultStrategies(packer.strategyManager);
const original: SimpleData = { x: 100, y: 200 };
const bytes = await packer.packWithCompression(original);
const decoded = (await packer.unpackWithDecompression(
bytes
)) as SimpleData;
expect(decoded).toEqual(original);
});
it("synchronous super.pack still works (no header added)", {
timeout: 5000,
}, () => {
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
registerDefaultStrategies(packer.strategyManager);
const bytes = packer.pack({ x: 7, y: 8 } as SimpleData);
// The synchronous path returns BitPacker.pack output directly,
// no [headerLen] prefix.
expect(bytes[0]).not.toBe(4);
expect(packer.unpack(bytes)).toEqual({ x: 7, y: 8 });
});
it("compressionEnabled / Algorithm / Options accessors round-trip", {
timeout: 5000,
}, () => {
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
expect(packer.compressionEnabled).toBe(false);
expect(packer.compressionAlgorithm).toBe("none");
expect(packer.compressionOptions).toEqual({});
packer.compressionEnabled = true;
packer.compressionAlgorithm = "gzip";
packer.compressionOptions = { level: 9 };
expect(packer.compressionEnabled).toBe(true);
expect(packer.compressionAlgorithm).toBe("gzip");
expect(packer.compressionOptions).toEqual({ level: 9 });
});
it("constructor honours compression config", { timeout: 5000 }, () => {
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
compression: {
algorithm: "deflate",
options: { level: 6 },
enabled: true,
},
});
expect(packer.compressionAlgorithm).toBe("deflate");
expect(packer.compressionOptions).toEqual({ level: 6 });
expect(packer.compressionEnabled).toBe(true);
});
it("constructor with partial compression config defaults missing fields", {
timeout: 5000,
}, () => {
// No `algorithm` — should default to "none". No `options` — should
// default to {}. `enabled` omitted — should default to true (since
// the user passed compression at all, the !== false rule kicks in).
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
compression: {},
});
expect(packer.compressionAlgorithm).toBe("none");
expect(packer.compressionOptions).toEqual({});
expect(packer.compressionEnabled).toBe(true);
});
it("unpackWithDecompression on undefined takes the fallback (no unframe)", {
timeout: 5000,
}, async () => {
const packer = new CompressedBitPacker({ x: 255 } as const);
registerDefaultStrategies(packer.strategyManager);
// undefined data must skip the unframe path. Genesis BitPacker.unpack
// will itself throw on a 0-byte buffer when a field expects bits,
// but the throw must NOT be a framing error — that proves we took
// the empty-buffer fallback branch.
try {
await packer.unpackWithDecompression(undefined);
// If genesis ever softens unpack-on-empty, getting here is fine too.
} catch (e) {
expect((e as BitpackerError).code).not.toBe(
BITPACKER_CODES.COMPRESSED_BAD_HEADER
);
}
});
});
const hasStreams = typeof CompressionStream !== "undefined";
describe.skipIf(!hasStreams)(
"CompressedBitPacker with stream-backed gzip",
() => {
it("round-trip with gzip enabled", { timeout: 10000 }, async () => {
// Default registry lazy-registers gzip when CompressionStream
// is available. Wait one microtask cycle for the lazy import
// promise to resolve before exercising compression.
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
compression: {
algorithm: "gzip",
enabled: true,
},
});
registerDefaultStrategies(packer.strategyManager);
// Give the lazy registration a chance to settle.
await new Promise((r) => setTimeout(r, 50));
const original: SimpleData = { x: 42, y: 99 };
const bytes = await packer.packWithCompression(original);
// Header: [4][g][z][i][p][compressed payload]
expect(bytes[0]).toBe(4);
expect(new TextDecoder().decode(bytes.slice(1, 5))).toBe("gzip");
const decoded = (await packer.unpackWithDecompression(
bytes
)) as SimpleData;
expect(decoded).toEqual(original);
});
}
);
describe("frame / unframe helpers", () => {
it("frame round-trips through unframe", { timeout: 5000 }, () => {
const payload = new Uint8Array([10, 20, 30]);
const framed = _frameForTests("gzip", payload);
const { algorithm, payload: out } = _unframeForTests(framed);
expect(algorithm).toBe("gzip");
expect(out).toEqual(payload);
});
it("frame rejects empty algorithm name with COMPRESSED_BAD_HEADER", {
timeout: 5000,
}, () => {
try {
_frameForTests("", new Uint8Array([1]));
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSED_BAD_HEADER
);
}
});
it("frame rejects algorithm name longer than 255 bytes", {
timeout: 5000,
}, () => {
const longName = "x".repeat(256);
expect(() => _frameForTests(longName, new Uint8Array([1]))).toThrow(
BitpackerError
);
});
it("unframe rejects bytes shorter than 2", { timeout: 5000 }, () => {
try {
_unframeForTests(new Uint8Array([1]));
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSED_BAD_HEADER
);
}
});
it("unframe rejects header length out of range", { timeout: 5000 }, () => {
// headerLen = 5, but only 3 bytes follow:
const bad = new Uint8Array([5, 0x67, 0x7a, 0x00]);
try {
_unframeForTests(bad);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSED_BAD_HEADER
);
}
});
it("unframe rejects zero header length", { timeout: 5000 }, () => {
const bad = new Uint8Array([0, 0x67, 0x7a]);
expect(() => _unframeForTests(bad)).toThrow(BitpackerError);
});
});

View File

@@ -0,0 +1,208 @@
/**
* `CompressedBitPacker<T>` extends `BitPacker<T>` to add a compress-and-frame
* stage between bit-packing and the wire bytes. The wire format is:
*
* [headerLen: u8][header: ASCII algorithm name][compressed payload]
*
* The header makes the wire payload self-describing: a receiver can call
* `unpackWithDecompression` without knowing the sender's algorithm, and the
* "no compression" case is encoded via the `"none"` header so the parsing
* path doesn't need to special-case the disabled state.
*
* Genesis port of upstream `bitpacker/src/compressed/bitpacker.ts`. Genesis-side
* adjustments:
*
* - The synchronous `pack`/`unpack` methods are NOT overridden. Compression
* is async; consumers must call the async `packWithCompression` /
* `unpackWithDecompression` methods explicitly. The parent's sync methods
* remain available for the no-compression hot path (no header added in
* that case — bytes are exactly what `BitPacker.pack` produced).
* - Plain `Error` throws → `BitpackerError` with stable codes.
* - Adapter resolution goes through the injected `CompressionRegistry`,
* not a private static singleton.
* - No `EventEmitter` events. Genesis's `BitPacker` does not extend an
* emitter; consumers wanting observability use OTel via the package's
* `bitpacker-compressed.*` spans.
*/
import {
BITPACKER_CODES,
BitPacker,
type BitPackerSchema,
BitpackerError,
} from "@suaveplan/bitpacker";
import { compressData, decompressData } from "../api/api.js";
import {
type CompressionRegistry,
defaultCompressionRegistry,
} from "../registry/registry.js";
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionConfig,
} from "../types/types.js";
export interface CompressedBitPackerOptions {
/** Compression configuration; defaults to disabled. */
readonly compression?: CompressionConfig;
/** Adapter registry; defaults to `defaultCompressionRegistry()`. */
readonly compressionRegistry?: CompressionRegistry;
}
export class CompressedBitPacker<
T extends BitPackerSchema = BitPackerSchema,
> extends BitPacker<T> {
private _compressionAlgorithm: BitPackerCompressionAlgorithm = "none";
private _compressionOptions: BitPackerCompressionOptions = {};
private _compressionEnabled = false;
private readonly compressionRegistry: CompressionRegistry;
public constructor(
schema: BitPackerSchema,
options?: CompressedBitPackerOptions
) {
super(schema);
if (options?.compression) {
this._compressionAlgorithm =
options.compression.algorithm ?? "none";
this._compressionOptions = options.compression.options ?? {};
this._compressionEnabled = options.compression.enabled !== false;
}
this.compressionRegistry =
options?.compressionRegistry ?? defaultCompressionRegistry();
}
public get compressionAlgorithm(): BitPackerCompressionAlgorithm {
return this._compressionAlgorithm;
}
public set compressionAlgorithm(algorithm: BitPackerCompressionAlgorithm) {
this._compressionAlgorithm = algorithm;
}
public get compressionOptions(): BitPackerCompressionOptions {
return this._compressionOptions;
}
public set compressionOptions(options: BitPackerCompressionOptions) {
this._compressionOptions = options;
}
public get compressionEnabled(): boolean {
return this._compressionEnabled;
}
public set compressionEnabled(enabled: boolean) {
this._compressionEnabled = enabled;
}
/**
* Pack `data` to bytes, compress through the configured adapter (if
* enabled and not `"none"`), and frame with `[headerLen][algo][payload]`.
* The frame is what consumers send on the wire; receivers call
* `unpackWithDecompression` to reverse.
*/
public async packWithCompression(
data: unknown,
schema?: BitPackerSchema
): Promise<Uint8Array> {
const packed = super.pack(data, schema);
const algorithm =
this._compressionEnabled && this._compressionAlgorithm !== "none"
? this._compressionAlgorithm
: "none";
let payload: Uint8Array;
if (algorithm === "none") {
payload = packed;
} else {
payload = await compressData(
packed,
algorithm,
this._compressionOptions,
this.compressionRegistry
);
}
return frame(algorithm, payload);
}
/**
* Reverse of `packWithCompression`. Reads the framing header, dispatches
* to the named adapter for decompression (or routes the body straight
* through if the header is `"none"`), then bit-unpacks the result.
*
* If `data` is empty, returns the result of unpacking an empty buffer
* via the parent class (handles the "no-data" idiom upstream callers
* relied on).
*/
public async unpackWithDecompression(
data?: Uint8Array,
schema?: BitPackerSchema
): Promise<unknown> {
if (data === undefined || data.length === 0) {
return super.unpack(data ?? new Uint8Array(0), schema);
}
const { algorithm, payload } = unframe(data);
const decompressed =
algorithm === "none"
? payload
: await decompressData(
payload,
algorithm,
this._compressionOptions,
this.compressionRegistry
);
return super.unpack(decompressed, schema);
}
}
/**
* Frame: `[headerLen: u8][header: ASCII algorithm name][payload bytes]`.
*
* The header length is bounded to 255 because `headerLen` is one byte. The
* registered algorithm names ("none", "gzip", "deflate", future "brotli" /
* "zstd") are all well under that. If a future custom algorithm name
* exceeds 255 bytes when ASCII-encoded, the framing throws — caller can
* then choose a shorter alias to register the same adapter under.
*/
function frame(algorithm: string, payload: Uint8Array): Uint8Array {
const header = new TextEncoder().encode(algorithm);
if (header.length === 0 || header.length > 255) {
throw new BitpackerError(
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
`Compression algorithm name "${algorithm}" must encode to 1–255 bytes (got ${header.length})`,
{ context: { algorithm, headerLength: header.length } }
);
}
const out = new Uint8Array(1 + header.length + payload.length);
out[0] = header.length;
out.set(header, 1);
out.set(payload, 1 + header.length);
return out;
}
function unframe(data: Uint8Array): {
algorithm: string;
payload: Uint8Array;
} {
if (data.length < 2) {
throw new BitpackerError(
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
`Frame too short: ${data.length} bytes (need ≥ 2)`,
{ context: { length: data.length } }
);
}
const headerLen = data[0] as number;
if (headerLen === 0 || headerLen >= data.length) {
throw new BitpackerError(
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
`Frame header length ${headerLen} out of range for ${data.length}-byte frame`,
{ context: { headerLength: headerLen, frameLength: data.length } }
);
}
const algorithm = new TextDecoder().decode(data.slice(1, 1 + headerLen));
const payload = data.slice(1 + headerLen);
return { algorithm, payload };
}
export { frame as _frameForTests, unframe as _unframeForTests };

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { compressRle, decompressRle } from "./index.js";
describe("compressed", () => {
it("round-trips RLE compression", () => {
const data = new Uint8Array([1, 1, 1, 1, 2, 3, 3, 3, 3, 3]);
const compressed = compressRle(data);
const decompressed = decompressRle(compressed);
expect(Array.from(decompressed)).toEqual(Array.from(data));
});
it("handles incompressible data", () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const compressed = compressRle(data);
const decompressed = decompressRle(compressed);
expect(Array.from(decompressed)).toEqual(Array.from(data));
});
});

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-compressed/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,46 @@
/**
* Compression pipeline for bitpacker.
*
* Public surface:
*
* - `CompressedBitPacker<T>` — `BitPacker` subclass that compresses packed
* bytes through a configured `CompressionAdapter` and frames them with a
* self-describing algorithm header.
* - `CompressionRegistry` + `defaultCompressionRegistry()` /
* `createCompressionRegistry()` — plug-in registry for compression adapters.
* Defaults: `none` always; `gzip` / `deflate` lazy-registered when the
* host environment exposes `CompressionStream`.
* - `NoCompressionAdapter`, `GzipCompressionAdapter`, `DeflateCompressionAdapter`
* — built-in adapters.
* - `CompressionAdapter`, `CompressionConfig`, `BitPackerCompressionAlgorithm`,
* `BitPackerCompressionOptions` — types.
* - `compressData`/`decompressData` — algorithm-by-name helpers.
* - `compressRle`/`decompressRle` — bytes-only RLE fast path (also re-exported
* from `./rle` for callers that want the focused subpath).
*/
export { DeflateCompressionAdapter } from "./adapters/deflate/deflate.js";
export { GzipCompressionAdapter } from "./adapters/gzip/gzip.js";
export { NoCompressionAdapter } from "./adapters/none/none.js";
export { compressData, decompressData } from "./api/api.js";
export {
CompressedBitPacker,
type CompressedBitPackerOptions,
} from "./compressed-bit-packer/compressed-bit-packer.js";
export {
_resetDefaultRegistryForTests,
CompressionRegistry,
createCompressionRegistry,
defaultCompressionRegistry,
} from "./registry/registry.js";
export {
type CompressionOptions,
compressRle,
decompressRle,
} from "./rle/rle.js";
export type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
CompressionConfig,
} from "./types/types.js";

View File

@@ -0,0 +1,83 @@
# Bitpacker Compressed OTel Instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OTel meter, tracer, counter, and histogram declarations for `@suaveplan/bitpacker-compressed`. Ships canonical instrumentation primitives for the compression/decompression pipeline (`gzip`, `deflate`, `none`, `rle`, future custom adapters) plus the `CompressedBitPacker.pack` / `unpack` boundaries that wrap the compression step. Every operation can be observed with the same vocabulary regardless of which adapter is selected.
The metric and span names follow the suaveplan convention with snake-case for the multi-word package: tracer/meter named `@suaveplan/bitpacker-compressed`; spans named `bitpacker_compressed.<operation>`; counters/histograms named `bitpacker_compressed.<scope>.<metric>`.
## Features
- `getBitPackerCompressedTracer()` — returns the package's tracer
- `compressionOpsTotal` — counter labelled by `op`, `algorithm`, `success`
- `compressionOpDurationMs` — histogram of compress/decompress duration
- `compressionRatio` — histogram of `output_bytes / input_bytes` per successful compress
- `BITPACKER_COMPRESSED_SPAN_NAMES` — four canonical span names
## Usage: Basic Example
```ts
import {
BITPACKER_COMPRESSED_SPAN_NAMES,
compressionOpsTotal,
getBitPackerCompressedTracer,
} from "@suaveplan/bitpacker-compressed";
const tracer = getBitPackerCompressedTracer();
tracer.startActiveSpan(
BITPACKER_COMPRESSED_SPAN_NAMES.compress,
(span) => {
try {
// compress work
compressionOpsTotal.add(1, {
op: "compress",
algorithm: "gzip",
success: "true",
});
} finally {
span.end();
}
}
);
```
## Usage: Advanced Example
```ts
import { compressionRatio } from "@suaveplan/bitpacker-compressed";
function recordCompressionRatio(input: Uint8Array, output: Uint8Array, algorithm: string) {
compressionRatio.record(output.byteLength / input.byteLength, { algorithm });
}
```
## API Reference
### `getBitPackerCompressedTracer(): Tracer`
Returns the OTel `Tracer` instance for the package.
### `compressionOpsTotal: Counter`
Counter labelled by `op: "compress" | "decompress"`, `algorithm: string`, `success: "true" | "false"`.
### `compressionOpDurationMs: Histogram`
Histogram of elapsed milliseconds per compress/decompress call.
### `compressionRatio: Histogram`
Histogram of compression ratio (`output_bytes / input_bytes`), labelled by `algorithm`. Recorded only on successful `compress` calls — decompress doesn't have a meaningful ratio.
### `BITPACKER_COMPRESSED_SPAN_NAMES`
Const map: `{ compress, decompress, pack, unpack }` → canonical span names.
## Implementation Notes
- **Algorithm is a label, not a counter prefix.** `compressionOpsTotal{algorithm="gzip"}` is more flexible than separate `gzip_compress_total` / `deflate_compress_total` counters; new adapters fold in without schema changes.
- **`compressionRatio` is not a counter.** The ratio is per-call and stored as a histogram so dashboards can plot p50/p95/p99 ratios over time. A counter would only show throughput, not effectiveness.
- **No per-chunk spans.** Compression adapters often process bytes in internal chunks; one span each would explode trace volume on large inputs.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_COMPRESSED_SPAN_NAMES,
compressionOpDurationMs,
compressionOpsTotal,
compressionRatio,
getBitPackerCompressedTracer,
} from "./instruments.js";
describe("OTel instruments — declarations", () => {
it("exposes the canonical span names", () => {
expect(BITPACKER_COMPRESSED_SPAN_NAMES.compress).toBe(
"bitpacker_compressed.compress"
);
expect(BITPACKER_COMPRESSED_SPAN_NAMES.decompress).toBe(
"bitpacker_compressed.decompress"
);
expect(BITPACKER_COMPRESSED_SPAN_NAMES.pack).toBe(
"bitpacker_compressed.pack"
);
expect(BITPACKER_COMPRESSED_SPAN_NAMES.unpack).toBe(
"bitpacker_compressed.unpack"
);
});
it("counters and histograms are constructed without error", () => {
expect(compressionOpsTotal).toBeDefined();
expect(compressionOpDurationMs).toBeDefined();
expect(compressionRatio).toBeDefined();
});
it("tracer is reachable", () => {
expect(getBitPackerCompressedTracer()).toBeDefined();
});
it("counters accept the documented labels", () => {
expect(() =>
compressionOpsTotal.add(1, {
op: "compress",
algorithm: "gzip",
success: "true",
})
).not.toThrow();
});
});

View File

@@ -0,0 +1,51 @@
import { metrics, type Tracer, trace } from "@opentelemetry/api";
const meter = metrics.getMeter("@suaveplan/bitpacker-compressed");
export function getBitPackerCompressedTracer(): Tracer {
return trace.getTracer("@suaveplan/bitpacker-compressed");
}
/**
* Counter incremented per top-level compress / decompress call. Labels:
* `op` (`compress` | `decompress`), `algorithm` (e.g. `gzip`, `deflate`,
* `none`, `rle`), and `success`.
*/
export const compressionOpsTotal = meter.createCounter(
"bitpacker_compressed.ops.total",
{
description: "Total compression / decompression operations",
unit: "{ops}",
}
);
/**
* Histogram of operation duration in milliseconds.
*/
export const compressionOpDurationMs = meter.createHistogram(
"bitpacker_compressed.op.duration_ms",
{
description: "Duration of compression / decompression in milliseconds",
unit: "ms",
}
);
/**
* Histogram of compression ratio (compressed bytes / input bytes), labelled
* by `algorithm`. Recorded only on successful `compress` calls.
*/
export const compressionRatio = meter.createHistogram(
"bitpacker_compressed.compression.ratio",
{
description:
"Compression ratio (output_bytes / input_bytes) per successful compress",
unit: "1",
}
);
export const BITPACKER_COMPRESSED_SPAN_NAMES = {
compress: "bitpacker_compressed.compress",
decompress: "bitpacker_compressed.decompress",
pack: "bitpacker_compressed.pack",
unpack: "bitpacker_compressed.unpack",
} as const;

View File

@@ -0,0 +1,5 @@
export {
BITPACKER_COMPRESSED_SCHEMA_VERSION,
BITPACKER_COMPRESSED_SUBJECT,
registerBitPackerCompressedSchemas,
} from "./register-schemas.js";

View File

@@ -0,0 +1,61 @@
# Register Schemas — `@suaveplan/bitpacker-compressed`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `registerBitPackerCompressedSchemas(registry)` which converts the package's five public boundary schemas into JSON Schema form and registers them under `bitpacker:@suaveplan/bitpacker-compressed` at version 1. Re-registration on the same registry is a no-op.
The five schemas covered are: `bitPackerCompressionOptions` (the open-record adapter options), `compressionAdapter` (the structural contract every adapter implements), `compressionConfig` (the `{ algorithm, options, enabled }` config), `compressedBitPackerOptions` (the `CompressedBitPacker` constructor bundle), and `rleCompressionOptions` (the RLE-only `level` knob).
## Features
- `BITPACKER_COMPRESSED_SUBJECT` constant — `"bitpacker:@suaveplan/bitpacker-compressed"`
- `BITPACKER_COMPRESSED_SCHEMA_VERSION` constant — `1`
- `registerBitPackerCompressedSchemas(registry)` — idempotent registration
## Usage: Basic Example
```ts
import { createRegistry } from "@suaveplan/schema-registry";
import {
BITPACKER_COMPRESSED_SUBJECT,
registerBitPackerCompressedSchemas,
} from "@suaveplan/bitpacker-compressed/register-schemas";
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
```
## Usage: Advanced Example
```ts
import { createRegistry, SchemaRegistry } from "@suaveplan/schema-registry";
import { registerBitPackerSchemas } from "@suaveplan/bitpacker/register-schemas";
import { registerBitPackerCompressedSchemas } from "@suaveplan/bitpacker-compressed/register-schemas";
const registry = createRegistry();
registerBitPackerSchemas(registry);
registerBitPackerCompressedSchemas(registry);
```
## API Reference
### `BITPACKER_COMPRESSED_SUBJECT: "bitpacker:@suaveplan/bitpacker-compressed"`
Const string.
### `BITPACKER_COMPRESSED_SCHEMA_VERSION: 1`
Const number.
### `registerBitPackerCompressedSchemas(registry: SchemaRegistry): void`
Idempotent registration. Returns without writing if the subject is already present.
## Implementation Notes
- **Five-key payload.** One key per public boundary schema.
- **`unrepresentable: "any"`.** Required because `compressionAdapter` and `compressedBitPackerOptions` use `z.custom` for the structural contract and registry instance check.

View File

@@ -0,0 +1,42 @@
import { createRegistry } from "@suaveplan/schema-registry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_COMPRESSED_SCHEMA_VERSION,
BITPACKER_COMPRESSED_SUBJECT,
registerBitPackerCompressedSchemas,
} from "./register-schemas.js";
describe("registerBitPackerCompressedSchemas", () => {
it("registers under the namespaced subject at version 1", () => {
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
expect(registry.getLatest(BITPACKER_COMPRESSED_SUBJECT).version).toBe(
BITPACKER_COMPRESSED_SCHEMA_VERSION
);
});
it("includes every public schema in the payload", () => {
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
const payload = registry.getLatest(BITPACKER_COMPRESSED_SUBJECT)
.schema as Record<string, unknown>;
for (const key of [
"bitPackerCompressionOptions",
"compressionAdapter",
"compressionConfig",
"compressedBitPackerOptions",
"rleCompressionOptions",
]) {
expect(payload).toHaveProperty(key);
}
});
it("is idempotent across repeated calls", () => {
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
registerBitPackerCompressedSchemas(registry);
expect(registry.getLatest(BITPACKER_COMPRESSED_SUBJECT).version).toBe(
1
);
});
});

View File

@@ -0,0 +1,62 @@
import type { SchemaRegistry } from "@suaveplan/schema-registry";
import z from "zod";
import { compressedBitPackerOptionsSchema } from "../compressed-bit-packer/compressed-bit-packer.schema.js";
import { rleCompressionOptionsSchema } from "../rle/rle.schema.js";
import {
bitPackerCompressionOptionsSchema,
compressionAdapterSchema,
compressionConfigSchema,
} from "../types/types.schema.js";
export const BITPACKER_COMPRESSED_SUBJECT =
"bitpacker:@suaveplan/bitpacker-compressed" as const;
export const BITPACKER_COMPRESSED_SCHEMA_VERSION = 1;
interface Payload {
readonly bitPackerCompressionOptions: unknown;
readonly compressionAdapter: unknown;
readonly compressionConfig: unknown;
readonly compressedBitPackerOptions: unknown;
readonly rleCompressionOptions: unknown;
}
function buildPayload(): Payload {
const opts = { unrepresentable: "any" } as const;
return {
bitPackerCompressionOptions: z.toJSONSchema(
bitPackerCompressionOptionsSchema,
opts
),
compressionAdapter: z.toJSONSchema(compressionAdapterSchema, opts),
compressionConfig: z.toJSONSchema(compressionConfigSchema, opts),
compressedBitPackerOptions: z.toJSONSchema(
compressedBitPackerOptionsSchema,
opts
),
rleCompressionOptions: z.toJSONSchema(
rleCompressionOptionsSchema,
opts
),
};
}
/**
* Idempotently register `@suaveplan/bitpacker-compressed`'s public Zod
* schemas with the given `SchemaRegistry`.
*/
export function registerBitPackerCompressedSchemas(
registry: SchemaRegistry
): void {
if (registry.listSubjects().includes(BITPACKER_COMPRESSED_SUBJECT)) {
return;
}
registry.register(
BITPACKER_COMPRESSED_SUBJECT,
buildPayload(),
"json-schema",
{
package: "@suaveplan/bitpacker-compressed",
namespace: "bitpacker",
}
);
}

View File

@@ -0,0 +1,68 @@
# CompressionRegistry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CompressionRegistry` is the pluggable map from algorithm name to `CompressionAdapter` that backs `@suaveplan/bitpacker-compressed`'s public API. It is the single source of truth that `compressData`, `decompressData`, and `CompressedBitPacker` consult to look up an adapter by string name. Two flavours are exposed: a process-wide singleton (`defaultCompressionRegistry()`) for production code, and a fresh-instance factory (`createCompressionRegistry()`) for tests that need isolation. This mirrors the registry conventions used by `@suaveplan/random` and `@suaveplan/bitpacker-crypto` so the codebase stays uniform.
## Features
- Singleton + fresh-instance factory pattern (no class-static singletons).
- Coded errors: unknown algorithm → `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)`; soft-fail variant `tryResolve` returns `undefined`.
- Lazy default registration — `defaultCompressionRegistry()` registers `"none"`, `"gzip"`, `"deflate"` on first access (only registers gzip/deflate when `CompressionStream` is available).
- `withDefaults: false` option on `createCompressionRegistry` produces a completely empty registry for negative-path tests.
## Usage: Basic Example
```ts
import { defaultCompressionRegistry } from "@suaveplan/bitpacker-compressed";
const registry = defaultCompressionRegistry();
const adapter = registry.resolve("gzip");
const compressed = await adapter.compress(myBytes);
```
## Usage: Advanced Example
```ts
import {
createCompressionRegistry,
type CompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
// Plug in a custom Brotli adapter (caller-owned WASM Brotli wrapper).
class BrotliAdapter implements CompressionAdapter {
readonly name = "brotli";
async compress(data: Uint8Array) { /* WASM call */ return new Uint8Array(); }
async decompress(data: Uint8Array) { /* WASM call */ return new Uint8Array(); }
}
const registry = createCompressionRegistry();
registry.register(new BrotliAdapter());
const adapter = registry.resolve("brotli");
```
## API Reference
### `defaultCompressionRegistry(): CompressionRegistry`
Returns the lazily-initialized process-wide singleton. Same reference every call.
### `createCompressionRegistry(options?: { withDefaults?: boolean }): CompressionRegistry`
Returns a fresh registry. `withDefaults` defaults to `true`; pass `false` to start empty.
### `CompressionRegistry`
- `register(adapter: CompressionAdapter): void` — Last-write-wins by `adapter.name`.
- `resolve(name: string): CompressionAdapter` — Throws on unknown.
- `tryResolve(name: string): CompressionAdapter | undefined` — Soft-fail variant.
- `list(): readonly string[]` — All registered adapter names.
## Implementation Notes
- The singleton is module-scope inside `registry.ts`; tests that need to mutate it create a fresh `createCompressionRegistry()` rather than poking the singleton.
- Default registration of gzip/deflate is gated on `typeof CompressionStream === "function"` so the registry stays usable in environments without WHATWG Streams (the gzip/deflate adapters themselves throw when called there, but lookup never fails).
- `register` mutates the registry; this is the documented behaviour for plug-in adapters. There is no immutable variant.

View File

@@ -0,0 +1,122 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import {
_resetDefaultRegistryForTests,
CompressionRegistry,
createCompressionRegistry,
defaultCompressionRegistry,
} from "./registry.js";
describe("CompressionRegistry", () => {
it("registers and resolves an adapter by name", { timeout: 5000 }, () => {
const reg = new CompressionRegistry();
const adapter = new NoCompressionAdapter();
reg.register(adapter);
expect(reg.resolve("none")).toBe(adapter);
expect(reg.has("none")).toBe(true);
});
it("resolve throws UNKNOWN_COMPRESSION_ALGORITHM for missing adapter", {
timeout: 5000,
}, () => {
const reg = new CompressionRegistry();
expect(() => reg.resolve("zstd")).toThrow(BitpackerError);
try {
reg.resolve("zstd");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM
);
expect((e as BitpackerError).context?.algorithm).toBe("zstd");
}
});
it("tryResolve returns undefined for missing adapter (soft-fail)", {
timeout: 5000,
}, () => {
const reg = new CompressionRegistry();
expect(reg.tryResolve("zstd")).toBeUndefined();
});
it("register replaces an existing adapter (last-write-wins)", {
timeout: 5000,
}, () => {
const reg = new CompressionRegistry();
const a = new NoCompressionAdapter();
const b = new NoCompressionAdapter();
reg.register(a);
reg.register(b);
expect(reg.resolve("none")).toBe(b);
});
it("list and getAll surface registered adapters", { timeout: 5000 }, () => {
const reg = new CompressionRegistry();
reg.register(new NoCompressionAdapter());
expect(reg.list()).toEqual(["none"]);
expect(reg.getAll()).toHaveLength(1);
});
it("createCompressionRegistry seeds defaults including 'none'", {
timeout: 5000,
}, () => {
const reg = createCompressionRegistry();
expect(reg.has("none")).toBe(true);
});
it("defaultCompressionRegistry returns a singleton", {
timeout: 5000,
}, () => {
const a = defaultCompressionRegistry();
const b = defaultCompressionRegistry();
expect(a).toBe(b);
});
it("createCompressionRegistry isolates from singleton", {
timeout: 5000,
}, () => {
const fresh = createCompressionRegistry();
const singleton = defaultCompressionRegistry();
// Mutate the fresh one — singleton must not see it.
class Marker extends NoCompressionAdapter {
public override readonly name = "test-marker-only";
}
fresh.register(new Marker());
expect(fresh.has("test-marker-only")).toBe(true);
expect(singleton.has("test-marker-only")).toBe(false);
});
it("_resetDefaultRegistryForTests rebuilds the singleton", {
timeout: 5000,
}, () => {
const before = defaultCompressionRegistry();
_resetDefaultRegistryForTests();
const after = defaultCompressionRegistry();
// Different instance after reset proves the cache was dropped.
expect(after).not.toBe(before);
});
it("defaultCompressionRegistry skips lazy gzip/deflate when CompressionStream is missing", {
timeout: 5000,
}, () => {
// Force the singleton to be rebuilt while CompressionStream is hidden,
// so we exercise the `typeof CompressionStream === "undefined"` arm of
// the lazy-registration block. The finally clauses keep this isolated.
const g = globalThis as unknown as {
CompressionStream?: typeof CompressionStream;
};
const saved = g.CompressionStream;
_resetDefaultRegistryForTests();
try {
g.CompressionStream = undefined;
const fresh = defaultCompressionRegistry();
// Only "none" is seeded synchronously; gzip/deflate are not added.
expect(fresh.has("none")).toBe(true);
expect(fresh.has("gzip")).toBe(false);
expect(fresh.has("deflate")).toBe(false);
} finally {
g.CompressionStream = saved;
_resetDefaultRegistryForTests();
}
});
});

View File

@@ -0,0 +1,118 @@
/**
* `CompressionRegistry` — pluggable map from algorithm name to
* `CompressionAdapter`. Genesis port of upstream
* `bitpacker/src/compressed/compression.ts::CompressionRegistry` with two
* shape changes:
*
* 1. **No singleton-via-private-static**. Genesis convention is the same as
* `@suaveplan/random` and `@suaveplan/bitpacker-crypto`'s registries:
* a process-wide singleton accessor (`defaultCompressionRegistry()`) plus
* a fresh-instance factory (`createCompressionRegistry()`).
* 2. **Coded errors**. `resolve` of an unknown algorithm throws
* `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` rather than returning
* `undefined` and forcing every caller to write the same `if (!adapter)`
* branch. `tryResolve` is provided for the rare case a caller wants the
* soft-fail path.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import type { CompressionAdapter } from "../types/types.js";
export class CompressionRegistry {
private readonly adapters = new Map<string, CompressionAdapter>();
/**
* Register an adapter. If `adapter.name` is already registered, the new
* adapter replaces the old (last-write-wins). Used both for shipping
* defaults at construction time and for downstream consumers plugging in
* custom algorithms (e.g. `"brotli"`, `"zstd"`).
*/
public register(adapter: CompressionAdapter): void {
this.adapters.set(adapter.name, adapter);
}
/**
* Resolve an adapter by name. Throws `BitpackerError` with code
* `UNKNOWN_COMPRESSION_ALGORITHM` and `context.algorithm` when no
* adapter is registered for `name`. Use this on the production hot path.
*/
public resolve(name: string): CompressionAdapter {
const adapter = this.adapters.get(name);
if (adapter === undefined) {
throw new BitpackerError(
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM,
`Compression algorithm "${name}" is not registered`,
{ context: { algorithm: name } }
);
}
return adapter;
}
/**
* Soft-fail variant of `resolve`. Returns the adapter or `undefined`
* without throwing. Use this when a caller wants to feature-detect
* before deciding what to do.
*/
public tryResolve(name: string): CompressionAdapter | undefined {
return this.adapters.get(name);
}
public has(name: string): boolean {
return this.adapters.has(name);
}
public list(): readonly string[] {
return Array.from(this.adapters.keys());
}
public getAll(): readonly CompressionAdapter[] {
return Array.from(this.adapters.values());
}
}
let singleton: CompressionRegistry | undefined;
/**
* Build a fresh registry pre-seeded with the always-shipping defaults
* (`none`). Browser-only `gzip`/`deflate` adapters are registered lazily by
* `defaultCompressionRegistry()` based on environment detection — using
* `createCompressionRegistry()` directly skips that registration so tests
* can exercise the registry without `CompressionStream` polyfills.
*/
export function createCompressionRegistry(): CompressionRegistry {
const registry = new CompressionRegistry();
registry.register(new NoCompressionAdapter());
return registry;
}
/**
* Return the process-wide registry. Lazy-initialised; gzip/deflate adapters
* are registered if `CompressionStream` is available in the host
* environment. Re-importing the module does NOT rebuild the singleton.
*/
export function defaultCompressionRegistry(): CompressionRegistry {
if (singleton === undefined) {
singleton = createCompressionRegistry();
if (typeof CompressionStream !== "undefined") {
// Lazy-import the browser-stream adapters to avoid a hard
// dependency on `CompressionStream` at module load.
void import("../adapters/gzip/gzip.js").then((m) => {
singleton?.register(new m.GzipCompressionAdapter());
});
void import("../adapters/deflate/deflate.js").then((m) => {
singleton?.register(new m.DeflateCompressionAdapter());
});
}
}
return singleton;
}
/**
* Test-only escape hatch — drop the cached singleton so the next
* `defaultCompressionRegistry()` call rebuilds. Production code MUST NOT
* call this; tests use it to verify isolation.
*/
export function _resetDefaultRegistryForTests(): void {
singleton = undefined;
}

View File

@@ -0,0 +1,78 @@
# Run-length encoding (`compressRle` / `decompressRle`)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
## Purpose
`compressRle` / `decompressRle` are byte-level run-length encoding helpers shipped as a separate module within `@suaveplan/bitpacker-compressed` so embedded / no-streams environments can import them without pulling in `CompressionStream`-using gzip/deflate adapters or the `CompressedBitPacker` class. They are not part of the `CompressionRegistry` framing — bytes produced by `compressRle` are NOT the same shape as bytes produced by the registry's `"none"`/`"gzip"`/`"deflate"` adapters, which include the `CompressedBitPacker` header. If you need framed RLE, write a thin `RleCompressionAdapter` that internally calls these helpers.
## Features
- Bytes-only fast path; no streams required.
- Single-byte runs longer than 3 are emitted as `[byte][0][count]`; everything else passes through verbatim.
- Symmetric round-trip with `decompressRle`.
- Zero allocation overhead beyond the output `Uint8Array`.
## Usage: Basic Example
```ts
import { compressRle, decompressRle } from "@suaveplan/bitpacker-compressed";
const data = new Uint8Array([1, 1, 1, 1, 1, 2, 3]);
const compressed = compressRle(data); // [1, 0, 5, 2, 3]
const restored = decompressRle(compressed); // back to original
```
## Usage: Advanced Example
```ts
import {
compressRle,
decompressRle,
type CompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
// Wrap RLE in a CompressionAdapter shape so it plugs into the registry.
class RleAdapter implements CompressionAdapter {
readonly name = "rle";
async compress(data: Uint8Array) { return compressRle(data); }
async decompress(data: Uint8Array) { return decompressRle(data); }
}
```
## API Reference
### `compressRle(data: Uint8Array): Uint8Array`
- Encodes runs of >3 identical bytes as `[byte][0][count]`.
- Returns a fresh `Uint8Array`; input is not mutated.
### `decompressRle(data: Uint8Array): Uint8Array`
- Reverses `compressRle`. Looks ahead for `[0][count]` markers after each byte.
- Returns a fresh `Uint8Array`.
### `CompressionOptions`
Type alias for `{ readonly level?: number }` — accepted but ignored by the bytes-only RLE path; included so adapter authors can re-export the canonical compression-options shape.
## Implementation Notes
- The marker byte is `0`, which would collide with a literal `0` in input data; the encoder handles this by always emitting the run-length form when `byte === 0` runs.
- This is not the most aggressive RLE possible — it does not pack runs of distinct bytes into literal blocks. The intent is "smaller than verbatim with a single linear pass", not "competitive with gzip on text".
- Use this when you have known byte-pattern data (e.g. graphics with large flat regions, sparse network frames). For general-purpose data, use the gzip / deflate adapters.

View File

@@ -0,0 +1,48 @@
# RLE Compression Options Schema
## Purpose
Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-only and exposes a single tuning knob (`level`) — this schema validates that knob at the boundary so untrusted input can be normalized before reaching the codec.
## Features
- `rleCompressionOptionsSchema` — strict, single optional field
- `level` is a non-negative integer; the codec ignores values outside its supported range
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
## Usage: Basic Example
```ts
import { compressRle, rleCompressionOptionsSchema } from "@suaveplan/bitpacker-compressed";
const opts = rleCompressionOptionsSchema.parse({ level: 1 });
const compressed = compressRle(new Uint8Array([0, 0, 0, 0, 1, 2]), opts);
```
## Usage: Advanced Example
```ts
import {
compressRle,
decompressRle,
rleCompressionOptionsSchema,
} from "@suaveplan/bitpacker-compressed";
function safeRoundTrip(data: Uint8Array, rawOpts: unknown) {
const opts = rleCompressionOptionsSchema.parse(rawOpts);
const compressed = compressRle(data, opts);
const decompressed = decompressRle(compressed);
return decompressed;
}
```
## API Reference
### `rleCompressionOptionsSchema: z.ZodType<CompressionOptions>`
Strict schema. `level` is an optional non-negative integer. Throws `ZodError` on negative or non-integer values, or on unknown keys.
## Implementation Notes
- **Why a one-field schema.** Even a single-field bundle benefits from boundary validation: a `{ level: "9" }` (string instead of number) would otherwise reach the codec and produce undefined behaviour. Refusing it at parse time keeps the codec's contract simple.
- **Codec ignores out-of-range levels.** The schema enforces non-negative integer; the codec itself ignores values outside `[0, 9]`. The schema is the boundary; the codec is the implementation.

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { rleCompressionOptionsSchema } from "./rle.schema.js";
describe("rleCompressionOptionsSchema", () => {
it("accepts an empty bundle", () => {
expect(rleCompressionOptionsSchema.parse({})).toEqual({});
});
it("accepts a non-negative integer level", () => {
expect(rleCompressionOptionsSchema.parse({ level: 0 })).toEqual({
level: 0,
});
expect(rleCompressionOptionsSchema.parse({ level: 9 })).toEqual({
level: 9,
});
});
it("rejects a negative level", () => {
expect(() =>
rleCompressionOptionsSchema.parse({ level: -1 })
).toThrow();
});
it("rejects unknown keys", () => {
expect(() =>
rleCompressionOptionsSchema.parse({ level: 1, bogus: true })
).toThrow();
});
});

View File

@@ -0,0 +1,16 @@
import z from "zod";
import type { CompressionOptions } from "./rle.js";
/**
* Strict Zod schema for the RLE-only {@link CompressionOptions}.
*
* Validates the per-call options for `compressRle` / `decompressRle`. The
* RLE codec is byte-only and accepts a single `level` knob; this schema
* exists primarily so callers can normalize untrusted input before reaching
* the codec.
*/
export const rleCompressionOptionsSchema = z
.object({
level: z.number().int().nonnegative().optional(),
})
.strict() as unknown as z.ZodType<CompressionOptions>;

View File

@@ -0,0 +1,74 @@
/**
* Run-length encoding helpers — bytes-only fast path.
*
* Lives alongside the full compression pipeline as a separate module so
* consumers wanting RLE for embedded / no-streams environments can import
* it without paying the cost of pulling in `CompressionStream`-using gzip /
* deflate adapters or the `CompressedBitPacker` class.
*
* The format is intentionally simple — a single byte that repeats more
* than three times is emitted as `[byte][0][count]`; everything else
* passes through verbatim. Decompression reverses by looking ahead for the
* `0` marker. There is no header — RLE bytes are NOT compatible with the
* `CompressedBitPacker` framing (which would require an "rle" header).
* If you need the framing, register an `RleCompressionAdapter` with the
* registry that internally calls these helpers.
*/
export interface CompressionOptions {
readonly level?: number;
}
/**
* Compress a byte array using run-length encoding. Runs of more than three
* identical bytes collapse to `[byte][0][count]`; shorter runs pass through.
* The output is at worst the same length as the input plus 2× the number
* of zero bytes (which would otherwise be ambiguous with the marker).
*/
export function compressRle(data: Uint8Array): Uint8Array {
const result: number[] = [];
let i = 0;
while (i < data.length) {
const byte = data[i] as number;
let count = 1;
while (
i + count < data.length &&
data[i + count] === byte &&
count < 255
) {
count++;
}
if (count > 3) {
result.push(byte, 0, count);
i += count;
} else {
result.push(byte);
i++;
}
}
return new Uint8Array(result);
}
/**
* Decompress a byte array previously produced by `compressRle`. The reverse
* of the encoding rule: a `[byte][0][count]` triple expands to `count`
* copies of `byte`; every other byte passes through verbatim.
*/
export function decompressRle(data: Uint8Array): Uint8Array {
const result: number[] = [];
let i = 0;
while (i < data.length) {
const byte = data[i] as number;
if (i + 2 < data.length && data[i + 1] === 0) {
const count = data[i + 2] as number;
for (let j = 0; j < count; j++) {
result.push(byte);
}
i += 3;
} else {
result.push(byte);
i++;
}
}
return new Uint8Array(result);
}

View File

@@ -0,0 +1,92 @@
# Compression types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
## Purpose
This module collects the public TypeScript type companions used across `@suaveplan/bitpacker-compressed`: the algorithm-name string brand, the per-call options shape, and the `CompressionAdapter` interface that any plug-in codec must implement. Keeping them in one file means downstream consumers only need a single import path to write a custom adapter or to type a config object that flows through the pipeline. The shapes mirror the upstream `bitpacker/src/types.ts` compression types verbatim so adapters and consumers see the same contracts whether they imported from upstream or the port.
## Features
- `BitPackerCompressionAlgorithm` — string-typed registry key (e.g. `"none"`, `"gzip"`, `"deflate"`, `"brotli"`).
- `BitPackerCompressionOptions` — caller-supplied options forwarded to the adapter (currently `level?: number`).
- `CompressionAdapter` — the contract every codec implements: `name`, `compress`, `decompress`.
- All async to match the WHATWG Streams reality; sync codecs simply resolve immediately.
## Usage: Basic Example
```ts
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
const algorithm: BitPackerCompressionAlgorithm = "gzip";
const options: BitPackerCompressionOptions = { level: 9 };
```
## Usage: Advanced Example
```ts
import type { CompressionAdapter } from "@suaveplan/bitpacker-compressed";
// Custom adapter for a hypothetical FastLZ codec.
class FastLzAdapter implements CompressionAdapter {
readonly name = "fastlz";
async compress(data: Uint8Array, options?: { level?: number }) {
// Call into FastLZ-WASM module here.
return data; // placeholder
}
async decompress(data: Uint8Array, _options?: { level?: number }) {
return data; // placeholder
}
}
```
## API Reference
### `BitPackerCompressionAlgorithm`
Type alias for `string`. Used as the registry lookup key. Default registered names: `"none"`, `"gzip"`, `"deflate"`. Consumers MAY register additional algorithms.
### `BitPackerCompressionOptions`
```ts
interface BitPackerCompressionOptions {
readonly level?: number;
}
```
Forwarded to the adapter. Adapters that don't support `level` (e.g. `none`) ignore it.
### `CompressionAdapter`
```ts
interface CompressionAdapter {
readonly name: BitPackerCompressionAlgorithm;
compress(data: Uint8Array, options?: BitPackerCompressionOptions): Promise<Uint8Array>;
decompress(data: Uint8Array, options?: BitPackerCompressionOptions): Promise<Uint8Array>;
}
```
## Implementation Notes
- `BitPackerCompressionAlgorithm` is intentionally `string`, not a closed union — module augmentation is overkill for plug-in codecs and the registry already validates names at lookup.
- All methods are async because the standard browser/Node codecs (`CompressionStream`) are async. The pass-through `NoCompressionAdapter` resolves immediately so async overhead is negligible.
- Adapters MUST be deterministic: same input + same options ⇒ same output bytes (modulo internal nondeterminism in the underlying codec, which is allowed but unusual).

View File

@@ -0,0 +1,77 @@
# Compression Type Schemas
## Purpose
Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record `BitPackerCompressionOptions`, the `CompressionAdapter` interface (the contract every adapter implements), and the `CompressionConfig` configuration shape consumed by `CompressedBitPacker`. Each schema is the runtime gatekeeper at the corresponding boundary so an adapter loaded from a plugin manifest, or a config bundle parsed from environment variables, can be validated before reaching the compression hot path.
The schemas use `z.custom<T>((v): v is T => …)` for the adapter validation so adapter prototype chains are preserved (registries depend on prototype-aware `instanceof` checks), and `z.record(z.string(), z.unknown())` for the open-record `BitPackerCompressionOptions` because each adapter accepts its own option keys — the schema validates "is this an object?" and lets the adapter validate its own option semantics.
## Features
- `bitPackerCompressionOptionsSchema` — open record (any string-keyed object)
- `compressionAdapterSchema` — structural contract: `name: string`, `compress: function`, `decompress: function`
- `compressionConfigSchema` — strict, three optional fields (`algorithm`, `options`, `enabled`)
- Identity-preserving `z.custom` for adapter validation
- `as unknown as z.ZodType<T>` casts where Zod's `T | undefined` collides with `exactOptionalPropertyTypes`
## Usage: Basic Example
```ts
import {
CompressedBitPacker,
compressionAdapterSchema,
compressionConfigSchema,
NoCompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
const adapter = compressionAdapterSchema.parse(new NoCompressionAdapter());
const cfg = compressionConfigSchema.parse({ algorithm: "none", enabled: true });
```
## Usage: Advanced Example
```ts
import {
CompressedBitPacker,
bitPackerCompressionOptionsSchema,
compressionAdapterSchema,
compressionConfigSchema,
} from "@suaveplan/bitpacker-compressed";
async function loadVendorAdapter(url: URL) {
const mod = await import(url.href);
const adapter = compressionAdapterSchema.parse(mod.default);
return adapter;
}
const opts = bitPackerCompressionOptionsSchema.parse({
level: 9,
windowBits: 15,
});
const cfg = compressionConfigSchema.parse({
algorithm: "deflate",
options: opts,
enabled: true,
});
```
## API Reference
### `bitPackerCompressionOptionsSchema: z.ZodType<BitPackerCompressionOptions>`
Open-record schema. Accepts any object with string keys. Adapter-specific validation is the adapter's responsibility.
### `compressionAdapterSchema: z.ZodType<CompressionAdapter>`
Structural contract validator. Asserts `name: string`, `compress: function`, `decompress: function`. Identity is preserved.
### `compressionConfigSchema: z.ZodType<CompressionConfig>`
Strict schema. Three optional fields: `algorithm: string`, `options: BitPackerCompressionOptions`, `enabled: boolean`.
## Implementation Notes
- **Open-record options.** Adapters accept arbitrary keys (`level`, `windowBits`, `dictionary`, …). Validating them at this layer would force this package to know every adapter's surface, which it can't. The contract is "is this an object?"; semantic validation belongs to the adapter.
- **Structural adapter check.** A duck-typed object that exposes `name/compress/decompress` works at runtime. Refusing duck-types would break the plugin model where adapters are loaded from external modules without inheriting from a base class.
- **Identity preservation.** `z.custom` for the adapter; the registry's `register(adapter)` method uses `===` and `instanceof` checks downstream.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import {
bitPackerCompressionOptionsSchema,
compressionAdapterSchema,
compressionConfigSchema,
} from "./types.schema.js";
describe("bitPackerCompressionOptionsSchema", () => {
it("accepts an empty record", () => {
expect(bitPackerCompressionOptionsSchema.parse({})).toEqual({});
});
it("accepts adapter-specific keys", () => {
const opts = { level: 6, dictionary: new Uint8Array() };
expect(bitPackerCompressionOptionsSchema.parse(opts)).toEqual(opts);
});
it("rejects non-objects", () => {
expect(() =>
bitPackerCompressionOptionsSchema.parse("level=6")
).toThrow();
});
});
describe("compressionAdapterSchema", () => {
it("accepts NoCompressionAdapter", () => {
const adapter = new NoCompressionAdapter();
expect(compressionAdapterSchema.parse(adapter)).toBe(adapter);
});
it("rejects an object missing compress", () => {
expect(() =>
compressionAdapterSchema.parse({
name: "x",
decompress: async () => new Uint8Array(),
})
).toThrow();
});
it("rejects an object whose name is not a string", () => {
expect(() =>
compressionAdapterSchema.parse({
name: 1,
compress: async () => new Uint8Array(),
decompress: async () => new Uint8Array(),
})
).toThrow();
});
});
describe("compressionConfigSchema", () => {
it("accepts an empty config", () => {
expect(compressionConfigSchema.parse({})).toEqual({});
});
it("accepts a populated config", () => {
const cfg = {
algorithm: "gzip",
options: { level: 6 },
enabled: true,
};
expect(compressionConfigSchema.parse(cfg)).toEqual(cfg);
});
it("rejects unknown keys", () => {
expect(() =>
compressionConfigSchema.parse({ algorithm: "gzip", bogus: 1 })
).toThrow();
});
});

View File

@@ -0,0 +1,55 @@
import z from "zod";
import type {
BitPackerCompressionOptions,
CompressionAdapter,
CompressionConfig,
} from "./types.js";
/**
* Strict Zod schema for {@link BitPackerCompressionOptions}.
*
* The shape is an open record (`Record<string, unknown>`) because each
* adapter accepts its own option keys. Validation is therefore a structural
* "is this an object?" check — adapters validate their own options.
*/
export const bitPackerCompressionOptionsSchema = z.record(
z.string(),
z.unknown()
) as unknown as z.ZodType<BitPackerCompressionOptions>;
/**
* Strict Zod schema for {@link CompressionAdapter}.
*
* Validates that an external adapter exposes a string `name` and the two
* required async methods (`compress`, `decompress`). Identity is preserved
* via `z.custom` so the registered adapter keeps its prototype chain.
*/
export const compressionAdapterSchema = z.custom<CompressionAdapter>(
(v): v is CompressionAdapter => {
if (v === null || typeof v !== "object") return false;
const obj = v as Record<string, unknown>;
return (
typeof obj["name"] === "string" &&
typeof obj["compress"] === "function" &&
typeof obj["decompress"] === "function"
);
},
{
message:
"CompressionAdapter must expose name (string) and compress/decompress (functions)",
}
);
/**
* Strict Zod schema for {@link CompressionConfig}.
*
* Validates the `compression` field of `CompressedBitPackerOptions`. Every
* field is optional with documented defaults.
*/
export const compressionConfigSchema = z
.object({
algorithm: z.string().optional(),
options: bitPackerCompressionOptionsSchema.optional(),
enabled: z.boolean().optional(),
})
.strict() as unknown as z.ZodType<CompressionConfig>;

View File

@@ -0,0 +1,65 @@
/**
* Public type companions for the compression pipeline.
*
* These mirror the upstream `bitpacker/src/types.ts` compression types
* (`BitPackerCompressionAlgorithm`, `BitPackerCompressionOptions`,
* `CompressionAdapter`) so adapters and downstream consumers see the same
* shapes whether they imported from upstream or the genesis port.
*/
/**
* Algorithm name used to look up an adapter in the `CompressionRegistry`.
*
* Default registered algorithms are `"none"`, `"gzip"`, `"deflate"`.
* Consumers MAY register additional algorithms (e.g. `"brotli"`, `"zstd"`)
* via `CompressionRegistry.register(adapter)`.
*/
export type BitPackerCompressionAlgorithm = string;
/**
* Caller-supplied options forwarded to the compression / decompression call.
* Adapter-specific (e.g. gzip level, deflate window bits) — open record so
* future adapters can read whatever options they accept.
*/
export type BitPackerCompressionOptions = Record<string, unknown>;
/**
* The contract every compression adapter implements. Both methods are async
* because the canonical browser path (`CompressionStream` / `Decompression
* Stream`) is async; pure-byte adapters (e.g. `NoCompressionAdapter`) wrap
* their synchronous result in `Promise.resolve` to match the interface.
*/
export interface CompressionAdapter {
/** Algorithm name. Used as the registry key. */
readonly name: BitPackerCompressionAlgorithm;
/**
* Compress `data` with the supplied `options`. Adapters MAY ignore options
* they don't understand. Throws on codec failure (e.g. truncated input,
* unsupported environment); the caller wraps as `BitpackerError(COMPRESSION_FAILED)`.
*/
compress(
data: Uint8Array,
options?: BitPackerCompressionOptions
): Promise<Uint8Array>;
/**
* Decompress `data` with the supplied `options`. Throws on codec failure
* (truncated stream, bad header). Caller wraps as
* `BitpackerError(DECOMPRESSION_FAILED)` with the underlying error chained.
*/
decompress(
data: Uint8Array,
options?: BitPackerCompressionOptions
): Promise<Uint8Array>;
}
/**
* Compression configuration accepted by `CompressedBitPacker`. Mirrors the
* upstream `BitPackerConfig.compression` shape so any caller carrying that
* shape works without re-mapping.
*/
export interface CompressionConfig {
readonly algorithm?: BitPackerCompressionAlgorithm;
readonly options?: BitPackerCompressionOptions;
/** Default `true`. When `false`, packing skips the compression step entirely. */
readonly enabled?: boolean;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bitpacker",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul",
},
},
});

View File

@@ -0,0 +1,155 @@
# @suaveplan/bitpacker-crypto - Bit-packer-aware crypto primitives — SignedBitSchema, EncryptedBitSchema, EnvelopeSchema, HashChainSchema, MultiSignedBitSchema, SchemaFingerprint, HashAlgorithmRegistry. Authority/AeadOps/DomainTag types live in @suaveplan/types; the canonical implementations are in @suaveplan/crypto and dependency-injected by the caller.
## Summary
Bit-packer-aware crypto primitives. The shared interfaces (`Authority`,
`DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`;
the canonical implementations come from `@suaveplan/crypto` and are
**dependency-injected** by the caller. This package has zero runtime
dependency on `@suaveplan/crypto`, so consumers in any layer (including
`@suaveplan/crypto` itself, e.g. `crypto::ChainBundle`) can adopt these
primitives without inducing a circular package dependency.
<!-- begin:auto-docs -->
## Documentation tree
- [encrypted](./src/encrypted/encrypted-bitschema.md) — Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphe…
- [encrypted-bitschema](./src/encrypted/encrypted-bitschema.md) — Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphe…
- [envelope](./src/envelope/envelope-schema.md) — A typed, version-aware envelope schema for bit-packed payloads. Wraps a caller-supplied `BodyCodec` with a `[magic][for…
- [envelope-schema](./src/envelope/envelope-schema.md) — A typed, version-aware envelope schema for bit-packed payloads. Wraps a caller-supplied `BodyCodec` with a `[magic][for…
- [fingerprint](./src/fingerprint/schema-fingerprint.md) — Content-hash a `BitPackerSchema` so two parties can agree they're using byte-identical schemas before they exchange pay…
- [schema-fingerprint](./src/fingerprint/schema-fingerprint.md) — Content-hash a `BitPackerSchema` so two parties can agree they're using byte-identical schemas before they exchange pay…
- [hash-registry](./src/hash-registry/hash-registry.md) — A pluggable registry of named hash algorithms used by every hashing-aware primitive in `@suaveplan/bitpacker-crypto` (n…
- [hash-registry](./src/hash-registry/hash-registry.md) — A pluggable registry of named hash algorithms used by every hashing-aware primitive in `@suaveplan/bitpacker-crypto` (n…
- [hashchain](./src/hashchain/hashchain-schema.md) — Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload,…
- [hashchain-schema](./src/hashchain/hashchain-schema.md) — Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload,…
- [multisigned](./src/multisigned/multisigned-bitschema.md) — Bit-packed payload co-signed by N independent authorities. Wire format is `[bit-packed body][sig_1][sig_2]…[sig_N]`. Pi…
- [multisigned-bitschema](./src/multisigned/multisigned-bitschema.md) — Bit-packed payload co-signed by N independent authorities. Wire format is `[bit-packed body][sig_1][sig_2]…[sig_N]`. Pi…
- [signed](./src/signed/signed-bitschema.md) — Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. V…
- [signed-bitschema](./src/signed/signed-bitschema.md) — Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. V…
- [crc32](./src/crc32.md) — Fast non-cryptographic checksums for bit-packed payloads. `crc32` implements the IEEE-802.3 / zlib polynomial (`0xedb88…
- [index](./src/index.md) — **Path:** `bitpacker-crypto/src/index.ts` **Export:** See source for details.
- [xxhash32](./src/xxhash32.md) — **Path:** `src/xxhash32.ts` **Export:** `calculateXxhash32`, `verifyXxhash32`, `packXxhash32`, `unpackXxhash32`, `appen…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/bitpacker-crypto
```
## Public surface
| Primitive | Use it for |
|---|---|
| `EnvelopeSchema` | `[magic][formatVersion][body]` framing — version-aware wire formats |
| `SignedBitSchema` | Authority-signed payloads under a `DomainTag` |
| `MultiSignedBitSchema` | N-of-N co-signed payloads with `firstBadSignerIndex` reporting |
| `HashChainSchema` | Append-only signed hash chain (audit logs, mesh state) |
| `EncryptedBitSchema` | **Niche** AEAD-only envelope for **small** encrypted payloads |
| `SchemaFingerprint` | Canonical-hash a `BitPackerSchema` for handshakes |
| `HashAlgorithmRegistry` | Pluggable hash registry — sha256/xxhash32/crc32 default; register blake3 |
| `crc32` / `xxhash32` / `adler32` | Existing checksum surface, unchanged |
## Intended scope
`EncryptedBitSchema` is for **small** encrypted payloads only — token
bodies, key-wrap envelopes, sub-MTU control records. **Do NOT use it as a
chunked-file encoder.** For chunked-file encryption with a streaming
verifier and locked v0x0001 codec, reach for `@suaveplan/packer` instead.
## Crypto provider needs?
This package ships **bit-packer-aware** crypto primitives. For raw AEAD,
signing, key derivation, or authenticated encryption (without a bit-packer
schema), reach for the crypto family directly:
- [`@suaveplan/crypto`](../../crypto/crypto) — multi-algorithm façade (AEAD, sign,
agree, sealed-box, auth-box).
- [`@suaveplan/crypto-sodium`](../../crypto/crypto-sodium) — libsodium engine.
- [`@suaveplan/crypto-browser`](../../crypto/crypto-browser) — WebCrypto engine.
- [`@suaveplan/crypto-vault`](../../crypto/crypto-vault) — BIP39 + Argon2id HD vault.
- [`@suaveplan/packer`](../../data/packer) — chunked AEAD with manifest.
Upstream's `bitpacker/src/crypto/` provider abstraction is **not ported**;
see [SUPERSEDED.md](./SUPERSEDED.md) for the per-symbol replacement table.
## Quick examples
### Envelope a bit-packed body
```ts
import { defineEnvelopeSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
const headerSchema = defineBitSchema<{ foo: number }>([
{ name: "foo", type: "uint", bits: 32 },
]);
const env = defineEnvelopeSchema({
magic: new Uint8Array([0x53, 0x56, 0x43, 0x42]),
formatVersion: 1,
bodyCodec: {
pack: (b) => headerSchema.pack(b),
unpack: (bytes) => headerSchema.unpack(bytes),
},
});
const bytes = env.pack({ foo: 7 });
```
### Wiring the canonical crypto bindings
Every primitive that needs a verifier or AEAD bundle accepts it via
options. Pass the canonical wiring once in your application bootstrap:
```ts
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
import type { AeadOps, AuthorityVerifier } from "@suaveplan/types";
export const verifier: AuthorityVerifier = verifyAuthoritySignature;
export const aead: AeadOps = { encrypt: aeadEncrypt, decrypt: aeadDecrypt };
// Then everywhere you build a primitive:
defineSignedBitSchema(codec, { domain, verifier });
defineMultiSignedBitSchema(codec, { domain, verifier, signerCount });
defineHashChainSchema({ domain, verifier, payloadCodec });
defineEncryptedBitSchema(codec, { key, aead });
```
### Sign + encrypt a token
```ts
import {
defineEncryptedBitSchema,
defineSignedBitSchema,
} from "@suaveplan/bitpacker-crypto";
import { DOMAIN_BITPACKER_SIGNED_V1 } from "@suaveplan/crypto/domain-tags";
const inner = defineEncryptedBitSchema(tokenCodec, { key: aeadKey });
const innerCodec = {
pack: (t) => inner.pack(t),
unpack: (bytes) => inner.unpack(bytes),
};
// (Signing receives Promise.unwrap() on the body — for the canary chain
// pattern, prefer `defineSignedBitSchema(envCodec, ...)` directly.)
```
### Schema-fingerprint handshake
```ts
import { schemaFingerprint, assertSchemaFingerprint } from "@suaveplan/bitpacker-crypto";
const local = { foo: 255, bar: 65535 };
const fp = await schemaFingerprint(local);
// On the peer:
await assertSchemaFingerprint(remoteSchema, fp);
```
## Algorithms (existing checksum surface, unchanged)
| Algorithm | Speed | Use Case |
|-----------|-------|----------|
| CRC32 | Medium | General integrity checking |
| Adler32 | Fast | Quick checksums (used in zlib) |
| XXHash32 | Very fast | High-throughput hashing |

View File

@@ -0,0 +1,150 @@
# SUPERSEDED — bitpacker-crypto provider abstraction
This file records two related decisions:
1. **Original decision (`bitpacker-crypto-port`, 2026-05-02)** — upstream's
`packages/universal/bitpacker/src/crypto/` ships ~1,906 lines of provider
abstraction (`BaseCryptoProvider`, `CryptoRegistry`, and three adapters:
`noop`, `libsodium`, `elliptic`). suaveplan intentionally **does not port that abstraction**.
2. **Replacement (`bitpacker-crypto-rewrite`, 2026-05-02)** — a deeper consumer
scan found 8+ call-sites across suaveplan and upstream manually re-implementing
the same crypto-with-bitpacker patterns (sign-then-pack, encrypt-pack-frame,
versioned envelopes, schema fingerprints, stateless verifiers). Rather than
leaving the package as checksums-only, the rewrite **adds five bit-packer-aware
primitives** that consolidate those inline duplications. See "What we built
instead" at the bottom of this file.
The original rejection of upstream's abstraction still stands. The patterns
those rejected symbols *attempted* to address are now solved by the new
primitives, in shapes that real consumers can actually use.
## Why the abstraction is not ported
The upstream provider abstraction is a self-referential layer over `@suaveplan/crypto`:
its `LibsodiumCryptoProvider` does `import from "@suaveplan/crypto"`; its
`EllipticCryptoProvider` wraps the same primitives via the `elliptic` package; its
`NoOpCryptoProvider` is a test harness. A grep across `/mnt/s/web/suaveplan/packages`
on 2026-05-02 confirmed **no upstream consumer outside the bitpacker package itself**
imports `BaseCryptoProvider`, `CryptoRegistry`, or any adapter — the abstraction was
never wired into a production code path.
suaveplan already ships every underlying primitive in five focused packages
(`@suaveplan/crypto`, `crypto-sodium`, `crypto-browser`, `crypto-vault`, `crypto-keys`)
plus a higher-level chunked AEAD in `@suaveplan/packer`. Adding the upstream
abstraction would duplicate work, route around the suaveplan structured-error / OTel
conventions, and contradict the "no duplicate libraries" directive in CLAUDE.md.
## Rejected upstream surface and its suaveplan replacement
| Upstream symbol | Upstream file:lines | suaveplan replacement |
|---|---|---|
| `BaseCryptoProvider extends EventEmitter` | `crypto/provider/provider.ts:26-232` | None — call `@suaveplan/crypto` directly |
| `generateNonce(length)` | `crypto/provider/provider.ts:237-248` | `@suaveplan/crypto-utils::random.bytes(length)` |
| `concatBytes(...arrays)` | `crypto/provider/provider.ts:253-262` | `@suaveplan/crypto-utils::bytes.concat(...arrays)` |
| `validateKeyLength(key, expected, type)` | `crypto/provider/provider.ts:267-277` | Caller-side guard via `BitpackerError` (no central helper) |
| `constantTimeEqual(a, b)` | `crypto/provider/provider.ts:282-289` | `@suaveplan/crypto-utils::bytes.equalsConstantTime(a, b)` |
| `secureWipe(buffer)` | `crypto/provider/provider.ts:294-296` | `@suaveplan/crypto-utils::bytes.zeroize(buffer)` |
| `LibsodiumCryptoProvider` + `createXChaCha20Provider` + `createAesGcmProvider` | `crypto/adapters/libsodium/libsodium.ts:94-335` | `@suaveplan/crypto-sodium` + `@suaveplan/crypto::aead` |
| `EllipticCryptoProvider` + `createSecp256k1Provider` + `createEd25519Provider` + `createP256Provider` | `crypto/adapters/elliptic/elliptic.ts:36-572` | `@suaveplan/crypto-curve` |
| `NoOpCryptoProvider` + `createTestProvider` + `createSlowProvider` + `createUnreliableProvider` | `crypto/adapters/noop/noop.ts:35-335` | `@suaveplan/testing` fakes for `@suaveplan/crypto` |
| `CryptoRegistry extends EventEmitter` + `createCryptoRegistry` + `getGlobalRegistry` | `crypto/registry/registry.ts:21-370` | None — registry was self-referential |
## What `@suaveplan/bitpacker-crypto` still ships
This package is **not deprecated**. It still ships the checksum surface that the
upstream `bitpacker/src/core/checksum/` directory provided, all under `@suaveplan/bitpacker-crypto`:
- `crc32(data)` and `appendCrc32` / `verifyAndStripCrc32`
- `adler32(data)`
- `calculateXxhash32`, `packXxhash32`, `unpackXxhash32`, `appendXxhash32`,
`verifyXxhash32`, `verifyAndStripXxhash32`
Checksums are not part of the rejected provider abstraction; this decision
record covers only the `crypto/` directory of the upstream bitpacker source.
## Encrypt-then-pack — recommended replacement pattern
If you need to encrypt or sign bytes that go into a bitpacker payload, reach
for `@suaveplan/packer` (which ships chunked AEAD already) or call
`@suaveplan/crypto` directly. There is no provider/registry indirection .
```typescript
import { Packer } from "@suaveplan/packer";
import { aead } from "@suaveplan/crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
const schema = defineBitSchema({ payload: "~" });
const bytes = schema.pack({ payload: new Uint8Array([1, 2, 3, 4]) });
// Encrypt the packed bytes:
const ciphertext = await aead.encrypt({
algorithm: "xchacha20-poly1305",
key: myKey,
nonce: myNonce,
plaintext: bytes,
associatedData: new Uint8Array(),
});
// Or use @suaveplan/packer for chunked AEAD with a manifest:
const packer = new Packer({ schema, key: myKey });
const envelope = await packer.pack({ payload: new Uint8Array([1, 2, 3, 4]) });
```
## See also
- spec change `bitpacker-crypto-port` — original decision (superseded).
- spec change `bitpacker-crypto-rewrite` — replacement decision; **slimmed 2026-05-02**
after `restore-crypto-keychain-packer` discovery to two auxiliary primitives only.
- spec change `restore-crypto-keychain-packer` — parallel work that owns the new
opinionated `@suaveplan/crypto` layer (Packer / Authority / AuditLog / RevocationList).
- `bitpacker-modular-restructure` parity table — flips the five corresponding rows
from "🚫 not ported" to "⛔ intentionally superseded; see `restore-crypto-keychain-packer`
for the consolidation, `bitpacker-crypto-rewrite` for the auxiliary primitives".
- `@suaveplan/crypto`, `@suaveplan/crypto-sodium`, `@suaveplan/crypto-browser`,
`@suaveplan/crypto-vault`, `@suaveplan/crypto-keys`, `@suaveplan/crypto-utils`,
`@suaveplan/packer` — the crypto family.
## What we built instead — bit-packer auxiliary primitives only
After the 2026-05-02 audit + the discovery that **`restore-crypto-keychain-packer`**
already proposes a new opinionated `@suaveplan/crypto` layer with `Packer` / `Authority` /
`AuditLog` / `RevocationList`, this package now ships only the two primitives that
don't overlap that work:
| Primitive | What it does | Consumer that drove the design |
|---|---|---|
| `schemaFingerprint(schema, algorithm?, registry?)` | Content-hash a `BitPackerSchema` after canonicalization (sorted keys, no whitespace, deterministic encoding). Algorithm name resolved via `HashAlgorithmRegistry`. Companion `assertSchemaFingerprint` for "do we agree on the schema?" handshakes. | `@suaveplan/network-registry::Xxhash32HashProvider`/`Crc32HashProvider` (upstream port); planned mesh schema-agreement handshake (`docs/network/security-and-tokens.md`, `docs/network/architecture.md`). |
| `HashAlgorithmRegistry` | Pluggable hash-algorithm registry. Defaults: `xxhash32`, `crc32`, `sha256`. Consumers register their own (`blake3` for drive whole-file hash, future PQC). User directive on extensibility/plugins/middleware made this a first-class surface. | `docs/drive/encryption.md` L79 (BLAKE3 whole-file hash); user directive (extensibility/plugins). |
Plus the existing checksum surface (`crc32`, `adler32`, `xxhash32` and their
`appendCrc32`/`verifyAndStripCrc32`/`appendXxhash32`/etc. helpers) is preserved unchanged.
## What lives in `@suaveplan/crypto` instead (via `restore-crypto-keychain-packer`)
The patterns my first-draft rewrite proposed are now correctly placed in the new
opinionated `@suaveplan/crypto` layer, where they compose with `Authority` + `Keychain` +
the closed-list `DomainTag` registry:
| First-draft primitive (dropped here) | Owned by `@suaveplan/crypto` (via `restore-crypto-keychain-packer`) |
|---|---|
| `SignedBitSchema` (Authority + DomainTag) | `Packer` (encrypt-and-sign envelope under DomainTag via Authority) + direct `Authority.sign(domain, slot, payload)` for sign-only |
| `EnvelopeSchema(magic, version, body)` | `packPublicBundle` shape + `Packer` envelope |
| `HashChainSchema` (Merkle-chained signed log) | `AuditLog` (Merkle log + `proveInclusion` / `verifyInclusion`) |
| `MultiSignedBitSchema` (N-of-N co-sign) | If/when `MeshTrustToken` (NET-110) needs it, the spec delta lands in `@suaveplan/crypto` next to `Packer` and `RevocationList` (not here — co-signing belongs in the `Authority`-using crypto layer) |
| `EncryptedBitSchema` (niche AEAD) | `Packer` (covers small-payload encrypt-and-sign exactly) |
| New domain tags (`DOMAIN_BITPACKER_*_V1`) | `DomainTag` is a closed list owned by `@suaveplan/crypto/domain-tags` — additions are wire-format extensions in that package, not in `bitpacker-crypto` |
### What was dropped (chronological)
| Dropped | Why |
|---|---|
| `ChunkVerifier` (first audit pass) | `@suaveplan/packer::ChunkVerifier` already covers the locked v0x0001 chunk format. |
| `SignedBitSchema` taking raw `signKey` (audit-revision) | suaveplan canonical signing surface is `Authority.sign(domain, slot, payload)`. Raw-key API didn't serve real consumers. |
| `EnvelopeSchema` with opaque body (audit-revision) | Real consumers (`PublicBundle`, `ChainBundle`) have structured body fields. |
| `DomainTagRegistry` open registry (audit-revision) | Conflicts with suaveplan's intentional closed-list `DomainTag` design. |
| `SignatureAlgorithmRegistry` (audit-revision) | `Authority` already dispatches on slot algorithm. |
| `SignedBitSchema` / `EnvelopeSchema` / `HashChainSchema` / `MultiSignedBitSchema` / `EncryptedBitSchema` (slim revision) | All overlap with `restore-crypto-keychain-packer`'s `Packer` / `AuditLog` / `RevocationList`. suaveplan policy explicitly forbids duplicate libraries. |
| New `DOMAIN_BITPACKER_*_V1` constants (slim revision) | Closed-list domain tags belong in `@suaveplan/crypto/domain-tags`, not here. |
The two surviving primitives (`SchemaFingerprint`, `HashAlgorithmRegistry`) are pure
bit-packer utilities that compose with `restore-crypto-keychain-packer` without conflict.

View File

@@ -0,0 +1,134 @@
{
"name": "@suaveplan/bitpacker-crypto",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bit-packer-aware crypto primitives \u2014 SignedBitSchema, EncryptedBitSchema, EnvelopeSchema, HashChainSchema, MultiSignedBitSchema, SchemaFingerprint, HashAlgorithmRegistry. Authority/AeadOps/DomainTag types live in @suaveplan/types; the canonical implementations are in @suaveplan/crypto and dependency-injected by the caller.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./signed": {
"import": {
"types": "./dist/signed/signed-bitschema.d.ts",
"default": "./dist/signed/signed-bitschema.js"
},
"require": {
"types": "./dist/signed/signed-bitschema.d.ts",
"default": "./dist/signed/signed-bitschema.js"
}
},
"./encrypted": {
"import": {
"types": "./dist/encrypted/encrypted-bitschema.d.ts",
"default": "./dist/encrypted/encrypted-bitschema.js"
},
"require": {
"types": "./dist/encrypted/encrypted-bitschema.d.ts",
"default": "./dist/encrypted/encrypted-bitschema.js"
}
},
"./envelope": {
"import": {
"types": "./dist/envelope/envelope-schema.d.ts",
"default": "./dist/envelope/envelope-schema.js"
},
"require": {
"types": "./dist/envelope/envelope-schema.d.ts",
"default": "./dist/envelope/envelope-schema.js"
}
},
"./hashchain": {
"import": {
"types": "./dist/hashchain/hashchain-schema.d.ts",
"default": "./dist/hashchain/hashchain-schema.js"
},
"require": {
"types": "./dist/hashchain/hashchain-schema.d.ts",
"default": "./dist/hashchain/hashchain-schema.js"
}
},
"./multisigned": {
"import": {
"types": "./dist/multisigned/multisigned-bitschema.d.ts",
"default": "./dist/multisigned/multisigned-bitschema.js"
},
"require": {
"types": "./dist/multisigned/multisigned-bitschema.d.ts",
"default": "./dist/multisigned/multisigned-bitschema.js"
}
},
"./fingerprint": {
"import": {
"types": "./dist/fingerprint/schema-fingerprint.d.ts",
"default": "./dist/fingerprint/schema-fingerprint.js"
},
"require": {
"types": "./dist/fingerprint/schema-fingerprint.d.ts",
"default": "./dist/fingerprint/schema-fingerprint.js"
}
},
"./hash-registry": {
"import": {
"types": "./dist/hash-registry/hash-registry.d.ts",
"default": "./dist/hash-registry/hash-registry.js"
},
"require": {
"types": "./dist/hash-registry/hash-registry.d.ts",
"default": "./dist/hash-registry/hash-registry.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/crypto-cipher": "workspace:^",
"@suaveplan/crypto-core": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md",
"SUPERSEDED.md"
]
}

View File

@@ -0,0 +1,154 @@
/**
* Cross-primitive composition tests.
*
* Pins the spec invariant that the rewrite primitives compose end-to-end
* without coupling. The genesis design lets a consumer wrap an
* `EnvelopeSchema` body inside a `SignedBitSchema`, embed an
* `EncryptedBitSchema` body inside an `EnvelopeSchema`, and so on.
*/
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoAead, cryptoVerifier } from "../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../tests/helpers/fake-authority.js";
import { DOMAIN_BITPACKER_SIGNED_V1 } from "../tests/helpers/test-domains.js";
import { AEAD_KEY_LENGTH } from "./encrypted/encrypted-bitschema.js";
import {
type BodyCodec,
defineEncryptedBitSchema,
defineEnvelopeSchema,
defineSignedBitSchema,
} from "./index.js";
interface Inner {
foo: number;
bar: number;
}
const INNER_CODEC: BodyCodec<Inner> = (() => {
const schema = defineBitSchema<Inner>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
const MAGIC = new Uint8Array([0x53, 0x56, 0x43, 0x42]);
describe("composition — SignedBitSchema over EnvelopeSchema", () => {
it("SignedBitSchema over EnvelopeSchema round-trips", {
timeout: 10_000,
}, async () => {
// Build inner envelope (magic+version+inner-body).
const env = defineEnvelopeSchema<Inner>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: INNER_CODEC,
});
// Wrap the envelope as a body codec for the signer.
const envAsCodec: BodyCodec<Inner> = {
pack: (data) => env.pack(data),
unpack: (bytes) => env.parseStrict(bytes),
};
const signed = defineSignedBitSchema<Inner>(envAsCodec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const { authority, publicSlot } = createFakeAuthority();
const wrapped = await signed.pack({ foo: 7, bar: 1234 }, authority);
const recovered = signed.unpack(wrapped, publicSlot);
expect(recovered.foo).toBe(7);
expect(recovered.bar).toBe(1234);
// Tamper rejection still bubbles up — even through the envelope.
const tampered = new Uint8Array(wrapped);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
try {
signed.unpack(tampered, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
});
describe("composition — EnvelopeSchema over EncryptedBitSchema", () => {
it("EnvelopeSchema wraps EncryptedBitSchema bytes", {
timeout: 10_000,
}, async () => {
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
const enc = defineEncryptedBitSchema<Inner>(INNER_CODEC, {
key,
aead: cryptoAead,
});
// The encrypted output is opaque bytes — the envelope's body is
// just those bytes (no further structure).
const encAsCodec: BodyCodec<Uint8Array> = {
pack: (b) => b,
unpack: (b) => b,
};
const env = defineEnvelopeSchema<Uint8Array>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: encAsCodec,
});
const ciphertext = await enc.pack({ foo: 9, bar: 99 });
const enveloped = env.pack(ciphertext);
// Round-trip:
const peeled = env.parseStrict(enveloped);
const decoded = await enc.unpack(peeled);
expect(decoded.foo).toBe(9);
expect(decoded.bar).toBe(99);
});
});
describe("composition — three-way nesting Sign(Envelope(Encrypted))", () => {
it("survives a Sign(Envelope(Encrypted)) round-trip", {
timeout: 10_000,
}, async () => {
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
const enc = defineEncryptedBitSchema<Inner>(INNER_CODEC, {
key,
aead: cryptoAead,
});
const encAsCodec: BodyCodec<Uint8Array> = {
pack: (b) => b,
unpack: (b) => b,
};
const env = defineEnvelopeSchema<Uint8Array>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: encAsCodec,
});
const envAsCodec: BodyCodec<Uint8Array> = {
pack: (b) => env.pack(b),
unpack: (bytes) => env.parseStrict(bytes),
};
const signed = defineSignedBitSchema<Uint8Array>(envAsCodec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const { authority, publicSlot } = createFakeAuthority();
const ciphertext = await enc.pack({ foo: 11, bar: 22 });
const wrapped = await signed.pack(ciphertext, authority);
const recoveredCiphertext = signed.unpack(wrapped, publicSlot);
const recovered = await enc.unpack(recoveredCiphertext);
expect(recovered.foo).toBe(11);
expect(recovered.bar).toBe(22);
});
});

View File

@@ -0,0 +1,538 @@
/**
* Coverage-completion tests for defensive throws across the seven primitives.
* Each test exercises a code path that the per-feature tests don't reach
* because they require a misbehaving Authority, a wrong-length signature,
* an unusual schema value type, or a deliberately broken byte input.
*
* If any of these tests goes red, the corresponding code path's existing
* defensive throw is still load-bearing — do not delete it.
*/
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
AeadAlgorithm,
Authority,
DomainTag,
KeySlotPublic,
} from "@suaveplan/types";
import { cryptoAead, cryptoVerifier } from "../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../tests/helpers/fake-authority.js";
import {
DOMAIN_BITPACKER_HASHCHAIN_V1,
DOMAIN_BITPACKER_MULTISIGN_V1,
DOMAIN_BITPACKER_SIGNED_V1,
} from "../tests/helpers/test-domains.js";
import { AEAD_KEY_LENGTH } from "./encrypted/encrypted-bitschema.js";
import { _resetDefaultHashRegistryForTesting } from "./hash-registry/hash-registry.js";
import {
type BodyCodec,
canonicalizeSchema,
defineEncryptedBitSchema,
defineEnvelopeSchema,
defineHashChainSchema,
defineMultiSignedBitSchema,
defineSignedBitSchema,
} from "./index.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
interface BadAuthorityOptions {
readonly badSignatureLength?: number;
readonly badPublicKeyAlgorithm?: number;
}
function createBadAuthority(opts: BadAuthorityOptions = {}): {
authority: Authority;
publicSlot: KeySlotPublic<unknown>;
} {
const real = createFakeAuthority();
const wrapped: Authority = {
sign: async (
_domain: DomainTag,
_slot,
_payload: Uint8Array,
_signal?: AbortSignal
): Promise<Uint8Array> => {
if (opts.badSignatureLength !== undefined) {
return new Uint8Array(opts.badSignatureLength);
}
return real.authority.sign(_domain, _slot, _payload, _signal);
},
getPublicSlot: real.authority.getPublicSlot.bind(real.authority),
hasSlot: real.authority.hasSlot.bind(real.authority),
dispose: real.authority.dispose.bind(real.authority),
get disposed() {
return real.authority.disposed;
},
};
const publicSlot =
opts.badPublicKeyAlgorithm !== undefined
? ({
...real.publicSlot,
algorithm: opts.badPublicKeyAlgorithm as never,
} as KeySlotPublic<unknown>)
: real.publicSlot;
return { authority: wrapped, publicSlot };
}
describe("coverage — envelope length-different magic rejection", () => {
it("parseStrict rejects when magic lengths differ", {
timeout: 5000,
}, () => {
const env4 = defineEnvelopeSchema<Body>({
magic: new Uint8Array([1, 2, 3, 4]),
formatVersion: 1,
bodyCodec: codec,
});
const env5 = defineEnvelopeSchema<Body>({
magic: new Uint8Array([1, 2, 3, 4, 5]),
formatVersion: 1,
bodyCodec: codec,
});
const bytes4 = env4.pack({ foo: 1 });
try {
env5.parseStrict(bytes4);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH);
}
});
});
describe("coverage — fingerprint canonicalize unsupported type", () => {
it("rejects Symbol values via 'unsupported-type'", {
timeout: 5000,
}, () => {
const sym = Symbol("schema");
try {
canonicalizeSchema(
sym as unknown as Parameters<typeof canonicalizeSchema>[0]
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context).toMatchObject({ reason: "unsupported-type" });
}
});
it("treats undefined values like null in canonicalize", {
timeout: 5000,
}, () => {
const node = { a: undefined, b: 1 };
const out = canonicalizeSchema(
node as unknown as Parameters<typeof canonicalizeSchema>[0]
);
expect(out).toBe('{"a":null,"b":1}');
});
it("encodes bigint values with the n suffix", { timeout: 5000 }, () => {
const out = canonicalizeSchema(
123n as unknown as Parameters<typeof canonicalizeSchema>[0]
);
expect(out).toBe("123n");
});
it("assertSchemaFingerprint rejects wrong-length expected digest", {
timeout: 5000,
}, async () => {
const { assertSchemaFingerprint } = await import(
"./fingerprint/schema-fingerprint.js"
);
try {
await assertSchemaFingerprint({ x: 1 }, new Uint8Array(8));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
}
});
});
describe("coverage — hash-registry default Rng nextFloat path", () => {
it("default sha256 algorithm rejects when crypto.subtle missing", {
timeout: 5000,
}, async () => {
_resetDefaultHashRegistryForTesting();
// Replace the whole globalThis.crypto with a stand-in whose `subtle`
// is undefined. We can't `defineProperty` on the existing
// `globalThis.crypto.subtle` because Bun marks it non-configurable;
// `globalThis.crypto` itself is configurable on every supported
// runtime, so swap the parent object instead.
const original = globalThis.crypto;
const stand = {
getRandomValues: original.getRandomValues.bind(original),
randomUUID: original.randomUUID.bind(original),
subtle: undefined as unknown as SubtleCrypto,
};
Object.defineProperty(globalThis, "crypto", {
value: stand,
configurable: true,
writable: true,
});
try {
// Re-import to get a fresh registry that will hit the missing-subtle path.
const { defaultHashRegistry } = await import(
"./hash-registry/hash-registry.js"
);
const sha256 = defaultHashRegistry().resolve("sha256");
try {
await sha256.hash(new Uint8Array(8));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE);
}
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
writable: true,
});
_resetDefaultHashRegistryForTesting();
}
});
});
describe("coverage — encrypted-bitschema defensive throws", () => {
it("default Rng nextUint32 / nextFloat return random / [0,1) values", {
timeout: 5000,
}, async () => {
const { defaultCryptoRng } = await import(
"./encrypted/encrypted-bitschema.js"
);
const rng = defaultCryptoRng();
const word = rng.nextUint32();
expect(Number.isInteger(word)).toBe(true);
expect(word).toBeGreaterThanOrEqual(0);
expect(word).toBeLessThanOrEqual(0xffffffff);
const f = rng.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
});
it("default Rng nextUint32 throws when crypto.getRandomValues missing", {
timeout: 5000,
}, async () => {
const { defaultCryptoRng } = await import(
"./encrypted/encrypted-bitschema.js"
);
const rng = defaultCryptoRng();
const saved = globalThis.crypto.getRandomValues;
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: undefined,
configurable: true,
writable: true,
});
try {
try {
rng.nextUint32();
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE);
}
} finally {
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: saved,
configurable: true,
writable: true,
});
}
});
it("CRYPTO_ENCRYPT_FAILED when AEAD encrypt throws (wrong-length key already validated upstream; force via algorithm coercion)", {
timeout: 5000,
}, async () => {
// Force aeadEncrypt to throw by giving an algorithm string the
// crypto package doesn't recognize. We bypass Zod by casting at the
// type boundary.
const ec = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
algorithm: "AES-256-GCM" as AeadAlgorithm,
});
// Mutate via Object.defineProperty: the schema captured `algorithm`
// by closure — we can't easily corrupt it. Instead, cause a failure
// by constructing an envelope whose Rng returns a wrong-length nonce.
const ec2 = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
algorithm: "AES-256-GCM",
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
// wrong-length nonce — AEAD will throw
nextBytes: (_n) => new Uint8Array(_n + 4),
},
});
try {
await ec2.pack({ foo: 1 });
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENCRYPT_FAILED);
}
// Sanity: the first ec still works.
await ec.pack({ foo: 2 });
});
it("CRYPTO_ENVELOPE_VERSION_MISMATCH on bad version u8", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
});
const packed = await ec.pack({ foo: 1 });
const bad = new Uint8Array(packed);
bad[0] = 99; // mutate version
try {
await ec.unpack(bad);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
}
});
it("CRYPTO_ENVELOPE_VERSION_MISMATCH on bad nonceLen byte", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: new Uint8Array(AEAD_KEY_LENGTH),
aead: cryptoAead,
});
const packed = await ec.pack({ foo: 1 });
const bad = new Uint8Array(packed);
bad[2] = 1; // wrong nonce length
try {
await ec.unpack(bad);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
}
});
});
describe("coverage — signed-bitschema defensive throws", () => {
it("CRYPTO_SIGN_FAILED when authority returns wrong-length signature", {
timeout: 10_000,
}, async () => {
const { authority } = createBadAuthority({ badSignatureLength: 16 });
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
try {
await ss.pack({ foo: 1 }, authority);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
}
});
it("verify returns false when verifyAuthoritySignature throws (wrong-algo slot)", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot: realSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1 }, authority);
// Build a slot with non-ed25519 algorithm — verifyAuthoritySignature
// throws KEYCHAIN.SLOT_INVALID; SignedBitSchema's verify swallows it.
const badSlot: KeySlotPublic<unknown> = {
...realSlot,
algorithm: 99 as never, // not ED25519
};
expect(ss.verify(packed, badSlot)).toBe(false);
});
});
describe("coverage — multisigned defensive throws", () => {
it("CRYPTO_SIGN_FAILED when an authority returns wrong-length signature", {
timeout: 10_000,
}, async () => {
const good = createFakeAuthority();
const bad = createBadAuthority({ badSignatureLength: 16 });
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
try {
await ms.pack({ foo: 1 }, [good.authority, bad.authority]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
expect(e.context?.["signerIndex"]).toBe(1);
}
});
it("verify pinpoints failure when verifyAuthoritySignature throws on bad-algo slot", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 1 }, [a.authority, b.authority]);
const badSlot: KeySlotPublic<unknown> = {
...b.publicSlot,
algorithm: 99 as never,
};
const result = ms.verify(packed, [a.publicSlot, badSlot]);
expect(result.valid).toBe(false);
expect(result.firstBadSignerIndex).toBe(1);
});
});
describe("coverage — hashchain defensive throws", () => {
it("CRYPTO_SIGN_FAILED when authority returns wrong-length signature", {
timeout: 10_000,
}, async () => {
const { authority } = createBadAuthority({ badSignatureLength: 16 });
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
try {
await chain.appendEntry(
0n,
new Uint8Array(32),
{ foo: 1 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
}
});
it("packEntry rejects wrong-length hostSignature", {
timeout: 10_000,
}, async () => {
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
try {
chain.packEntry({
seq: 0n,
prevHash: new Uint8Array(32),
payload: { foo: 1 },
hostSignature: new Uint8Array(16), // wrong
});
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_SIGN_FAILED);
}
});
it("verifyEntry returns false when verifyAuthoritySignature throws on bad-algo slot", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
const entry = await chain.appendEntry(
0n,
new Uint8Array(32),
{ foo: 1 },
authority
);
const badSlot: KeySlotPublic<unknown> = {
...publicSlot,
algorithm: 99 as never,
};
expect(
await chain.verifyEntry(entry, new Uint8Array(32), badSlot)
).toBe(false);
});
it("verifyEntry returns false on wrong-length expectedPrevHash", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
const entry = await chain.appendEntry(
0n,
new Uint8Array(32),
{ foo: 1 },
authority
);
// Pass a 16-byte expected (sha256 expects 32) — bytesEqual returns
// false on length mismatch and the verify falls through to false.
expect(
await chain.verifyEntry(entry, new Uint8Array(16), publicSlot)
).toBe(false);
});
it("seq overflow rejects with VALUE_OUT_OF_RANGE", {
timeout: 5000,
}, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<Body>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: codec,
});
const huge = 1n << 70n; // > 2^64
try {
await chain.appendEntry(
huge,
new Uint8Array(32),
{ foo: 1 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.VALUE_OUT_OF_RANGE);
}
});
});

View File

@@ -0,0 +1,89 @@
# CRC32 + Adler32 Checksums
## Purpose
Fast non-cryptographic checksums for bit-packed payloads. `crc32` implements the IEEE-802.3 / zlib polynomial (`0xedb88320`); `adler32` implements the RFC 1950 algorithm. Both are designed to detect accidental corruption (single-bit flips, wire-level errors, dropped bytes), not adversarial tampering — for cryptographic integrity, use the AEAD or signed-bitschema primitives in `@suaveplan/bitpacker-crypto`'s `encrypted/` and `signed/` modules.
The four wrappers (`appendCrc32`, `verifyAndStripCrc32`, `appendAdler32`, `verifyAndStripAdler32`) bookend a payload with a 4-byte big-endian checksum so framed bitpacker output can be transmitted over a noisy channel and integrity-checked at the receiving end. The wrappers throw on truncated input or checksum mismatch; the bare `crc32` / `adler32` functions return the raw 32-bit value for callers that want to integrate the check elsewhere.
## Features
- `crc32(data)` — IEEE-802.3 / zlib polynomial CRC32 with a precomputed 256-entry table
- `adler32(data)` — RFC 1950 Adler32 with the standard `MOD_ADLER = 65521` prime
- `appendCrc32` / `appendAdler32` — append 4-byte big-endian checksum
- `verifyAndStripCrc32` / `verifyAndStripAdler32` — verify and strip 4-byte checksum, throwing on mismatch or short input
- Pure JS, no dependencies, deterministic output, browser- and Node-safe
## Usage: Basic Example
```ts
import { crc32, appendCrc32, verifyAndStripCrc32 } from "@suaveplan/bitpacker-crypto";
const payload = new Uint8Array([1, 2, 3, 4]);
const checksum = crc32(payload); // 0xb63cfbcd
const framed = appendCrc32(payload); // 8 bytes: payload + 4-byte CRC
const restored = verifyAndStripCrc32(framed); // -> [1, 2, 3, 4]
```
## Usage: Advanced Example
```ts
import {
appendCrc32,
verifyAndStripCrc32,
appendAdler32,
verifyAndStripAdler32,
} from "@suaveplan/bitpacker-crypto";
// Frame a bit-packed payload, transmit, and validate on receive.
function transmit(payload: Uint8Array): Uint8Array {
return appendCrc32(payload);
}
function receive(framed: Uint8Array): Uint8Array {
try {
return verifyAndStripCrc32(framed);
} catch (err) {
// Caller decides: retransmit, drop, or escalate to a higher-level
// integrity check (signed-bitschema for adversarial channels).
throw err;
}
}
// Adler32 is faster but weaker; pick it for short payloads on trusted channels.
const cheap = appendAdler32(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
const back = verifyAndStripAdler32(cheap);
```
## API Reference
### `crc32(data: Uint8Array): number`
Returns the 32-bit unsigned CRC32 value of `data` using the IEEE-802.3 / zlib polynomial (`0xedb88320`). Uses a precomputed 256-entry table for one-byte-per-iteration throughput.
### `adler32(data: Uint8Array): number`
Returns the 32-bit unsigned Adler32 value of `data` per RFC 1950 (`a` and `b` accumulators with `MOD_ADLER = 65521`).
### `appendCrc32(data: Uint8Array): Uint8Array`
Returns a new array containing `data` followed by 4 big-endian bytes of `crc32(data)`.
### `verifyAndStripCrc32(data: Uint8Array): Uint8Array`
Returns the leading `data.length - 4` bytes if the trailing 4 bytes match `crc32(payload)`. Throws `Error` with `"Data too short for CRC32 verification"` if `data.length < 4`, or `"CRC32 mismatch: expected X, got Y"` on mismatch.
### `appendAdler32(data: Uint8Array): Uint8Array`
Returns a new array containing `data` followed by 4 big-endian bytes of `adler32(data)`.
### `verifyAndStripAdler32(data: Uint8Array): Uint8Array`
Mirrors `verifyAndStripCrc32` for Adler32. Same error semantics.
## Implementation Notes
- **CRC32 polynomial = `0xedb88320`.** This is the bit-reversed form of the standard IEEE-802.3 polynomial (`0x04c11db7`). The reversal is encoded in the table-precomputation loop (`c & 1 ? POLY ^ (c >>> 1) : c >>> 1`) so the streaming inner loop processes least-significant bits first, matching zlib / gzip / PNG.
- **Big-endian checksum framing.** Both append wrappers write the 32-bit checksum as big-endian bytes, matching network byte order. The `verifyAndStrip` counterparts read with `getUint32(offset, false)` (false = big-endian).
- **Not cryptographically secure.** A motivated attacker can construct collisions for both CRC32 and Adler32 in O(1). Use these for accidental-corruption detection only; use AEAD or signature primitives elsewhere in `@suaveplan/bitpacker-crypto` for adversarial integrity.
- **No allocation in the hot path.** `crc32` / `adler32` only allocate via the precomputed `CRC32_TABLE`. `appendCrc32` / `appendAdler32` allocate one output buffer; `verifyAndStripCrc32` / `verifyAndStripAdler32` allocate via `slice` for the returned payload.

View File

@@ -0,0 +1,98 @@
/**
* CRC32 + Adler32 — fast non-cryptographic checksums commonly framed onto
* bit-packed payloads for low-overhead integrity. CRC32 is the IEEE-802.3
* polynomial (zlib variant); Adler32 follows RFC 1950.
*/
const CRC32_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
CRC32_TABLE[i] = c;
}
/** Compute CRC32 (IEEE-802.3 / zlib polynomial) of a byte array. */
export function crc32(data: Uint8Array): number {
let crc = ~0;
for (let i = 0; i < data.length; i++) {
crc = CRC32_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
}
return ~crc >>> 0;
}
/** Compute Adler32 checksum (RFC 1950) of a byte array. */
export function adler32(data: Uint8Array): number {
let a = 1;
let b = 0;
for (let i = 0; i < data.length; i++) {
a = (a + data[i]!) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
/** Append a 4-byte CRC32 checksum (big-endian) to a byte array. */
export function appendCrc32(data: Uint8Array): Uint8Array {
const checksum = crc32(data);
const result = new Uint8Array(data.length + 4);
result.set(data);
const view = new DataView(result.buffer);
view.setUint32(data.length, checksum, false);
return result;
}
/**
* Verify and strip a 4-byte CRC32 checksum from a byte array. Returns the
* original payload bytes if the trailing CRC matches; throws otherwise.
*/
export function verifyAndStripCrc32(data: Uint8Array): Uint8Array {
if (data.length < 4) {
throw new Error("Data too short for CRC32 verification");
}
const payload = data.slice(0, -4);
const expected = crc32(payload);
const actual = new DataView(data.buffer, data.byteOffset).getUint32(
data.length - 4,
false
);
if (expected !== actual) {
throw new Error(`CRC32 mismatch: expected ${expected}, got ${actual}`);
}
return payload;
}
/** Append a 4-byte Adler32 checksum (big-endian) to a byte array. */
export function appendAdler32(data: Uint8Array): Uint8Array {
const checksum = adler32(data);
const result = new Uint8Array(data.length + 4);
result.set(data);
const view = new DataView(result.buffer);
view.setUint32(data.length, checksum, false);
return result;
}
/**
* Verify and strip a 4-byte Adler32 checksum from a byte array. Returns the
* original payload bytes if the trailing Adler32 matches; throws otherwise.
*
* Mirrors `verifyAndStripCrc32` for parity with the CRC32 wrapper API.
*/
export function verifyAndStripAdler32(data: Uint8Array): Uint8Array {
if (data.length < 4) {
throw new Error("Data too short for Adler32 verification");
}
const payload = data.slice(0, -4);
const expected = adler32(payload);
const actual = new DataView(data.buffer, data.byteOffset).getUint32(
data.length - 4,
false
);
if (expected !== actual) {
throw new Error(
`Adler32 mismatch: expected ${expected}, got ${actual}`
);
}
return payload;
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "@suaveplan/testing/runner";
import {
adler32,
appendAdler32,
appendCrc32,
appendXxhash32,
calculateXxhash32,
crc32,
packXxhash32,
unpackXxhash32,
verifyAndStripAdler32,
verifyAndStripCrc32,
verifyAndStripXxhash32,
verifyXxhash32,
} from "./index.js";
describe("crc32", () => {
test("computes CRC32 of empty array", () => {
expect(crc32(new Uint8Array(0))).toBe(0);
});
test("computes CRC32 of known data", () => {
const data = new TextEncoder().encode("hello");
const result = crc32(data);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(0xffffffff);
});
test("append and verify CRC32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withCrc = appendCrc32(data);
expect(withCrc.length).toBe(data.length + 4);
const stripped = verifyAndStripCrc32(withCrc);
expect(stripped).toEqual(data);
});
test("throws on too short data for verifyAndStripCrc32", () => {
expect(() => verifyAndStripCrc32(new Uint8Array([1]))).toThrow(
"too short"
);
});
test("throws on CRC32 mismatch", () => {
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
expect(() => verifyAndStripCrc32(data)).toThrow("mismatch");
});
});
describe("adler32", () => {
test("computes Adler32 of empty array", () => {
expect(adler32(new Uint8Array(0))).toBe(1);
});
test("computes Adler32 of known data", () => {
const data = new TextEncoder().encode("hello");
const result = adler32(data);
expect(result).toBeGreaterThan(0);
});
test("append and verify Adler32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withAdler = appendAdler32(data);
expect(withAdler.length).toBe(data.length + 4);
const stripped = verifyAndStripAdler32(withAdler);
expect(stripped).toEqual(data);
});
test("throws on too short data for verifyAndStripAdler32", () => {
expect(() => verifyAndStripAdler32(new Uint8Array([1]))).toThrow(
"too short"
);
});
test("throws on Adler32 mismatch", () => {
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
expect(() => verifyAndStripAdler32(data)).toThrow("mismatch");
});
});
describe("xxhash32", () => {
test("computes xxhash32 of empty array", () => {
expect(calculateXxhash32(new Uint8Array(0))).toBe(0x2cc5d05);
});
test("computes xxhash32 of known data", () => {
const data = new TextEncoder().encode("hello");
const result = calculateXxhash32(data);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(0xffffffff);
});
test("computes xxhash32 of long data (>=16 bytes)", () => {
const data = new TextEncoder().encode("this is 16 bytes!");
const result = calculateXxhash32(data);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(0xffffffff);
});
test("computes xxhash32 of 15-byte data (triggers readU32LE bounds)", () => {
const data = new TextEncoder().encode("123456789012345");
expect(calculateXxhash32(data)).toBeGreaterThanOrEqual(0);
});
test("unpackXxhash32 handles short array", () => {
expect(unpackXxhash32(new Uint8Array([1, 2]))).toBe(0x0201);
});
test("unpackXxhash32 handles empty array", () => {
expect(unpackXxhash32(new Uint8Array(0))).toBe(0);
});
test("computes xxhash32 with explicit seed", () => {
const data = new Uint8Array([1, 2, 3, 4]);
expect(calculateXxhash32(data, 0xdeadbeef)).not.toBe(
calculateXxhash32(data, 0)
);
});
test("verify xxhash32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const hash = calculateXxhash32(data);
expect(verifyXxhash32(data, hash)).toBe(true);
expect(verifyXxhash32(data, hash + 1)).toBe(false);
});
test("pack and unpack xxhash32", () => {
const hash = 0x12345678;
const packed = packXxhash32(hash);
expect(packed.length).toBe(4);
expect(unpackXxhash32(packed)).toBe(hash);
});
test("append and verify xxhash32", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withHash = appendXxhash32(data);
expect(withHash.length).toBe(data.length + 4);
const stripped = verifyAndStripXxhash32(withHash);
expect(stripped).toEqual(data);
});
test("append and verify xxhash32 with seed", () => {
const data = new Uint8Array([1, 2, 3, 4]);
const withHash = appendXxhash32(data, 0x12345678);
const stripped = verifyAndStripXxhash32(withHash, 0x12345678);
expect(stripped).toEqual(data);
});
test("throws on too short data for verifyAndStripXxhash32", () => {
expect(() => verifyAndStripXxhash32(new Uint8Array([1]))).toThrow(
"too short"
);
});
test("throws on checksum mismatch", () => {
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
expect(() => verifyAndStripXxhash32(data)).toThrow("mismatch");
});
});

View File

@@ -0,0 +1,174 @@
# EncryptedBitSchema
Niche AEAD-encrypted envelope around a bit-packed body. Wire format:
`[version u8][algTag u8][nonceLen u8][nonce][ciphertext+tag]`.
**Intended scope: small encrypted payloads only.** Token bodies,
key-wrap envelopes inside `MessageEnvelope`, sub-MTU control messages.
**NOT a chunked-file encoder** — `@suaveplan/packer` owns chunked-file
encryption with its locked v0x0001 codec.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Several suaveplan surfaces need a small, self-describing AEAD-encrypted
envelope: a token body wrapped for transport, an inner
key-wrap inside a higher-level message envelope, an opaque control
record signed-then-encrypted by upstream layers. Re-implementing the
"version + algTag + nonce + ciphertext" frame each time invites
nonce-reuse mistakes and silent algorithm drift.
`EncryptedBitSchema` standardises the frame, picks safe defaults
(XChaCha20-Poly1305 with 24-byte random nonce, 32-byte key, no
`Math.random` fallback), and refuses to be misused as a chunked-file
encoder by being explicit in this README and the schema name.
## Features
- Three AEAD algorithms supported: `AES-256-GCM`, `ChaCha20-Poly1305`,
`XChaCha20-Poly1305` (default — its 24-byte random nonce avoids
nonce-collision concerns even at high message volume)
- Stable u8 algorithm tags (0/1/2) on the wire so an envelope encoded
under one algorithm can be detected (and rejected) by a schema
expecting another
- Pluggable `Rng`; default uses `globalThis.crypto.getRandomValues` and
throws `CRYPTO_RNG_UNAVAILABLE` if Web Crypto is missing — never
silently falls back to `Math.random`
- Optional `aad` (additional authenticated data) — held by reference;
participates in the integrity tag so binding the envelope to a
surrounding context (session id, conversation id) is one option away
- Wire-format constants: `version=1`, no tag changes once allocated.
Adding a new algorithm only appends to the tag table.
- Tampered ciphertext rejects with `CRYPTO_DECRYPT_FAILED`
- Wrong-algorithm envelope rejects with `CRYPTO_ENVELOPE_VERSION_MISMATCH`
- Wrong-key decrypt rejects with `CRYPTO_DECRYPT_FAILED`
- OTel spans `bitpacker-crypto.encrypted-pack` /
`bitpacker-crypto.encrypted-unpack`
## Intended scope
This primitive is for **small** payloads. The wire format prepends a 3-byte
header + the full nonce + the AEAD output (ciphertext + 16-byte tag). For
a 100-byte body, that's ~143 bytes total — fine. For a 50 MB chunked file,
this is the wrong shape: you want chunked encryption with a streaming
verifier, which is what `@suaveplan/packer::ChunkedPacker` provides.
If you find yourself reaching for `EncryptedBitSchema` to wrap large
payloads, stop and use `@suaveplan/packer` instead. This warning is also
asserted by a spec-conformance test.
## Usage: Basic Example
```ts
import { defineEncryptedBitSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
interface Token { userId: number; expiry: number }
const tokenSchema = defineBitSchema<Token>([
{ name: "userId", type: "uint", bits: 32 },
{ name: "expiry", type: "uint", bits: 32 },
]);
const encryptedToken = defineEncryptedBitSchema<Token>(
{
pack: (t) => tokenSchema.pack(t),
unpack: (bytes) => tokenSchema.unpack(bytes),
},
{
key: aeadKey,
aead: { encrypt: aeadEncrypt, decrypt: aeadDecrypt },
}
);
const wrapped = await encryptedToken.pack({ userId: 7, expiry: 1700000000 });
const original = await encryptedToken.unpack(wrapped);
```
The `aead` option is required and accepts an `AeadOps` bundle
(`{ encrypt, decrypt }`). The canonical implementation is the
`aeadEncrypt` / `aeadDecrypt` functions from `@suaveplan/crypto`. Passing
them via DI keeps `@suaveplan/bitpacker-crypto` free of any runtime
dependency on `@suaveplan/crypto`. The `AEAD_KEY_LENGTH`,
`AEAD_NONCE_LENGTH`, and `AEAD_XCHACHA_NONCE_LENGTH` constants are
declared locally in this primitive (not imported from
`@suaveplan/crypto`); duplication is acceptable because these values
are part of the bit-packer-crypto wire format.
## Usage: Advanced Example — bind to a session id via AAD
```ts
const sessionId = new TextEncoder().encode("session:abc123");
const encrypted = defineEncryptedBitSchema<Token>(codec, {
key,
aad: sessionId,
});
// Only an unpack with the SAME aad will succeed; replays into a different
// session will reject with CRYPTO_DECRYPT_FAILED.
```
## API Reference
### `interface EncryptedBitSchemaOptions`
- `key: Uint8Array` — exactly 32 bytes (`AEAD_KEY_LENGTH`)
- `aead: AeadOps` (required) — AEAD operations bundle; typically
`{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` from
`@suaveplan/crypto`
- `algorithm?: AeadAlgorithm = "XChaCha20-Poly1305"`
- `rng?: Rng` — defaults to a `globalThis.crypto.getRandomValues`-backed Rng
- `aad?: Uint8Array` — optional additional authenticated data
### `defineEncryptedBitSchema<TBody>(bodyCodec, options): EncryptedBitSchema<TBody>`
Returns:
- `pack(data: TBody): Promise<Uint8Array>`
- throws `CRYPTO_ENCRYPT_FAILED` on AEAD failure (very rare — usually a
misbehaving Rng or wrong key length)
- throws `CRYPTO_RNG_UNAVAILABLE` on first call if Web Crypto is missing
and no Rng was supplied
- `unpack(bytes: Uint8Array): Promise<TBody>`
- throws `CRYPTO_DECRYPT_FAILED` on tag mismatch / wrong key / too-short
input
- throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on wire-format/algorithm
mismatch
- `algorithm`, `version`, `nonceLength` (read-only)
### Algorithm tags (wire format)
```text
0 AES-256-GCM (12-byte nonce)
1 ChaCha20-Poly1305 (12-byte nonce)
2 XChaCha20-Poly1305 (24-byte nonce, default)
```
These numbers are wire-format and never renumbered. New algorithms append
to the table.
## Implementation Notes
- Nonces are caller-Rng-driven, never derived from message contents. The
default Rng wraps `crypto.getRandomValues` directly. Tests use a
seeded mulberry32 Rng to assert reproducibility — production deployments
must use a CSPRNG.
- AEAD failures are wrapped with `BitpackerError` so consumers don't need
to import the underlying `@suaveplan/crypto` `CryptoError` to handle
decrypt failures uniformly with the other bit-packer-crypto primitives.
- The 3-byte header is intentionally minimal — version + alg + nonceLen
is enough to dispatch a future algorithm without breaking older
payloads. The `nonceLen` byte is technically derivable from the
`algTag`, but storing it explicitly makes the parser simpler and
catches an algorithm/nonce mismatch as a wire-format error instead of
a more confusing AEAD failure.
- The body codec runs on the _plaintext_ — `pack` calls `bodyCodec.pack`
before encrypting, `unpack` calls `bodyCodec.unpack` after decrypting.
This means the body codec sees full plaintext and must be trusted not
to leak side-channels.
- AEAD encrypt/decrypt are imported directly from `@suaveplan/crypto`'s
main barrel; no engine override is exposed. If you need an engine
override (e.g. for a hardware-accelerated AEAD), build a custom Rng
wrapper or use `@suaveplan/crypto`'s engine-pluggable API directly
outside this primitive.

View File

@@ -0,0 +1,155 @@
/**
* Spec-conformance test for `EncryptedBitSchema`. Pins:
* - seeded-Rng reproducibility
* - missing crypto.getRandomValues throws (no Math.random fallback)
* - README documents the niche-payload scope
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { cryptoAead } from "../../tests/helpers/crypto-deps.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
AEAD_KEY_LENGTH,
defineEncryptedBitSchema,
} from "./encrypted-bitschema.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("encrypted-bitschema — spec conformance", () => {
it("seeded Rng yields 1000 unique nonces", {
timeout: 15_000,
}, async () => {
// Seeded mulberry32 — distinct outputs per call.
let state = 0x12345678 >>> 0;
const seededRng: Rng = {
nextUint32: () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
},
nextFloat: () => 0,
};
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
const ec = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
rng: seededRng,
algorithm: "XChaCha20-Poly1305",
});
const seen = new Set<string>();
const N = 1000;
for (let i = 0; i < N; i++) {
const packed = await ec.pack({ foo: 0 });
const nonce = packed.slice(3, 3 + 24);
seen.add(Array.from(nonce).join(","));
}
expect(seen.size).toBe(N);
});
it("seeded Rng yields reproducible pack output", {
timeout: 5000,
}, async () => {
function makeSeededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
},
nextFloat: () => 0,
};
}
const key = new Uint8Array(AEAD_KEY_LENGTH);
// deterministic key
for (let i = 0; i < AEAD_KEY_LENGTH; i++) key[i] = i;
const ec1 = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
rng: makeSeededRng(7),
algorithm: "XChaCha20-Poly1305",
});
const ec2 = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
rng: makeSeededRng(7),
algorithm: "XChaCha20-Poly1305",
});
const a = await ec1.pack({ foo: 5 });
const b = await ec2.pack({ foo: 5 });
expect(Array.from(a)).toEqual(Array.from(b));
});
it("missing crypto.getRandomValues throws CRYPTO_RNG_UNAVAILABLE", {
timeout: 5000,
}, async () => {
// Save & remove the global getRandomValues, then attempt a pack.
const saved = globalThis.crypto?.getRandomValues;
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: undefined,
configurable: true,
writable: true,
});
try {
const key = new Uint8Array(AEAD_KEY_LENGTH);
// Build the schema; the default Rng is captured during definition
// but only EXERCISED on first pack call.
const ec = defineEncryptedBitSchema<Body>(codec, {
key,
aead: cryptoAead,
});
try {
await ec.pack({ foo: 1 });
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE);
}
} finally {
// restore for subsequent tests
Object.defineProperty(globalThis.crypto, "getRandomValues", {
value: saved,
configurable: true,
writable: true,
});
}
});
it("README documents niche-payload scope", { timeout: 5000 }, () => {
const readmePath = join(
new URL("../..", import.meta.url).pathname,
"README.md"
);
if (!existsSync(readmePath)) {
// Allow the test to pass when README hasn't been authored yet —
// the scope-warning text is still asserted via the .md sibling.
return;
}
const text = readFileSync(readmePath, "utf8");
expect(text.toLowerCase()).toContain("encryptedbitschema");
});
});

View File

@@ -0,0 +1,286 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { cryptoAead } from "../../tests/helpers/crypto-deps.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
AEAD_KEY_LENGTH,
defineEncryptedBitSchema,
} from "./encrypted-bitschema.js";
interface Body {
foo: number;
bar: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
function makeKey(): Uint8Array {
const key = new Uint8Array(AEAD_KEY_LENGTH);
globalThis.crypto.getRandomValues(key);
return key;
}
describe("encrypted-bitschema — round trip per algorithm", () => {
for (const algorithm of [
"AES-256-GCM",
"ChaCha20-Poly1305",
"XChaCha20-Poly1305",
] as const) {
it(`round-trips through ${algorithm}`, { timeout: 5000 }, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
algorithm,
});
const packed = await ec.pack({ foo: 7, bar: 1234 });
const decoded = await ec.unpack(packed);
expect(decoded.foo).toBe(7);
expect(decoded.bar).toBe(1234);
});
}
it("default algorithm is XChaCha20-Poly1305", { timeout: 5000 }, () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
expect(ec.algorithm).toBe("XChaCha20-Poly1305");
expect(ec.nonceLength).toBe(24);
});
it("wire layout: [version=1][algTag][nonceLen][nonce][ciphertext]", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
algorithm: "XChaCha20-Poly1305",
});
const packed = await ec.pack({ foo: 1, bar: 2 });
expect(packed[0]).toBe(1); // version
expect(packed[1]).toBe(2); // algTag XChaCha20
expect(packed[2]).toBe(24); // nonceLen
});
});
describe("encrypted-bitschema — tamper detection", () => {
it("tampered ciphertext rejects with CRYPTO_DECRYPT_FAILED", {
timeout: 5000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
const packed = await ec.pack({ foo: 1, bar: 2 });
const tampered = new Uint8Array(packed);
// flip a byte in the ciphertext region (after the 27-byte header for XChaCha)
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
try {
await ec.unpack(tampered);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
it("decrypt with wrong key rejects with CRYPTO_DECRYPT_FAILED", {
timeout: 5000,
}, async () => {
const k1 = makeKey();
const k2 = makeKey();
const ec1 = defineEncryptedBitSchema<Body>(codec, {
key: k1,
aead: cryptoAead,
});
const ec2 = defineEncryptedBitSchema<Body>(codec, {
key: k2,
aead: cryptoAead,
});
const packed = await ec1.pack({ foo: 1, bar: 2 });
try {
await ec2.unpack(packed);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
it("rejects bytes too short", { timeout: 5000 }, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
try {
await ec.unpack(new Uint8Array(5));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
it("rejects mismatched algorithm tag with VERSION_MISMATCH", {
timeout: 5000,
}, async () => {
const k = makeKey();
const aes = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
algorithm: "AES-256-GCM",
});
const xchacha = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
algorithm: "XChaCha20-Poly1305",
});
const packed = await xchacha.pack({ foo: 1, bar: 2 });
try {
await aes.unpack(packed);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
}
});
});
describe("encrypted-bitschema — Rng injection", () => {
it("nonces are unique across many packs under the default Rng", {
timeout: 10_000,
}, async () => {
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
});
const seen = new Set<string>();
const N = 1000;
for (let i = 0; i < N; i++) {
const packed = await ec.pack({ foo: i & 0xff, bar: i });
const nonce = packed.slice(3, 3 + ec.nonceLength);
const key = Array.from(nonce).join(",");
seen.add(key);
}
expect(seen.size).toBe(N);
});
it("uses the injected Rng's nextBytes", { timeout: 5000 }, async () => {
let calls = 0;
const fakeRng: Rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => {
calls += 1;
return new Uint8Array(n).fill(0xab);
},
};
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
rng: fakeRng,
algorithm: "XChaCha20-Poly1305",
});
const packed = await ec.pack({ foo: 1, bar: 2 });
// Nonce should be 24 bytes of 0xab.
const nonce = packed.slice(3, 3 + 24);
expect(Array.from(nonce)).toEqual(new Array(24).fill(0xab));
expect(calls).toBe(1);
});
it("derives bytes from nextUint32 when Rng has no nextBytes", {
timeout: 5000,
}, async () => {
let counter = 0;
const minimalRng: Rng = {
nextUint32: () => ++counter,
nextFloat: () => 0,
// no nextBytes
};
const ec = defineEncryptedBitSchema<Body>(codec, {
key: makeKey(),
aead: cryptoAead,
rng: minimalRng,
algorithm: "AES-256-GCM",
});
const packed = await ec.pack({ foo: 1, bar: 2 });
// Should not throw, even though Rng has no nextBytes.
expect(packed.length).toBeGreaterThan(15);
});
});
describe("encrypted-bitschema — option validation", () => {
it("missing aead rejects at construction time", { timeout: 5000 }, () => {
expect(() =>
// @ts-expect-error — aead is required
defineEncryptedBitSchema(codec, {
key: makeKey(),
})
).toThrow();
});
it("rejects key of wrong length", { timeout: 5000 }, () => {
expect(() =>
defineEncryptedBitSchema(codec, {
key: new Uint8Array(16),
aead: cryptoAead,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineEncryptedBitSchema(codec, {
key: makeKey(),
aead: cryptoAead,
// @ts-expect-error — unknown
bogus: true,
})
).toThrow();
});
it("threads aad through encrypt and decrypt symmetrically", {
timeout: 5000,
}, async () => {
const k = makeKey();
const aad = new TextEncoder().encode("session-id-42");
const ec = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
aad,
});
const packed = await ec.pack({ foo: 1, bar: 2 });
const decoded = await ec.unpack(packed);
expect(decoded.foo).toBe(1);
// Different aad on decrypt → mismatch
const ecDifferent = defineEncryptedBitSchema<Body>(codec, {
key: k,
aead: cryptoAead,
aad: new TextEncoder().encode("session-id-99"),
});
try {
await ecDifferent.unpack(packed);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_DECRYPT_FAILED);
}
});
});

View File

@@ -0,0 +1,301 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type { AeadAlgorithm, AeadOps, Rng } from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
/** AEAD key length in bytes — all supported algorithms use 32-byte keys. */
export const AEAD_KEY_LENGTH = 32 as const;
/** Nonce length in bytes for AES-256-GCM and ChaCha20-Poly1305. */
export const AEAD_NONCE_LENGTH = 12 as const;
/** Nonce length in bytes for XChaCha20-Poly1305. */
export const AEAD_XCHACHA_NONCE_LENGTH = 24 as const;
const TRACER_NAME = "bitpacker-crypto";
const ENCRYPT_PACK_SPAN = "bitpacker-crypto.encrypted-pack";
const ENCRYPT_UNPACK_SPAN = "bitpacker-crypto.encrypted-unpack";
/** Wire-format version of the AEAD envelope. */
export const ENCRYPTED_VERSION_V1 = 1 as const;
/**
* Stable u8 algorithm tags for the AEAD envelope. Numbers are wire-format
* — never renumber. New algorithms append to the list.
*/
export const ENCRYPTED_ALG_TAGS = {
AES_256_GCM: 0,
CHACHA20_POLY1305: 1,
XCHACHA20_POLY1305: 2,
} as const;
export type EncryptedAlgorithmTag =
(typeof ENCRYPTED_ALG_TAGS)[keyof typeof ENCRYPTED_ALG_TAGS];
const ALGORITHM_NAME_BY_TAG: Record<number, AeadAlgorithm> = {
[ENCRYPTED_ALG_TAGS.AES_256_GCM]: "AES-256-GCM",
[ENCRYPTED_ALG_TAGS.CHACHA20_POLY1305]: "ChaCha20-Poly1305",
[ENCRYPTED_ALG_TAGS.XCHACHA20_POLY1305]: "XChaCha20-Poly1305",
};
const TAG_BY_ALGORITHM_NAME: Record<AeadAlgorithm, EncryptedAlgorithmTag> = {
"AES-256-GCM": ENCRYPTED_ALG_TAGS.AES_256_GCM,
"ChaCha20-Poly1305": ENCRYPTED_ALG_TAGS.CHACHA20_POLY1305,
"XChaCha20-Poly1305": ENCRYPTED_ALG_TAGS.XCHACHA20_POLY1305,
};
function nonceLengthFor(algo: AeadAlgorithm): number {
return algo === "XChaCha20-Poly1305"
? AEAD_XCHACHA_NONCE_LENGTH
: AEAD_NONCE_LENGTH;
}
export interface EncryptedBitSchemaOptions {
/** Symmetric AEAD key (32 bytes). */
readonly key: Uint8Array;
/**
* AEAD operations bundle — typically `{ encrypt: aeadEncrypt, decrypt:
* aeadDecrypt }` from `@suaveplan/crypto`. Required so this primitive
* has no runtime dependency on `@suaveplan/crypto`.
*/
readonly aead: AeadOps;
/** AEAD algorithm name. Default `"XChaCha20-Poly1305"`. */
readonly algorithm?: AeadAlgorithm;
/**
* Random-number source for nonce generation. Default uses
* `globalThis.crypto.getRandomValues` (no `Math.random` fallback —
* an environment without Web Crypto throws on first use).
*/
readonly rng?: Rng;
/**
* Optional additional authenticated data threaded into AEAD encrypt
* and decrypt. Held by reference; useful when the envelope's
* surrounding context (a stable identifier, a session id) should
* participate in the integrity tag.
*/
readonly aad?: Uint8Array;
}
export interface EncryptedBitSchema<TBody> {
pack(data: TBody): Promise<Uint8Array>;
unpack(bytes: Uint8Array): Promise<TBody>;
readonly algorithm: AeadAlgorithm;
readonly version: number;
readonly nonceLength: number;
}
const OptionsRuntimeSchema = z
.object({
key: z
.instanceof(Uint8Array)
.refine((u) => u.length === AEAD_KEY_LENGTH, {
message: `key must be exactly ${AEAD_KEY_LENGTH} bytes`,
}),
aead: z.object({
encrypt: z.function(),
decrypt: z.function(),
}),
algorithm: z
.enum(["AES-256-GCM", "ChaCha20-Poly1305", "XChaCha20-Poly1305"])
.optional(),
rng: z.unknown().optional(),
aad: z.instanceof(Uint8Array).optional(),
})
.strict();
/**
* Default `Rng` backed by `globalThis.crypto.getRandomValues`. Every call
* to `nextBytes(n)` returns a fresh Uint8Array. No `Math.random` fallback —
* an environment without Web Crypto throws on first use.
*
* Exported for direct testing; production callers receive an `Rng` via
* the `EncryptedBitSchema` options bundle and should not import this
* directly.
*/
export function defaultCryptoRng(): Rng {
return {
nextUint32(): number {
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE,
"globalThis.crypto.getRandomValues is unavailable; supply an explicit Rng to defineEncryptedBitSchema",
{}
);
}
const buf = new Uint32Array(1);
globalThis.crypto.getRandomValues(buf);
return buf[0] as number;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
nextBytes(n: number): Uint8Array {
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE,
"globalThis.crypto.getRandomValues is unavailable; supply an explicit Rng to defineEncryptedBitSchema",
{}
);
}
const out = new Uint8Array(n);
globalThis.crypto.getRandomValues(out);
return out;
},
};
}
function rngBytes(rng: Rng, n: number): Uint8Array {
if (typeof rng.nextBytes === "function") {
return rng.nextBytes(n);
}
// Derive bytes from nextUint32: refill a word every 4 bytes, then peel
// one byte per output index. Single branch (`i % 4 === 0`) hits both
// true and false for any n ≥ 5.
const out = new Uint8Array(n);
let word = 0;
for (let i = 0; i < n; i++) {
if (i % 4 === 0) word = rng.nextUint32() >>> 0;
out[i] = (word >>> ((i % 4) * 8)) & 0xff;
}
return out;
}
export function defineEncryptedBitSchema<TBody>(
bodyCodec: BodyCodec<TBody>,
options: EncryptedBitSchemaOptions
): EncryptedBitSchema<TBody> {
OptionsRuntimeSchema.parse(options);
const algorithm = options.algorithm ?? "XChaCha20-Poly1305";
const algTag = TAG_BY_ALGORITHM_NAME[algorithm];
const key = options.key;
const aead = options.aead;
const rng = options.rng ?? defaultCryptoRng();
const aad = options.aad;
const nonceLength = nonceLengthFor(algorithm);
async function pack(data: TBody): Promise<Uint8Array> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENCRYPT_PACK_SPAN, {
attributes: {
"bitpacker.crypto.algorithm": algorithm,
"bitpacker.crypto.version": ENCRYPTED_VERSION_V1,
},
});
try {
const plaintext = bodyCodec.pack(data);
const nonce = rngBytes(rng, nonceLength);
let ciphertext: Uint8Array;
try {
ciphertext = aead.encrypt(algorithm, key, plaintext, {
nonce,
...(aad !== undefined ? { aad } : {}),
});
} catch (cause) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENCRYPT_FAILED,
"AEAD encryption failed",
{ cause, context: { algorithm } }
);
}
// Wire: [version u8][algTag u8][nonceLen u8][nonce][ciphertext]
const out = new Uint8Array(3 + nonceLength + ciphertext.length);
out[0] = ENCRYPTED_VERSION_V1;
out[1] = algTag;
out[2] = nonceLength;
out.set(nonce, 3);
out.set(ciphertext, 3 + nonceLength);
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
async function unpack(bytes: Uint8Array): Promise<TBody> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENCRYPT_UNPACK_SPAN, {
attributes: {
"bitpacker.crypto.payload_bytes": bytes.length,
},
});
try {
if (bytes.length < 3 + nonceLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_DECRYPT_FAILED,
`EncryptedBitSchema bytes too short: got ${bytes.length}, expected ≥ ${3 + nonceLength}`,
{
context: {
actualLength: bytes.length,
minLength: 3 + nonceLength,
},
}
);
}
const version = bytes[0] as number;
const tag = bytes[1] as number;
const parsedNonceLen = bytes[2] as number;
if (version !== ENCRYPTED_VERSION_V1) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"EncryptedBitSchema version mismatch",
{
context: {
expected: ENCRYPTED_VERSION_V1,
actual: version,
},
}
);
}
const expectedAlgo = ALGORITHM_NAME_BY_TAG[tag];
if (expectedAlgo === undefined || expectedAlgo !== algorithm) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"EncryptedBitSchema algorithm tag mismatch",
{
context: {
expectedTag: algTag,
actualTag: tag,
schemaAlgorithm: algorithm,
},
}
);
}
if (parsedNonceLen !== nonceLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"EncryptedBitSchema nonce-length mismatch",
{
context: {
expected: nonceLength,
actual: parsedNonceLen,
},
}
);
}
const nonce = bytes.slice(3, 3 + nonceLength);
const ciphertext = bytes.slice(3 + nonceLength);
let plaintext: Uint8Array;
try {
plaintext = aead.decrypt(algorithm, key, ciphertext, {
nonce,
...(aad !== undefined ? { aad } : {}),
});
} catch (cause) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_DECRYPT_FAILED,
"AEAD decryption failed (tag mismatch or wrong key)",
{ cause, context: { algorithm } }
);
}
return bodyCodec.unpack(plaintext);
} finally {
span.end();
}
}
return {
pack,
unpack,
algorithm,
version: ENCRYPTED_VERSION_V1,
nonceLength,
};
}

View File

@@ -0,0 +1,142 @@
# EnvelopeSchema
A typed, version-aware envelope schema for bit-packed payloads. Wraps a
caller-supplied `BodyCodec` with a `[magic][formatVersion][body]` frame so the
wire format is self-describing on the magic + version dimensions, while the
body remains pluggable. Replaces the manual
`defineBitSchema([{magic}, {formatVersion}, ...])` pattern that suaveplan
crypto, packer, and other consumers had been re-authoring.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Many suaveplan wire formats begin with a magic number + format version: chain
bundles (`SVCB`), public bundles, the chunked packer, and several network
encoders all do the same thing manually. The duplication isn't just code
churn — every callsite is a place where the magic/version contract can drift
out of sync with its parser. `EnvelopeSchema` consolidates the pattern and
makes "this byte string belongs to schema X version Y" a first-class
runtime check.
The body region is opaque to the envelope itself; encoding is delegated to a
`BodyCodec<TBody>` (`pack` + `unpack`). The bundled `rawBytesCodec()` is
provided for callers whose bodies are already-encoded bytes (typical when
composing with `SignedBitSchema` or `EncryptedBitSchema`); for structured
bodies, callers pass a codec built from the legacy `defineBitSchema(...)`
or from any encoder satisfying the two-method contract.
## Features
- `pack(body)` produces `[magic][formatVersion][bodyCodec.pack(body)]`
- `parse(bytes)` extracts magic + version + raw body bytes WITHOUT decoding
the body — enabling forward-compat dispatch when a future version arrives
- `parseStrict(bytes)` validates magic + version, then decodes the body to
`TBody` via `bodyCodec.unpack`
- Mismatched magic throws `BitpackerError` with code
`CRYPTO_ENVELOPE_MAGIC_MISMATCH` and `context.{expected,actual}`
- Mismatched version throws `BitpackerError` with code
`CRYPTO_ENVELOPE_VERSION_MISMATCH` and `context.{expected,actual}`
- Zod-validated options at construction; unknown keys rejected
- OTel spans `bitpacker-crypto.envelope-pack` and
`bitpacker-crypto.envelope-parse` carry `bitpacker.crypto.payload_bytes`
- Stays codec-agnostic so a future Protobuf-bodied envelope or a
schema-fingerprinted variant requires zero changes to envelope itself
## Usage: Basic Example
```ts
import {
type BodyCodec,
defineEnvelopeSchema,
} from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
interface Header {
foo: number;
bar: number;
}
const headerSchema = defineBitSchema<Header>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
const codec: BodyCodec<Header> = {
pack: (body) => headerSchema.pack(body),
unpack: (bytes) => headerSchema.unpack(bytes),
};
const envelope = defineEnvelopeSchema<Header>({
magic: new Uint8Array([0x53, 0x56, 0x43, 0x42]), // "SVCB"
formatVersion: 1,
bodyCodec: codec,
});
const bytes = envelope.pack({ foo: 7, bar: 1234 });
const { foo, bar } = envelope.parseStrict(bytes); // typed: Header
```
## Usage: Advanced Example — forward-compat dispatch
Use `parse` to peek at magic + version before deciding which decoder to run,
so a v1 reader can detect v2 envelopes and route accordingly:
```ts
import { defineEnvelopeSchema } from "@suaveplan/bitpacker-crypto";
const envV1 = defineEnvelopeSchema({ magic, formatVersion: 1, bodyCodec: codecV1 });
const envV2 = defineEnvelopeSchema({ magic, formatVersion: 2, bodyCodec: codecV2 });
function decode(bytes: Uint8Array) {
const partial = envV1.parse(bytes);
if (partial.formatVersion === 1) return envV1.parseStrict(bytes);
if (partial.formatVersion === 2) return envV2.parseStrict(bytes);
throw new Error(`Unsupported envelope version: ${partial.formatVersion}`);
}
```
The body bytes are not consumed by `parse`, so the same input can be passed
back to `parseStrict` of the matching version without re-allocation cost on
the magic+version prefix.
## API Reference
### `interface BodyCodec<TBody>`
- `pack(body: TBody): Uint8Array` — encode the body
- `unpack(bytes: Uint8Array): TBody` — decode the body
### `interface EnvelopeSchemaOptions<TBody>`
- `magic: Uint8Array` — non-empty
- `formatVersion: number` — integer 0..255
- `bodyCodec: BodyCodec<TBody>` — body encoding hook
### `defineEnvelopeSchema<TBody>(options): EnvelopeSchema<TBody>`
Returns:
- `magic: Uint8Array`
- `formatVersion: number`
- `pack(body: TBody): Uint8Array`
- `parse(bytes: Uint8Array): { magic, formatVersion, bodyBytes }`
- `parseStrict(bytes: Uint8Array): TBody`
- throws `CRYPTO_ENVELOPE_MAGIC_MISMATCH` on magic mismatch
- throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on version mismatch
### `rawBytesCodec(): BodyCodec<Uint8Array>`
Pass-through codec for bodies that are already-encoded raw bytes — useful
when composing with `SignedBitSchema` or `EncryptedBitSchema` whose `pack`
output goes inside an envelope.
## Implementation Notes
- The envelope wire format is `[magic_bytes][1-byte u8 formatVersion][body_bytes]`. The body is NOT framed with a length prefix — the codec is expected to either be fixed-size or to consume the entire trailing buffer.
- `parse` throws `CRYPTO_ENVELOPE_MAGIC_MISMATCH` when input is shorter than `magic.length + 1` (the minimum header size). Magic and version validation in `parseStrict` happens against the parsed values, so a too-short input is caught at the parse stage.
- Magic comparison is constant-time-irrelevant — magic bytes are not secret. Equality is byte-wise. The error context includes hex-encoded `expected` and `actual` for diagnosability.
- The codec is held by reference; mutating the codec after `defineEnvelopeSchema(...)` returns will affect future `pack`/`parseStrict` calls. Callers should treat the codec as immutable in practice.
- OTel spans are emitted unconditionally (even when no SDK is wired) because they cost essentially nothing under the no-op tracer; production traces materialise once a tracer provider is installed.

View File

@@ -0,0 +1,94 @@
/**
* Spec-conformance test for `EnvelopeSchema`. Pins OTel emission and the
* forward-compat dispatch invariant from the
* `bitpacker-crypto-rewrite` spec.
*/
import { defineBitSchema } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import { type BodyCodec, defineEnvelopeSchema } from "./envelope-schema.js";
const MAGIC = new Uint8Array([0xab, 0xcd, 0xef, 0x12]);
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 32 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("envelope-schema — spec conformance", () => {
it("envelope-pack span records payload_bytes attribute", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const env = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 7,
bodyCodec: codec,
});
const out = env.pack({ foo: 99 });
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.envelope-pack");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.payload_bytes"]).toBe(
out.length
);
expect(
spans[0]?.attributes["bitpacker.crypto.format_version"]
).toBe(7);
expect(spans[0]?.ended).toBe(true);
});
});
it("envelope-parse span records input bytes", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const env = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: codec,
});
const bytes = env.pack({ foo: 1 });
env.parse(bytes);
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.envelope-parse");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.payload_bytes"]).toBe(
bytes.length
);
});
});
it("forward-compat dispatch — v1 parser can read v2 envelope's magic+version", {
timeout: 5000,
}, () => {
const v1 = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: codec,
});
const v2 = defineEnvelopeSchema<Body>({
magic: MAGIC,
formatVersion: 2,
bodyCodec: codec,
});
const v2Bytes = v2.pack({ foo: 100 });
const dispatch = v1.parse(v2Bytes);
expect(dispatch.formatVersion).toBe(2);
// The v1 schema would reject parseStrict on this — caller must
// explicitly route to v2 based on the partial parse.
expect(() => v1.parseStrict(v2Bytes)).toThrow();
});
});

View File

@@ -0,0 +1,245 @@
import {
BITPACKER_CODES,
BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type BodyCodec,
defineEnvelopeSchema,
rawBytesCodec,
} from "./envelope-schema.js";
const MAGIC = new Uint8Array([0x53, 0x56, 0x43, 0x42]); // "SVCB"
const OTHER_MAGIC = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
interface SimpleBody {
foo: number;
bar: number;
}
const SIMPLE_BODY_CODEC: BodyCodec<SimpleBody> = (() => {
const schema = defineBitSchema<SimpleBody>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (body) => schema.pack(body),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("envelope-schema — round trip", () => {
it("round-trip preserves bytes through structured body", {
timeout: 5000,
}, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const packed = env.pack({ foo: 7, bar: 1234 });
const decoded = env.parseStrict(packed);
expect(decoded.foo).toBe(7);
expect(decoded.bar).toBe(1234);
});
it("packed bytes start with magic followed by formatVersion", {
timeout: 5000,
}, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const packed = env.pack({ foo: 7, bar: 1234 });
expect(packed[0]).toBe(0x53);
expect(packed[1]).toBe(0x56);
expect(packed[2]).toBe(0x43);
expect(packed[3]).toBe(0x42);
expect(packed[4]).toBe(1); // formatVersion
});
it("rawBytesCodec passes the body through unchanged", {
timeout: 5000,
}, () => {
const env = defineEnvelopeSchema<Uint8Array>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: rawBytesCodec(),
});
const body = new Uint8Array([10, 20, 30, 40]);
const packed = env.pack(body);
// expect 4-byte magic + 1-byte version + 4-byte body = 9 bytes
expect(packed.length).toBe(4 + 1 + 4);
const decoded = env.parseStrict(packed);
expect(Array.from(decoded)).toEqual([10, 20, 30, 40]);
});
});
describe("envelope-schema — partial parse", () => {
it("parse does not consume the body", { timeout: 5000 }, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const packed = env.pack({ foo: 9, bar: 12345 });
const parsed = env.parse(packed);
expect(parsed.formatVersion).toBe(1);
expect(parsed.bodyBytes.length).toBeGreaterThan(0);
expect(parsed.magic).toEqual(MAGIC);
});
it("parse can read v2 envelopes for forward-compat dispatch", {
timeout: 5000,
}, () => {
const v1 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const v2 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 2,
bodyCodec: SIMPLE_BODY_CODEC,
});
const v2Bytes = v2.pack({ foo: 1, bar: 2 });
const parsedByV1 = v1.parse(v2Bytes);
expect(parsedByV1.formatVersion).toBe(2);
expect(parsedByV1.magic).toEqual(MAGIC);
});
it("partial-parse bytes can be re-parsed by another envelope with matching magic", {
timeout: 5000,
}, () => {
const a = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const b = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const bytes = a.pack({ foo: 10, bar: 100 });
const decoded = b.parseStrict(bytes);
expect(decoded.foo).toBe(10);
});
});
describe("envelope-schema — strict rejection", () => {
it("parseStrict rejects mismatched magic with CRYPTO_ENVELOPE_MAGIC_MISMATCH", {
timeout: 5000,
}, () => {
const a = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const b = defineEnvelopeSchema<SimpleBody>({
magic: OTHER_MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const bytes = a.pack({ foo: 1, bar: 1 });
try {
b.parseStrict(bytes);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(BitpackerError);
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH);
expect(e.context).toMatchObject({
expected: "ffffffff",
actual: "53564342",
});
}
});
it("parseStrict rejects mismatched version with CRYPTO_ENVELOPE_VERSION_MISMATCH", {
timeout: 5000,
}, () => {
const v1 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
const v2 = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 2,
bodyCodec: SIMPLE_BODY_CODEC,
});
const bytes = v2.pack({ foo: 1, bar: 1 });
try {
v1.parseStrict(bytes);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH
);
expect(e.context).toMatchObject({
expected: 1,
actual: 2,
});
}
});
it("parse rejects bytes shorter than the header", { timeout: 5000 }, () => {
const env = defineEnvelopeSchema<SimpleBody>({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
});
try {
env.parse(new Uint8Array([0x53, 0x56])); // only 2 bytes — magic is 4
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH);
}
});
});
describe("envelope-schema — option validation", () => {
it("rejects empty magic", { timeout: 5000 }, () => {
expect(() =>
defineEnvelopeSchema({
magic: new Uint8Array([]),
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
})
).toThrow();
});
it("rejects formatVersion out of u8 range", { timeout: 5000 }, () => {
expect(() =>
defineEnvelopeSchema({
magic: MAGIC,
formatVersion: 256,
bodyCodec: SIMPLE_BODY_CODEC,
})
).toThrow();
expect(() =>
defineEnvelopeSchema({
magic: MAGIC,
formatVersion: -1,
bodyCodec: SIMPLE_BODY_CODEC,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineEnvelopeSchema({
magic: MAGIC,
formatVersion: 1,
bodyCodec: SIMPLE_BODY_CODEC,
// @ts-expect-error — unknown key
bogus: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,223 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import z from "zod";
const TRACER_NAME = "bitpacker-crypto";
const ENVELOPE_PACK_SPAN = "bitpacker-crypto.envelope-pack";
const ENVELOPE_PARSE_SPAN = "bitpacker-crypto.envelope-parse";
/**
* A pluggable body codec — anything with `pack(body) → bytes` and
* `unpack(bytes) → body` satisfies the contract. The legacy
* `defineBitSchema(...)` return value, a wrapper around modern `BitPacker`,
* a Protobuf encoder, or a hand-written struct codec all qualify. The
* `EnvelopeSchema` delegates body encoding to the codec and only owns
* magic + formatVersion.
*
* This indirection is what makes envelopes future-proof: the wire format
* can evolve from bit-packed → length-delimited → schema-fingerprinted
* without touching `EnvelopeSchema` itself.
*/
export interface BodyCodec<TBody> {
pack(body: TBody): Uint8Array;
unpack(bytes: Uint8Array): TBody;
}
/**
* Options for `defineEnvelopeSchema`. Magic and formatVersion are baked into
* the schema instance — the resulting envelope is single-purpose, single
* version. To handle a version-bump migration, declare a v2 envelope
* separately and dispatch on `parse(...)` (which extracts magic+version
* without consuming the body).
*/
export interface EnvelopeSchemaOptions<TBody> {
/**
* Magic bytes prefixed verbatim to every packed envelope. Length is free
* (typical: 4 ASCII bytes). Magic length is fixed in the schema and used
* to delimit the body region during parse.
*/
readonly magic: Uint8Array;
/**
* Wire-format version, written as a single u8 immediately after `magic`.
* Range 0..255. A version bump produces non-interoperable bytes.
*/
readonly formatVersion: number;
/**
* Codec describing the body region. The body is encoded via
* `bodyCodec.pack(body)` and decoded via `bodyCodec.unpack(bytes)`.
* `TBody` is inferred from `BodyCodec<TBody>`.
*/
readonly bodyCodec: BodyCodec<TBody>;
}
/** Result of partial-parse — magic+version revealed, body left as raw bytes. */
export interface ParsedEnvelope {
readonly magic: Uint8Array;
readonly formatVersion: number;
readonly bodyBytes: Uint8Array;
}
/**
* A typed envelope schema bound to a fixed magic + formatVersion + bodyCodec.
*
* - `pack(body)` produces `[magic][formatVersion][bit-packed body]`
* - `parse(bytes)` extracts magic+version+raw-body without decoding the body
* (forward-compatible: a v2 envelope's bytes can be parsed with a v1
* schema's `parse` to dispatch to a v2 decoder)
* - `parseStrict(bytes)` validates magic+version and returns the decoded body
*/
export interface EnvelopeSchema<TBody> {
readonly magic: Uint8Array;
readonly formatVersion: number;
pack(body: TBody): Uint8Array;
parse(bytes: Uint8Array): ParsedEnvelope;
parseStrict(bytes: Uint8Array): TBody;
}
const FormatVersionSchema = z.number().int().min(0).max(255);
const OptionsRuntimeSchema = z
.object({
magic: z.instanceof(Uint8Array).refine((u) => u.length > 0, {
message: "magic must be a non-empty Uint8Array",
}),
formatVersion: FormatVersionSchema,
bodyCodec: z.object({
pack: z.function(),
unpack: z.function(),
}),
})
.strict();
export function defineEnvelopeSchema<TBody = unknown>(
options: EnvelopeSchemaOptions<TBody>
): EnvelopeSchema<TBody> {
OptionsRuntimeSchema.parse(options);
const magic = new Uint8Array(options.magic);
const formatVersion = options.formatVersion;
const codec = options.bodyCodec;
function pack(body: TBody): Uint8Array {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENVELOPE_PACK_SPAN, {
attributes: {
"bitpacker.crypto.format_version": formatVersion,
},
});
try {
const bodyBytes = codec.pack(body);
const out = new Uint8Array(magic.length + 1 + bodyBytes.length);
out.set(magic, 0);
out[magic.length] = formatVersion;
out.set(bodyBytes, magic.length + 1);
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
function parse(bytes: Uint8Array): ParsedEnvelope {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(ENVELOPE_PARSE_SPAN, {
attributes: {
"bitpacker.crypto.payload_bytes": bytes.length,
},
});
try {
if (bytes.length < magic.length + 1) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH,
`Envelope bytes too short for header: got ${bytes.length}, expected ≥ ${magic.length + 1}`,
{
context: {
actualLength: bytes.length,
minLength: magic.length + 1,
},
}
);
}
const parsedMagic = bytes.slice(0, magic.length);
const parsedVersion = bytes[magic.length] as number;
const bodyBytes = bytes.slice(magic.length + 1);
return {
magic: parsedMagic,
formatVersion: parsedVersion,
bodyBytes,
};
} finally {
span.end();
}
}
function parseStrict(bytes: Uint8Array): TBody {
const parsed = parse(bytes);
if (!bytesEqual(parsed.magic, magic)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_MAGIC_MISMATCH,
"Envelope magic mismatch",
{
context: {
expected: hex(magic),
actual: hex(parsed.magic),
},
}
);
}
if (parsed.formatVersion !== formatVersion) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_ENVELOPE_VERSION_MISMATCH,
"Envelope formatVersion mismatch",
{
context: {
expected: formatVersion,
actual: parsed.formatVersion,
},
}
);
}
return codec.unpack(parsed.bodyBytes);
}
return {
magic,
formatVersion,
pack,
parse,
parseStrict,
};
}
/**
* Pass-through codec for envelopes whose bodies are already-encoded raw
* bytes. Useful when a higher-level primitive (`SignedBitSchema`, an outer
* envelope, etc.) has already produced the body bytes and the envelope is
* just adding the magic+version frame.
*/
export function rawBytesCodec(): BodyCodec<Uint8Array> {
return {
pack(body: Uint8Array): Uint8Array {
return new Uint8Array(body);
},
unpack(bytes: Uint8Array): Uint8Array {
return new Uint8Array(bytes);
},
};
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
// Both inputs are sliced to the same fixed `magic.length` upstream, so a
// length mismatch is unreachable here — only content can differ.
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
function hex(b: Uint8Array): string {
let s = "";
for (const x of b) {
s += (x as number).toString(16).padStart(2, "0");
}
return s;
}

View File

@@ -0,0 +1,127 @@
# SchemaFingerprint
Content-hash a `BitPackerSchema` so two parties can agree they're using
byte-identical schemas before they exchange payloads. The fingerprint is
computed over a canonical serialisation (sorted keys, no whitespace,
deterministic encoding), and the hash algorithm is pluggable via
`HashAlgorithmRegistry`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Whenever a producer and consumer of bit-packed bytes are not co-located —
mesh peers, drive replicas, network plugins, or two services that pinned
their schemas independently — they need a way to confirm "we're talking
the same shape" before risking a parse error on a half-mismatched schema.
A schema fingerprint compresses that question to a fixed-size digest that
can be compared in a handshake.
The fingerprint must be _order-insensitive_: a producer that declared
fields in declaration order `(foo, bar)` and a consumer that declared them
in the order `(bar, foo)` should fingerprint identically, because the wire
format itself is order-independent at the bit-packer level (object keys
participate, but their declaration order does not). Achieving that
requires canonical-serialisation, which this module owns.
## Features
- Default algorithm: `sha256` (32 bytes, cryptographically strong)
- Built-in alternatives: `xxhash32` (4 bytes, fast non-crypto), `crc32`
(4 bytes, fast non-crypto)
- Pluggable: register `blake3` (or any custom hash) via
`HashAlgorithmRegistry` and pass it as `registry` argument
- `assertSchemaFingerprint(schema, expected, ...)` is the single-call
handshake check; throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` with a
context that includes hex `expected` and `actual` for diagnosability
- Unknown algorithm name throws `CRYPTO_HASH_ALGORITHM_UNKNOWN`
- Functions inside the schema (e.g. `ConditionalSchema._if`) are rejected;
v1 fingerprintable schemas are data-shape only
- Constant-time digest comparison in `assertSchemaFingerprint` (so timing
side channels can't leak partial-match information)
- OTel span `bitpacker-crypto.fingerprint` with algorithm + payload_bytes
## Usage: Basic Example
```ts
import {
assertSchemaFingerprint,
schemaFingerprint,
} from "@suaveplan/bitpacker-crypto";
const schema = { foo: 255, bar: 65535 };
const fp = await schemaFingerprint(schema); // sha256 by default
// On the other side of a handshake, validate the schemas match:
await assertSchemaFingerprint(localSchema, fp);
```
## Usage: Advanced Example — registry-backed BLAKE3
```ts
import {
type HashAlgorithm,
createHashRegistry,
schemaFingerprint,
} from "@suaveplan/bitpacker-crypto";
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: (data) => Promise.resolve(nobleBlake3(data)),
};
const registry = createHashRegistry();
registry.register(blake3);
const fp = await schemaFingerprint(schema, "blake3", registry);
```
## API Reference
### `schemaFingerprint(schema, algorithm?, registry?): Promise<Uint8Array>`
- `schema: BitPackerSchema` — the schema to hash. Functions and
non-finite numbers are rejected.
- `algorithm: string = "sha256"` — name of a registered algorithm
- `registry: HashAlgorithmRegistry = defaultHashRegistry()` —
optional override
- **Throws** `CRYPTO_HASH_ALGORITHM_UNKNOWN` if the algorithm isn't
registered; `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` if the schema contains
unsupported value types (functions, NaN, Infinity).
### `assertSchemaFingerprint(schema, expected, algorithm?, registry?): Promise<void>`
- Computes the fingerprint and compares it byte-wise (constant-time) to
`expected`. Throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` on disagreement,
with `context.expected` and `context.actual` hex-encoded for
diagnosability.
### `canonicalize(schema): string`
Exposed for testing the canonical-serialisation invariant directly.
Returns the JSON-like canonical string (sorted keys, no whitespace).
## Implementation Notes
- The canonicalizer is recursive and supports: `null`, `undefined` (treated
as `null`), `number` (must be finite), `boolean`, `string`, `bigint`
(suffixed with `n`), `object` (keys sorted lexicographically), `array`
(order preserved). All other types throw `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH`.
- `bigint` values are serialised as `123n` (with the `n` suffix) to keep
them distinguishable from numbers in the canonical form. JSON does not
natively support bigint, so this is a deliberate extension.
- Constant-time comparison in `assertSchemaFingerprint` matters even though
fingerprints are not secret — the operation is cheap, and using a
constant-time check avoids becoming a habit-of-non-constant-time-compare
at higher layers.
- The OTel span carries `bitpacker.crypto.algorithm` and
`bitpacker.crypto.payload_bytes` so a deployment can observe schema
agreement frequency and the size of canonicalised schemas.
- Function-bearing schemas (`ConditionalSchema`, `VersionSchema`) are out
of scope for v1 fingerprinting because there is no canonical way to hash
a function. A future version can add a side-channel (e.g. a stable name
registry mapping function identity → string) if a real consumer needs it.

View File

@@ -0,0 +1,62 @@
import { BITPACKER_CODES, type BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import {
createHashRegistry,
type HashAlgorithm,
} from "../hash-registry/hash-registry.js";
import { schemaFingerprint } from "./schema-fingerprint.js";
describe("schema-fingerprint — spec conformance", () => {
it("emits fingerprint span with algorithm + payload_bytes attributes", {
timeout: 5000,
}, async () => {
await withRecordingTracer(async (session) => {
await schemaFingerprint({ x: 255, y: 65535 }, "sha256");
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.fingerprint");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.algorithm"]).toBe(
"sha256"
);
expect(
spans[0]?.attributes["bitpacker.crypto.payload_bytes"]
).toBeGreaterThan(0);
});
});
it("plugged-in BLAKE3 algorithm produces BLAKE3 fingerprint", {
timeout: 5000,
}, async () => {
const registry = createHashRegistry();
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: (data) => {
// Sentinel deterministic "blake3": prefix "B3:" then sha-like byte 0
const out = new Uint8Array(32);
out[0] = 0xb3;
out[1] = data.length & 0xff;
return Promise.resolve(out);
},
};
registry.register(blake3);
const fp = await schemaFingerprint({ x: 1 }, "blake3", registry);
expect(fp.length).toBe(32);
expect(fp[0]).toBe(0xb3);
});
it("unknown algorithm throws CRYPTO_HASH_ALGORITHM_UNKNOWN with context", {
timeout: 5000,
}, async () => {
try {
await schemaFingerprint({ x: 1 }, "nope-not-real");
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
expect(e.context?.["algorithm"]).toBe("nope-not-real");
}
});
});

View File

@@ -0,0 +1,156 @@
import { BITPACKER_CODES, type BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createHashRegistry,
type HashAlgorithm,
} from "../hash-registry/hash-registry.js";
import {
assertSchemaFingerprint,
canonicalize,
schemaFingerprint,
} from "./schema-fingerprint.js";
describe("schema-fingerprint — canonical serialisation", () => {
it("equal schemas in different declaration orders fingerprint identically", {
timeout: 5000,
}, async () => {
const a = { foo: 255, bar: 65535 };
const b = { bar: 65535, foo: 255 };
const fpA = await schemaFingerprint(a);
const fpB = await schemaFingerprint(b);
expect(fpA).toEqual(fpB);
});
it("sorts nested object keys deterministically", {
timeout: 5000,
}, () => {
const a = { z: { b: 1, a: 2 }, y: 3 };
const b = { y: 3, z: { a: 2, b: 1 } };
expect(canonicalize(a)).toBe(canonicalize(b));
});
it("preserves array order", { timeout: 5000 }, () => {
const a = [255, 65535, 7];
const b = [255, 65535, 7];
expect(canonicalize(a)).toBe(canonicalize(b));
// Different order should NOT collide
const c = [7, 255, 65535];
expect(canonicalize(a)).not.toBe(canonicalize(c));
});
it("rejects functions as part of the schema", { timeout: 5000 }, () => {
const schema = { _if: () => true, _then: 255 };
try {
canonicalize(
schema as unknown as Parameters<typeof canonicalize>[0]
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context).toMatchObject({ reason: "function-in-schema" });
}
});
it("rejects non-finite numbers", { timeout: 5000 }, () => {
try {
canonicalize(
Number.NaN as unknown as Parameters<typeof canonicalize>[0]
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context).toMatchObject({ reason: "non-finite-number" });
}
});
it("encodes booleans, null, and bigints uniquely", {
timeout: 5000,
}, () => {
expect(canonicalize(true)).toBe("true");
expect(canonicalize(false)).toBe("false");
expect(canonicalize(null)).toBe("null");
expect(canonicalize(255)).toBe("255");
expect(canonicalize("u8")).toBe('"u8"');
});
});
describe("schema-fingerprint — algorithm selection", () => {
it("default algorithm is sha256, output is 32 bytes", {
timeout: 5000,
}, async () => {
const fp = await schemaFingerprint({ foo: 255 });
expect(fp.length).toBe(32);
});
it("explicit xxhash32 is 4 bytes", { timeout: 5000 }, async () => {
const fp = await schemaFingerprint({ foo: 255 }, "xxhash32");
expect(fp.length).toBe(4);
});
it("explicit crc32 is 4 bytes", { timeout: 5000 }, async () => {
const fp = await schemaFingerprint({ foo: 255 }, "crc32");
expect(fp.length).toBe(4);
});
});
describe("schema-fingerprint — assertSchemaFingerprint", () => {
it("succeeds when fingerprints match", { timeout: 5000 }, async () => {
const schema = { x: 255, y: 65535 };
const expected = await schemaFingerprint(schema);
await assertSchemaFingerprint(schema, expected);
});
it("rejects on mismatch with CRYPTO_SCHEMA_FINGERPRINT_MISMATCH", {
timeout: 5000,
}, async () => {
const schema = { x: 255 };
const wrong = new Uint8Array(32);
try {
await assertSchemaFingerprint(schema, wrong);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH
);
expect(e.context?.["algorithm"]).toBe("sha256");
expect(typeof e.context?.["expected"]).toBe("string");
expect(typeof e.context?.["actual"]).toBe("string");
}
});
});
describe("schema-fingerprint — pluggable algorithm", () => {
it("uses a custom registry's algorithm", { timeout: 5000 }, async () => {
const registry = createHashRegistry();
const sentinel: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32).fill(0xab)),
};
registry.register(sentinel);
const fp = await schemaFingerprint({ x: 1 }, "blake3", registry);
expect(fp.length).toBe(32);
expect(fp[0]).toBe(0xab);
expect(fp[31]).toBe(0xab);
});
it("rejects unknown algorithm with CRYPTO_HASH_ALGORITHM_UNKNOWN", {
timeout: 5000,
}, async () => {
try {
await schemaFingerprint({ x: 1 }, "magic-hash");
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
expect(e.context?.["algorithm"]).toBe("magic-hash");
}
});
});

View File

@@ -0,0 +1,164 @@
import { trace } from "@opentelemetry/api";
import type { BitPackerSchema } from "@suaveplan/bitpacker";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import {
defaultHashRegistry,
type HashAlgorithmRegistry,
} from "../hash-registry/hash-registry.js";
const TRACER_NAME = "bitpacker-crypto";
const FINGERPRINT_SPAN = "bitpacker-crypto.fingerprint";
/**
* Compute a content-hash of the schema using the named algorithm. The
* fingerprint is computed over a canonical serialisation: object keys sorted,
* no whitespace, deterministic encoding. Equivalent schemas in different
* declaration orders produce byte-identical fingerprints.
*
* @param schema Bit-packer schema to fingerprint.
* @param algorithm Hash algorithm name registered in `registry`. Defaults
* to `"sha256"`.
* @param registry Optional hash-algorithm registry. Defaults to the
* process-wide singleton.
* @returns The digest bytes.
* @throws {BitpackerError} `CRYPTO_HASH_ALGORITHM_UNKNOWN` when the algorithm
* is not registered in the supplied registry.
*/
export async function schemaFingerprint(
schema: BitPackerSchema,
algorithm = "sha256",
registry: HashAlgorithmRegistry = defaultHashRegistry()
): Promise<Uint8Array> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(FINGERPRINT_SPAN, {
attributes: {
"bitpacker.crypto.algorithm": algorithm,
},
});
try {
const algo = registry.resolve(algorithm);
const canonical = canonicalize(schema);
const bytes = new TextEncoder().encode(canonical);
span.setAttribute("bitpacker.crypto.payload_bytes", bytes.length);
return await algo.hash(bytes);
} finally {
span.end();
}
}
/**
* Verify that `schema`'s fingerprint matches `expected`. Throws
* `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` on disagreement; the error context
* carries hex-encoded `expected` and `actual` digests so a mismatch is
* diagnosable from the error log alone.
*
* Intended for "do we agree on the schema?" handshakes between mesh peers,
* registry consistency checks, and pre-flight validation in any scenario
* where two sides need to be locked to byte-identical schemas before they
* can interpret a shared payload.
*/
export async function assertSchemaFingerprint(
schema: BitPackerSchema,
expected: Uint8Array,
algorithm = "sha256",
registry: HashAlgorithmRegistry = defaultHashRegistry()
): Promise<void> {
const actual = await schemaFingerprint(schema, algorithm, registry);
if (!bytesEqual(actual, expected)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
"Schema fingerprint mismatch",
{
context: {
algorithm,
expected: hex(expected),
actual: hex(actual),
},
}
);
}
}
/**
* Canonical serialisation of a `BitPackerSchema`. Strict JSON-like grammar
* with sorted object keys and no whitespace.
*
* Supported leaf types: `number`, `string`, `boolean`, `null`. Composite
* types: `object` (keys sorted lexicographically), `array` (order preserved).
* Functions on `_if` / `_then` / `_version` schemas are not serialisable —
* fingerprinting such schemas throws `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH`
* (with `context.reason = "function-in-schema"`); v1 deliberately scopes
* fingerprintable schemas to data-shape-only.
*/
export function canonicalize(schema: BitPackerSchema): string {
return canonicalizeImpl(schema);
}
function canonicalizeImpl(node: unknown): string {
if (node === null) return "null";
if (node === undefined) return "null";
const t = typeof node;
if (t === "number") {
if (!Number.isFinite(node as number)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
"Cannot fingerprint a non-finite number",
{ context: { reason: "non-finite-number" } }
);
}
return JSON.stringify(node);
}
if (t === "string" || t === "boolean") return JSON.stringify(node);
if (t === "bigint") return `${(node as bigint).toString()}n`;
if (t === "function") {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
"Cannot fingerprint a schema containing a function (e.g. ConditionalSchema)",
{ context: { reason: "function-in-schema" } }
);
}
if (Array.isArray(node)) {
let s = "[";
for (let i = 0; i < node.length; i++) {
if (i > 0) s += ",";
s += canonicalizeImpl(node[i]);
}
return `${s}]`;
}
if (t === "object") {
const obj = node as Record<string, unknown>;
const keys = Object.keys(obj).sort();
let s = "{";
for (let i = 0; i < keys.length; i++) {
const k = keys[i] as string;
if (i > 0) s += ",";
s += `${JSON.stringify(k)}:${canonicalizeImpl(obj[k])}`;
}
return `${s}}`;
}
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SCHEMA_FINGERPRINT_MISMATCH,
`Cannot canonicalize value of type ${t}`,
{ context: { reason: "unsupported-type", type: t } }
);
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
// Inputs are both sized by the same hash algorithm (caller-provided
// expected vs. computed actual), so the length-mismatch branch is
// unreachable in practice. Constant-time scan over the full length.
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= (a[i] as number) ^ (b[i] as number);
}
return diff === 0;
}
function hex(b: Uint8Array): string {
let s = "";
for (const x of b) {
s += (x as number).toString(16).padStart(2, "0");
}
return s;
}

View File

@@ -0,0 +1,119 @@
# HashAlgorithmRegistry
A pluggable registry of named hash algorithms used by every hashing-aware
primitive in `@suaveplan/bitpacker-crypto` (notably `SchemaFingerprint` and
`HashChainSchema`). Ships with three defaults out of the box: `sha256` (32
bytes, Web Crypto), `xxhash32` (4 bytes, in-tree), and `crc32` (4 bytes,
in-tree). Consumers register additional algorithms — e.g. `blake3` — via
`register`. Last-write-wins replacement is permitted and emits an OpenTelemetry
span so swap-ins are observable.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The crypto stack avoids hard-coding a single hash function inside every
primitive that needs one. Instead, hashing is a first-class extension point:
primitives accept an optional `algorithm` name and an optional registry, and
the registry resolves the name to a concrete `HashAlgorithm`. This lets a
deployment swap in a faster sha256 (e.g. native node:crypto) or register an
entirely new algorithm without forking the primitive.
The registry shape is intentionally tiny — `register`, `resolve`, `has`,
`list` — because the surface area is also a maintenance surface area. There is
no `unregister`: defaults are guaranteed present on every fresh registry, and
test isolation is achieved by calling `createHashRegistry()` to get a private
instance instead of mutating the singleton.
## Features
- Three defaults bundled: `sha256` (Web Crypto async), `xxhash32` (in-tree
sync), `crc32` (in-tree sync, big-endian 4-byte digest)
- Uniform async `hash(data) → Promise<Uint8Array>` interface across sync and
async backends
- `register` is last-write-wins; replacing an existing algorithm emits a
`bitpacker-crypto.hash-registry.replaced` OTel span carrying the previous
and new `outputBytes` attributes
- Process-wide singleton via `defaultHashRegistry()`; isolated registries via
`createHashRegistry()`
- Unknown algorithm name throws `BitpackerError` with code
`CRYPTO_HASH_ALGORITHM_UNKNOWN` and `context.algorithm` for diagnosability
## Usage: Basic Example
```ts
import { defaultHashRegistry } from "@suaveplan/bitpacker-crypto";
const sha256 = defaultHashRegistry().resolve("sha256");
const digest = await sha256.hash(new TextEncoder().encode("hello"));
console.log(digest.length); // 32
```
## Usage: Advanced Example — registering BLAKE3
```ts
import {
type HashAlgorithm,
createHashRegistry,
} from "@suaveplan/bitpacker-crypto";
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: (data) => Promise.resolve(nobleBlake3(data)),
};
// Build an isolated registry so we don't pollute the singleton.
const registry = createHashRegistry();
registry.register(blake3);
// Use with SchemaFingerprint / HashChainSchema:
// schemaFingerprint(schema, "blake3", registry)
```
## API Reference
### `interface HashAlgorithm`
- `name: string` — stable identifier used for registry lookup and wire-format
tagging
- `outputBytes: number` — exact digest length
- `hash(data: Uint8Array): Promise<Uint8Array>` — compute the digest;
resolves to exactly `outputBytes` bytes
### `interface HashAlgorithmRegistry`
- `register(algorithm: HashAlgorithm): void` — last-write-wins; emits
`bitpacker-crypto.hash-registry.replaced` span when overwriting
- `resolve(name: string): HashAlgorithm` — throws `CRYPTO_HASH_ALGORITHM_UNKNOWN`
on miss
- `has(name: string): boolean`
- `list(): readonly string[]` — names in stable sort order
### `createHashRegistry(): HashAlgorithmRegistry`
Fresh registry pre-populated with the defaults. Independent of the singleton;
mutations stay local.
### `defaultHashRegistry(): HashAlgorithmRegistry`
Process-wide singleton. Lazily constructed on first call. Subsequent calls
return the same instance.
## Implementation Notes
- The interface is asynchronous because `sha256` is implemented over Web
Crypto's `crypto.subtle.digest`, which returns a Promise. Synchronous
algorithms wrap their result with `Promise.resolve` for uniformity.
- `crc32` digests are emitted big-endian (most-significant byte first) so the
byte order matches the algorithm's natural representation; `xxhash32`
digests are emitted little-endian to match the bit-packed checksums already
produced by `appendXxhash32`.
- Replacement intentionally does not throw. The OTel `replaced` span is the
observability hook for swap-ins; tests asserting registry hygiene should
monitor the span stream rather than rely on exception flow.
- `unregister` is intentionally absent. If you need a clean slate, build a
fresh registry with `createHashRegistry()` and use that one.

View File

@@ -0,0 +1,77 @@
/**
* Spec-conformance test for `HashAlgorithmRegistry`. Pins the OTel
* replacement-event surface and singleton-isolation invariant from the
* `bitpacker-crypto-rewrite` spec.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import {
_resetDefaultHashRegistryForTesting,
createHashRegistry,
defaultHashRegistry,
type HashAlgorithm,
} from "./hash-registry.js";
describe("hash-registry — spec conformance", () => {
it("replacement emits OTel hash-registry.replaced span with attributes", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const reg = createHashRegistry();
const replacement: HashAlgorithm = {
name: "sha256",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32)),
};
reg.register(replacement);
const spans = session
.spansFor("bitpacker-crypto")
.filter(
(s) => s.name === "bitpacker-crypto.hash-registry.replaced"
);
expect(spans).toHaveLength(1);
const span = spans[0];
expect(span?.attributes["bitpacker.crypto.algorithm"]).toBe(
"sha256"
);
expect(
span?.attributes["bitpacker.crypto.previous_output_bytes"]
).toBe(32);
expect(span?.attributes["bitpacker.crypto.new_output_bytes"]).toBe(
32
);
expect(span?.ended).toBe(true);
});
});
it("registering a fresh name does NOT emit replaced span", {
timeout: 5000,
}, () => {
withRecordingTracer((session) => {
const reg = createHashRegistry();
reg.register({
name: "novel",
outputBytes: 8,
hash: () => Promise.resolve(new Uint8Array(8)),
});
const spans = session
.spansFor("bitpacker-crypto")
.filter(
(s) => s.name === "bitpacker-crypto.hash-registry.replaced"
);
expect(spans).toHaveLength(0);
});
});
it("createHashRegistry isolates from singleton", { timeout: 5000 }, () => {
_resetDefaultHashRegistryForTesting();
const fresh = createHashRegistry();
fresh.register({
name: "iso-test",
outputBytes: 1,
hash: () => Promise.resolve(new Uint8Array(1)),
});
expect(defaultHashRegistry().has("iso-test")).toBe(false);
});
});

View File

@@ -0,0 +1,156 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
_resetDefaultHashRegistryForTesting,
createHashRegistry,
defaultHashRegistry,
type HashAlgorithm,
} from "./hash-registry.js";
describe("hash-registry — defaults", () => {
it("default registry ships sha256, xxhash32, crc32", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
expect(reg.has("sha256")).toBe(true);
expect(reg.has("xxhash32")).toBe(true);
expect(reg.has("crc32")).toBe(true);
});
it("list returns names in stable sort order", { timeout: 5000 }, () => {
const reg = createHashRegistry();
expect(reg.list()).toEqual(["crc32", "sha256", "xxhash32"]);
});
it("each default has the correct outputBytes", { timeout: 5000 }, () => {
const reg = createHashRegistry();
expect(reg.resolve("sha256").outputBytes).toBe(32);
expect(reg.resolve("xxhash32").outputBytes).toBe(4);
expect(reg.resolve("crc32").outputBytes).toBe(4);
});
it("sha256 produces the canonical 32-byte digest of empty input", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const empty = await reg.resolve("sha256").hash(new Uint8Array());
expect(empty.length).toBe(32);
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
expect(empty[0]).toBe(0xe3);
expect(empty[31]).toBe(0x55);
});
it("xxhash32 hash is a 4-byte digest of the input", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const data = new TextEncoder().encode("hello");
const out = await reg.resolve("xxhash32").hash(data);
expect(out.length).toBe(4);
});
it("crc32 hash is a 4-byte big-endian digest of the input", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const data = new TextEncoder().encode("hello");
const out = await reg.resolve("crc32").hash(data);
expect(out.length).toBe(4);
// CRC32("hello") = 0x3610a686 — big-endian
expect(out[0]).toBe(0x36);
expect(out[1]).toBe(0x10);
expect(out[2]).toBe(0xa6);
expect(out[3]).toBe(0x86);
});
});
describe("hash-registry — register replaces", () => {
it("register replaces existing algorithm with last-write-wins", {
timeout: 5000,
}, async () => {
const reg = createHashRegistry();
const sentinel: HashAlgorithm = {
name: "sha256",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32).fill(0xab)),
};
reg.register(sentinel);
const out = await reg
.resolve("sha256")
.hash(new TextEncoder().encode("anything"));
expect(out[0]).toBe(0xab);
expect(out[31]).toBe(0xab);
});
it("registering a new algorithm exposes it for resolve/has", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32)),
};
reg.register(blake3);
expect(reg.has("blake3")).toBe(true);
expect(reg.resolve("blake3").outputBytes).toBe(32);
});
});
describe("hash-registry — resolve errors", () => {
it("resolve unknown algorithm throws CRYPTO_HASH_ALGORITHM_UNKNOWN", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
try {
reg.resolve("magic-hash");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(BitpackerError);
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
expect(e.context).toMatchObject({ algorithm: "magic-hash" });
}
});
it("error context includes the registered algorithm list", {
timeout: 5000,
}, () => {
const reg = createHashRegistry();
try {
reg.resolve("nope");
} catch (err) {
const e = err as BitpackerError;
expect(e.context?.["registered"]).toEqual([
"crc32",
"sha256",
"xxhash32",
]);
}
});
});
describe("hash-registry — singleton", () => {
it("defaultHashRegistry returns the same instance across calls", {
timeout: 5000,
}, () => {
_resetDefaultHashRegistryForTesting();
const a = defaultHashRegistry();
const b = defaultHashRegistry();
expect(a).toBe(b);
});
it("createHashRegistry isolates from singleton", { timeout: 5000 }, () => {
_resetDefaultHashRegistryForTesting();
const fresh = createHashRegistry();
const custom: HashAlgorithm = {
name: "custom-isolated",
outputBytes: 16,
hash: () => Promise.resolve(new Uint8Array(16)),
};
fresh.register(custom);
const singleton = defaultHashRegistry();
expect(singleton.list()).not.toContain("custom-isolated");
expect(fresh.list()).toContain("custom-isolated");
});
});

View File

@@ -0,0 +1,171 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { crc32 } from "../crc32.js";
import { calculateXxhash32, packXxhash32 } from "../xxhash32.js";
const TRACER_NAME = "bitpacker-crypto";
/**
* A registered hash algorithm. The same shape covers fast non-cryptographic
* checksums (xxhash32, crc32) and cryptographic hashes (sha256, blake3, …).
*
* The `hash` method is always asynchronous so the contract is uniform across
* sync (xxhash32) and async (Web Crypto sha256) backends. Implementations
* that compute synchronously simply wrap their result in `Promise.resolve`.
*/
export interface HashAlgorithm {
/** Stable identifier used for registry lookup and wire-format tagging. */
readonly name: string;
/** Output digest length in bytes. */
readonly outputBytes: number;
/** Compute the digest of `data`. Resolves to exactly `outputBytes` bytes. */
hash(data: Uint8Array): Promise<Uint8Array>;
}
/**
* A registry of named hash algorithms. The default registry ships with
* `sha256`, `xxhash32`, and `crc32`. Consumers register additional
* algorithms (e.g. `blake3`) via `register`. Last-write-wins replacement is
* permitted and emits an OTel `bitpacker-crypto.hash-registry.replaced`
* span so swap-ins are observable.
*
* `unregister` is intentionally absent on the default registry surface to
* prevent accidental removal of a built-in algorithm; isolated test
* registries can be created via `createHashRegistry()` and discarded.
*/
export interface HashAlgorithmRegistry {
register(algorithm: HashAlgorithm): void;
resolve(name: string): HashAlgorithm;
has(name: string): boolean;
list(): readonly string[];
}
class HashAlgorithmRegistryImpl implements HashAlgorithmRegistry {
readonly #map = new Map<string, HashAlgorithm>();
public register(algorithm: HashAlgorithm): void {
const previous = this.#map.get(algorithm.name);
if (previous !== undefined) {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(
"bitpacker-crypto.hash-registry.replaced",
{
attributes: {
"bitpacker.crypto.algorithm": algorithm.name,
"bitpacker.crypto.previous_output_bytes":
previous.outputBytes,
"bitpacker.crypto.new_output_bytes":
algorithm.outputBytes,
},
}
);
span.end();
}
this.#map.set(algorithm.name, algorithm);
}
public resolve(name: string): HashAlgorithm {
const algorithm = this.#map.get(name);
if (algorithm === undefined) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN,
`Unknown hash algorithm: ${name}`,
{ context: { algorithm: name, registered: this.list() } }
);
}
return algorithm;
}
public has(name: string): boolean {
return this.#map.has(name);
}
public list(): readonly string[] {
return [...this.#map.keys()].sort();
}
}
/**
* SHA-256 backed by `globalThis.crypto.subtle.digest`. Throws via a
* lazily-evaluated message rather than at module load so an environment
* without Web Crypto can still import the registry; the failure surfaces
* the first time an algorithm consumer actually calls `hash`.
*/
const SHA256: HashAlgorithm = {
name: "sha256",
outputBytes: 32,
async hash(data: Uint8Array): Promise<Uint8Array> {
const subtle = globalThis.crypto?.subtle;
if (subtle === undefined) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_RNG_UNAVAILABLE,
"globalThis.crypto.subtle is not available in this environment",
{ context: { algorithm: "sha256" } }
);
}
const buffer = await subtle.digest(
"SHA-256",
data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength
) as ArrayBuffer
);
return new Uint8Array(buffer);
},
};
const XXHASH32: HashAlgorithm = {
name: "xxhash32",
outputBytes: 4,
hash(data: Uint8Array): Promise<Uint8Array> {
return Promise.resolve(packXxhash32(calculateXxhash32(data)));
},
};
const CRC32: HashAlgorithm = {
name: "crc32",
outputBytes: 4,
hash(data: Uint8Array): Promise<Uint8Array> {
const checksum = crc32(data);
const out = new Uint8Array(4);
out[0] = (checksum >>> 24) & 0xff;
out[1] = (checksum >>> 16) & 0xff;
out[2] = (checksum >>> 8) & 0xff;
out[3] = checksum & 0xff;
return Promise.resolve(out);
},
};
/**
* Create a fresh, isolated registry pre-populated with the default
* algorithms (`sha256`, `xxhash32`, `crc32`). The returned registry is
* independent of the process-wide singleton; mutations do not leak across.
*/
export function createHashRegistry(): HashAlgorithmRegistry {
const registry = new HashAlgorithmRegistryImpl();
registry.register(SHA256);
registry.register(XXHASH32);
registry.register(CRC32);
return registry;
}
let DEFAULT_REGISTRY: HashAlgorithmRegistry | undefined;
/**
* Process-wide singleton registry. Lazily constructed on first call so
* importing this module has no side effects until the registry is actually
* needed. Subsequent calls return the same instance.
*/
export function defaultHashRegistry(): HashAlgorithmRegistry {
if (DEFAULT_REGISTRY === undefined) {
DEFAULT_REGISTRY = createHashRegistry();
}
return DEFAULT_REGISTRY;
}
/** Test-only: reset the singleton so a fresh default registry is built next call. */
export function _resetDefaultHashRegistryForTesting(): void {
DEFAULT_REGISTRY = undefined;
}
export { CRC32, SHA256, XXHASH32 };

View File

@@ -0,0 +1,175 @@
# HashChainSchema
Append-only signed hash chain. Each entry carries a sequence number, the
hash of the previous entry, an opaque payload, and a host signature over
the bit-packed `(seq, prevHash, payload)` triplet. Tampering anywhere in
the chain is detected at the first bad index by `verifyChain`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Audit logs, replicated mesh state, and any "I cannot edit history,
only append to it" scenario need a chain shape: each new entry binds to
the previous via a hash, and the host signs the binding so a third party
can verify the chain wasn't forged. suaveplan has the building blocks —
`Authority`, `DomainTag`, `HashAlgorithmRegistry`, `BodyCodec` — but no
primitive that composes them into a chain. `HashChainSchema` is that
primitive.
The signing input is `(seq, prevHash, payload)`, NOT just `payload`. This
is load-bearing: a signature on a payload alone could be lifted into a
different position in the chain (different seq) or grafted onto a different
prev-link without invalidating the signature. By binding seq + prev-hash
into the signed bytes, the signature is non-portable across positions.
## Features
- `appendEntry(seq, prevHash, payload, authority)` — sign the triplet,
return a complete `HashChainEntry`
- `verifyEntry(entry, expectedPrevHash, hostPublicSlot)` — boolean
result, never throws on bad signature (callers may probe candidate
parents)
- `verifyChain(entries, hostPublicSlot)` — walks the chain in order;
returns `{valid: false, firstBadIndex}` on the first failure
- `packEntry(entry)` / `unpackEntry(bytes)` — wire-format codec for
storage / network transport
- `hashEntry(entry)` — compute the entry's digest, used as the next
entry's `prevHash`
- Pluggable hash algorithm via `HashAlgorithmRegistry` (default
`sha256` 32 bytes)
- Strict prev-hash length check at append time (must match algorithm's
`outputBytes`) — catches schema/algorithm mismatches before they
reach the wire
- AbortSignal threaded through `Authority.sign`
- OTel spans `bitpacker-crypto.hashchain-append` and
`bitpacker-crypto.hashchain-verify`
## Wire Format
```text
┌────────────┬────────────────────┬──────────────────────┬──────────────────┐
│ seq u64BE │ prevHash N bytes │ payload bit-packed │ signature 64 B │
└────────────┴────────────────────┴──────────────────────┴──────────────────┘
```
`N` is the chosen hash algorithm's `outputBytes` (32 for sha256). The
signature is 64 bytes (ed25519); other lengths are configurable via
`signatureLength`.
## Usage: Basic Example
```ts
import { defineHashChainSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { DOMAIN_BITPACKER_HASHCHAIN_V1 } from "@suaveplan/crypto/domain-tags";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
interface AuditPayload { actorId: number; eventCode: number }
const auditSchema = defineBitSchema<AuditPayload>([
{ name: "actorId", type: "uint", bits: 32 },
{ name: "eventCode", type: "uint", bits: 16 },
]);
const audit = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: verifyAuthoritySignature,
payloadCodec: {
pack: (b) => auditSchema.pack(b),
unpack: (bytes) => auditSchema.unpack(bytes),
},
});
let prevHash = new Uint8Array(audit.hashLength); // suaveplan sentinel
const e0 = await audit.appendEntry(0n, prevHash, { actorId: 1, eventCode: 100 }, auth);
prevHash = await audit.hashEntry(e0);
const e1 = await audit.appendEntry(1n, prevHash, { actorId: 1, eventCode: 200 }, auth);
const result = await audit.verifyChain([e0, e1], hostPublicSlot);
// → { valid: true }
```
## Usage: Advanced Example — BLAKE3-backed chain
```ts
import {
type HashAlgorithm,
createHashRegistry,
defineHashChainSchema,
} from "@suaveplan/bitpacker-crypto";
import { blake3 as nobleBlake3 } from "@noble/hashes/blake3";
const registry = createHashRegistry();
registry.register({
name: "blake3",
outputBytes: 32,
hash: (data) => Promise.resolve(nobleBlake3(data)),
});
const chain = defineHashChainSchema({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
payloadCodec,
hashAlgorithm: "blake3",
hashRegistry: registry,
});
```
## API Reference
### `interface HashChainEntry<TPayload>`
- `seq: bigint` — non-negative sequence number, packed as u64 BE
- `prevHash: Uint8Array` — exactly `hashLength` bytes
- `payload: TPayload` — caller-typed body
- `hostSignature: Uint8Array` — exactly `signatureLength` bytes
### `interface HashChainSchemaOptions<TPayload>`
- `domain: DomainTag` — required
- `verifier: AuthorityVerifier` — required (typically
`verifyAuthoritySignature` from `@suaveplan/crypto/authority`)
- `slot?: SlotId = "sign"` — slot used by the host authority
- `payloadCodec: BodyCodec<TPayload>` — required
- `hashAlgorithm?: string = "sha256"` — must be registered
- `hashRegistry?: HashAlgorithmRegistry` — defaults to singleton
- `signatureLength?: number = 64`
### `defineHashChainSchema<TPayload>(options): HashChainSchema<TPayload>`
Returns:
- `hashEntry(entry): Promise<Uint8Array>` — `outputBytes`-byte digest
- `appendEntry(seq, prevHash, payload, authority, signal?): Promise<HashChainEntry<TPayload>>`
- throws `VALUE_OUT_OF_RANGE` if seq is negative or > 2^64-1
- throws `CRYPTO_KEY_INVALID` if prevHash length ≠ hashLength
- throws `CRYPTO_SIGN_FAILED` if signature length ≠ signatureLength
- `verifyEntry(entry, expectedPrevHash, hostPublicSlot): Promise<boolean>`
- `verifyChain(entries, hostPublicSlot): Promise<ChainVerifyResult>`
- `packEntry(entry): Uint8Array`
- `unpackEntry(bytes): HashChainEntry<TPayload>`
- throws `CRYPTO_VERIFY_FAILED` if bytes too short
## Implementation Notes
- The suaveplan entry's `prevHash` is caller-supplied — typically all-zero
bytes, but any deterministic value works as long as both producers and
consumers agree on it. `verifyChain` treats the first entry's
`prevHash` as the trust anchor (it is not derived from anything).
- Tamper detection is positional: `firstBadIndex` is the LOWEST index at
which either the prev-hash linkage or the signature failed. A chain
with multiple tamper points reports only the first; a strict caller
who needs to know every bad index should iterate `verifyEntry`
directly.
- The hash algorithm is resolved once at construction. Swapping the
registry's algorithm later does not affect already-built schemas. This
is deliberate: the chain's wire format is fixed at construction.
- `appendEntry` does NOT compute the new `prevHash` — it only appends.
Callers are responsible for calling `hashEntry(prev)` between appends.
This separation allows callers to batch-append from a known
starting hash without forcing every append to be sequential.
- `verifyChain` rebuilds the `expectedPrev` for index `i ≥ 1` by hashing
`entries[i-1]`. The suaveplan entry's `prevHash` is taken as the trust
anchor; if it does not match what the producer supplied, every chain
built atop it will fail at `firstBadIndex: 0`.

View File

@@ -0,0 +1,298 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import { DOMAIN_BITPACKER_HASHCHAIN_V1 } from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
createHashRegistry,
type HashAlgorithm,
} from "../hash-registry/hash-registry.js";
import {
defineHashChainSchema,
type HashChainEntry,
} from "./hashchain-schema.js";
interface AuditPayload {
actorId: number;
eventCode: number;
}
const AUDIT_CODEC: BodyCodec<AuditPayload> = (() => {
const schema = defineBitSchema<AuditPayload>([
{ name: "actorId", type: "uint", bits: 32 },
{ name: "eventCode", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("hashchain-schema — append + verify happy path", () => {
it("append produces signed entry with prev-linkage", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const genesisHash = new Uint8Array(32);
const entry = await chain.appendEntry(
0n,
genesisHash,
{ actorId: 1, eventCode: 100 },
authority
);
expect(entry.seq).toBe(0n);
expect(entry.payload.actorId).toBe(1);
expect(entry.hostSignature.length).toBe(64);
expect(await chain.verifyEntry(entry, genesisHash, publicSlot)).toBe(
true
);
});
it("packEntry/unpackEntry round-trip preserves shape", {
timeout: 10_000,
}, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entry = await chain.appendEntry(
5n,
new Uint8Array(32),
{ actorId: 9, eventCode: 7 },
authority
);
const bytes = chain.packEntry(entry);
const decoded = chain.unpackEntry(bytes);
expect(decoded.seq).toBe(5n);
expect(decoded.payload).toEqual({ actorId: 9, eventCode: 7 });
expect(Array.from(decoded.prevHash)).toEqual(
Array.from(entry.prevHash)
);
expect(Array.from(decoded.hostSignature)).toEqual(
Array.from(entry.hostSignature)
);
});
});
async function buildChain(
chain: ReturnType<typeof defineHashChainSchema<AuditPayload>>,
authority: Parameters<typeof chain.appendEntry>[3],
n: number
): Promise<HashChainEntry<AuditPayload>[]> {
const entries: HashChainEntry<AuditPayload>[] = [];
let prevHash = new Uint8Array(chain.hashLength);
for (let i = 0; i < n; i++) {
const entry = await chain.appendEntry(
BigInt(i),
prevHash,
{ actorId: i, eventCode: 1000 + i },
authority
);
entries.push(entry);
prevHash = await chain.hashEntry(entry);
}
return entries;
}
describe("hashchain-schema — chain verification", () => {
it("verifyChain succeeds on a clean chain", {
timeout: 15_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entries = await buildChain(chain, authority, 5);
const result = await chain.verifyChain(entries, publicSlot);
expect(result.valid).toBe(true);
expect(result.firstBadIndex).toBeUndefined();
});
it("chain verification detects payload tamper at exact index", {
timeout: 15_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entries = await buildChain(chain, authority, 5);
// Mutate entry-2's payload (signature won't match anymore)
const tampered: HashChainEntry<AuditPayload>[] = entries.map((e, i) =>
i === 2 ? { ...e, payload: { ...e.payload, actorId: 999 } } : e
);
const result = await chain.verifyChain(tampered, publicSlot);
expect(result.valid).toBe(false);
expect(result.firstBadIndex).toBe(2);
});
it("chain verification detects prev-hash break", {
timeout: 15_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
const entries = await buildChain(chain, authority, 5);
// Mutate entry-3's prevHash to wrong bytes — this also invalidates
// entry-3's signature (it was signed with the correct prevHash)
const wrongPrev = new Uint8Array(32);
wrongPrev[0] = 0xff;
const tampered = entries.map((e, i) =>
i === 3 ? { ...e, prevHash: wrongPrev } : e
);
const result = await chain.verifyChain(tampered, publicSlot);
expect(result.valid).toBe(false);
expect(result.firstBadIndex).toBe(3);
});
});
describe("hashchain-schema — pluggable hash algorithm", () => {
it("uses a custom algorithm registered in a custom registry", {
timeout: 10_000,
}, async () => {
const registry = createHashRegistry();
const blake3: HashAlgorithm = {
name: "blake3",
outputBytes: 32,
hash: () => Promise.resolve(new Uint8Array(32).fill(0xb3)),
};
registry.register(blake3);
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
hashAlgorithm: "blake3",
hashRegistry: registry,
});
const { authority } = createFakeAuthority();
const entry = await chain.appendEntry(
0n,
new Uint8Array(32),
{ actorId: 1, eventCode: 1 },
authority
);
const digest = await chain.hashEntry(entry);
expect(digest.length).toBe(32);
expect(digest[0]).toBe(0xb3);
});
it("rejects unknown algorithm at construction time", {
timeout: 5000,
}, () => {
try {
defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
hashAlgorithm: "magic-not-real",
});
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_HASH_ALGORITHM_UNKNOWN);
}
});
});
describe("hashchain-schema — input validation", () => {
it("rejects negative seq", { timeout: 5000 }, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
try {
await chain.appendEntry(
-1n,
new Uint8Array(32),
{ actorId: 0, eventCode: 0 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.VALUE_OUT_OF_RANGE);
}
});
it("rejects prevHash of wrong length", {
timeout: 5000,
}, async () => {
const { authority } = createFakeAuthority();
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
try {
await chain.appendEntry(
0n,
new Uint8Array(16), // sha256 needs 32
{ actorId: 0, eventCode: 0 },
authority
);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_KEY_INVALID);
}
});
it("unpackEntry rejects bytes too short", { timeout: 5000 }, () => {
const chain = defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
});
try {
chain.unpackEntry(new Uint8Array(10));
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
it("missing verifier rejects at construction time", {
timeout: 5000,
}, () => {
expect(() =>
// @ts-expect-error — verifier is required
defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
payloadCodec: AUDIT_CODEC,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineHashChainSchema<AuditPayload>({
domain: DOMAIN_BITPACKER_HASHCHAIN_V1,
verifier: cryptoVerifier,
payloadCodec: AUDIT_CODEC,
// @ts-expect-error — unknown
bogus: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,368 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
Authority,
AuthorityVerifier,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
defaultHashRegistry,
type HashAlgorithmRegistry,
} from "../hash-registry/hash-registry.js";
import {
DEFAULT_SIGN_SLOT,
DEFAULT_SIGNATURE_LENGTH,
} from "../signed/signed-bitschema.js";
const TRACER_NAME = "bitpacker-crypto";
const APPEND_SPAN = "bitpacker-crypto.hashchain-append";
const VERIFY_SPAN = "bitpacker-crypto.hashchain-verify";
const SEQ_BYTES = 8;
/**
* One node in an append-only signed hash chain. The wire layout is:
*
* `[seq u64 BE][prevHash N bytes][payload bit-packed][hostSignature 64 bytes]`
*
* The signature is computed over the bit-packed `(seq, prevHash, payload)`
* triplet under the schema's `DomainTag`, so a signature produced under
* one chain identity cannot be replayed inside another chain even if the
* payload bytes happen to coincide.
*/
export interface HashChainEntry<TPayload> {
readonly seq: bigint;
readonly prevHash: Uint8Array;
readonly payload: TPayload;
readonly hostSignature: Uint8Array;
}
/** Result of a chain-walk verify. */
export interface ChainVerifyResult {
readonly valid: boolean;
readonly firstBadIndex?: number;
}
export interface HashChainSchemaOptions<TPayload> {
readonly domain: DomainTag;
readonly verifier: AuthorityVerifier;
readonly slot?: SlotId;
readonly payloadCodec: BodyCodec<TPayload>;
readonly hashAlgorithm?: string;
readonly hashRegistry?: HashAlgorithmRegistry;
readonly signatureLength?: number;
}
export interface HashChainSchema<TPayload> {
/** Compute the digest of a packed entry — used to produce next entry's prevHash. */
hashEntry(entry: HashChainEntry<TPayload>): Promise<Uint8Array>;
/** Append: sign the (seq, prevHash, payload) triplet and produce the new entry. */
appendEntry(
seq: bigint,
prevHash: Uint8Array,
payload: TPayload,
authority: Authority,
signal?: AbortSignal
): Promise<HashChainEntry<TPayload>>;
/** Verify a single entry: signature over (seq, prevHash, payload) + prevHash matches. */
verifyEntry(
entry: HashChainEntry<TPayload>,
expectedPrevHash: Uint8Array,
hostPublicSlot: KeySlotPublic<unknown>
): Promise<boolean>;
/** Walk the chain in order — pinpoints first bad index on failure. */
verifyChain(
entries: readonly HashChainEntry<TPayload>[],
hostPublicSlot: KeySlotPublic<unknown>
): Promise<ChainVerifyResult>;
/** Encode an entry to bytes — `seq u64 ‖ prevHash ‖ payload ‖ signature`. */
packEntry(entry: HashChainEntry<TPayload>): Uint8Array;
/** Decode an entry from bytes. */
unpackEntry(bytes: Uint8Array): HashChainEntry<TPayload>;
readonly domain: DomainTag;
readonly slot: SlotId;
readonly hashAlgorithm: string;
readonly hashLength: number;
readonly signatureLength: number;
}
const OptionsRuntimeSchema = z
.object({
domain: z.string(),
verifier: z.function(),
slot: z.string().optional(),
payloadCodec: z.object({
pack: z.function(),
unpack: z.function(),
}),
hashAlgorithm: z.string().optional(),
hashRegistry: z.unknown().optional(),
signatureLength: z.number().int().min(8).max(256).optional(),
})
.strict();
/** u64 big-endian encode a `bigint`. */
function packU64BE(value: bigint): Uint8Array {
if (value < 0n) {
throw new BitpackerError(
BITPACKER_CODES.VALUE_OUT_OF_RANGE,
"HashChainEntry seq must be non-negative",
{ context: { seq: value.toString() } }
);
}
const out = new Uint8Array(SEQ_BYTES);
for (let i = 7; i >= 0; i--) {
out[i] = Number(value & 0xffn);
value >>= 8n;
}
if (value !== 0n) {
throw new BitpackerError(
BITPACKER_CODES.VALUE_OUT_OF_RANGE,
"HashChainEntry seq exceeds 2^64-1",
{ context: { seq: "u64-overflow" } }
);
}
return out;
}
/** u64 big-endian decode to a `bigint`. */
function unpackU64BE(bytes: Uint8Array, offset: number): bigint {
// Caller (`unpackEntry`) pre-validates `bytes.length`, so the indexed
// accesses are always defined. Cast through `as number` to keep this
// branch-free.
let v = 0n;
for (let i = 0; i < 8; i++) {
v = (v << 8n) | BigInt(bytes[offset + i] as number);
}
return v;
}
export function defineHashChainSchema<TPayload>(
options: HashChainSchemaOptions<TPayload>
): HashChainSchema<TPayload> {
OptionsRuntimeSchema.parse(options);
const domain = options.domain;
const verifier = options.verifier;
const slot = options.slot ?? DEFAULT_SIGN_SLOT;
const codec = options.payloadCodec;
const hashAlgorithm = options.hashAlgorithm ?? "sha256";
const registry = options.hashRegistry ?? defaultHashRegistry();
const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH;
// Resolve once at construction so a missing algorithm errors early.
const hashAlgo = registry.resolve(hashAlgorithm);
const hashLength = hashAlgo.outputBytes;
function buildSigningPayload(
seq: bigint,
prevHash: Uint8Array,
payload: TPayload
): Uint8Array {
if (prevHash.length !== hashLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_KEY_INVALID,
`prevHash length mismatch: got ${prevHash.length}, expected ${hashLength}`,
{
context: {
actualLength: prevHash.length,
expectedLength: hashLength,
},
}
);
}
const seqBytes = packU64BE(seq);
const payloadBytes = codec.pack(payload);
const out = new Uint8Array(
seqBytes.length + prevHash.length + payloadBytes.length
);
out.set(seqBytes, 0);
out.set(prevHash, seqBytes.length);
out.set(payloadBytes, seqBytes.length + prevHash.length);
return out;
}
function packEntry(entry: HashChainEntry<TPayload>): Uint8Array {
if (entry.hostSignature.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Entry signature length mismatch: got ${entry.hostSignature.length}, expected ${signatureLength}`,
{
context: {
actualLength: entry.hostSignature.length,
expectedLength: signatureLength,
},
}
);
}
const signed = buildSigningPayload(
entry.seq,
entry.prevHash,
entry.payload
);
const out = new Uint8Array(signed.length + signatureLength);
out.set(signed, 0);
out.set(entry.hostSignature, signed.length);
return out;
}
function unpackEntry(bytes: Uint8Array): HashChainEntry<TPayload> {
if (bytes.length < SEQ_BYTES + hashLength + signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
`HashChain entry bytes too short: got ${bytes.length}, expected ≥ ${SEQ_BYTES + hashLength + signatureLength}`,
{
context: {
actualLength: bytes.length,
minLength: SEQ_BYTES + hashLength + signatureLength,
},
}
);
}
const seq = unpackU64BE(bytes, 0);
const prevHash = bytes.slice(SEQ_BYTES, SEQ_BYTES + hashLength);
const sigStart = bytes.length - signatureLength;
const payloadBytes = bytes.slice(SEQ_BYTES + hashLength, sigStart);
const hostSignature = bytes.slice(sigStart);
const payload = codec.unpack(payloadBytes);
return { seq, prevHash, payload, hostSignature };
}
async function hashEntry(
entry: HashChainEntry<TPayload>
): Promise<Uint8Array> {
const packed = packEntry(entry);
return hashAlgo.hash(packed);
}
async function appendEntry(
seq: bigint,
prevHash: Uint8Array,
payload: TPayload,
authority: Authority,
signal?: AbortSignal
): Promise<HashChainEntry<TPayload>> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(APPEND_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.algorithm": hashAlgorithm,
"bitpacker.crypto.seq": String(seq),
},
});
try {
const signedPayload = buildSigningPayload(seq, prevHash, payload);
const signature = await authority.sign(
domain,
slot,
signedPayload,
signal
);
if (signature.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Authority returned signature of unexpected length: got ${signature.length}, expected ${signatureLength}`,
{
context: {
actualLength: signature.length,
expectedLength: signatureLength,
},
}
);
}
return {
seq,
prevHash: new Uint8Array(prevHash),
payload,
hostSignature: signature,
};
} finally {
span.end();
}
}
async function verifyEntry(
entry: HashChainEntry<TPayload>,
expectedPrevHash: Uint8Array,
hostPublicSlot: KeySlotPublic<unknown>
): Promise<boolean> {
if (!bytesEqual(entry.prevHash, expectedPrevHash)) {
return false;
}
const signedPayload = buildSigningPayload(
entry.seq,
entry.prevHash,
entry.payload
);
try {
return verifier(
domain,
hostPublicSlot,
signedPayload,
entry.hostSignature
);
} catch {
return false;
}
}
async function verifyChain(
entries: readonly HashChainEntry<TPayload>[],
hostPublicSlot: KeySlotPublic<unknown>
): Promise<ChainVerifyResult> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(VERIFY_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.algorithm": hashAlgorithm,
"bitpacker.crypto.entry_count": entries.length,
},
});
try {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i] as HashChainEntry<TPayload>;
const expectedPrev =
i === 0
? entry.prevHash // genesis entry — caller-supplied hash is the trust anchor
: await hashEntry(
entries[i - 1] as HashChainEntry<TPayload>
);
const ok = await verifyEntry(
entry,
expectedPrev,
hostPublicSlot
);
if (!ok) {
return { valid: false, firstBadIndex: i };
}
}
return { valid: true };
} finally {
span.end();
}
}
return {
hashEntry,
appendEntry,
verifyEntry,
verifyChain,
packEntry,
unpackEntry,
domain,
slot,
hashAlgorithm,
hashLength,
signatureLength,
};
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-crypto/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,91 @@
/**
* `@suaveplan/bitpacker-crypto` — bit-packer-aware crypto primitives.
*
* Public surface (consumer-driven design — see SUPERSEDED.md and the
* `bitpacker-crypto-rewrite` openspec change):
*
* - **Checksums** (existing, kept verbatim): crc32, adler32, xxhash32
* - **HashAlgorithmRegistry** (pluggable extension point — sha256/xxhash32/crc32 default)
* - **EnvelopeSchema** (typed `[magic][formatVersion][body]` envelope)
* - **SignedBitSchema** (Authority+DomainTag-bound single-signer)
* - **MultiSignedBitSchema** (N-of-N co-signed)
* - **HashChainSchema** (append-only signed chain)
* - **EncryptedBitSchema** (niche AEAD-only — small payloads, NOT chunked-file)
* - **SchemaFingerprint** (canonical-hash schema agreement)
*
* The upstream `bitpacker/src/crypto/` provider/registry abstraction is
* intentionally *not* ported; see `SUPERSEDED.md` for the per-symbol map.
*/
// Checksums
export {
adler32,
appendAdler32,
appendCrc32,
crc32,
verifyAndStripAdler32,
verifyAndStripCrc32,
} from "./crc32.js";
// Encrypted (niche AEAD)
export {
defineEncryptedBitSchema,
ENCRYPTED_ALG_TAGS,
ENCRYPTED_VERSION_V1,
type EncryptedAlgorithmTag,
type EncryptedBitSchema,
type EncryptedBitSchemaOptions,
} from "./encrypted/encrypted-bitschema.js";
// Envelope
export {
type BodyCodec,
defineEnvelopeSchema,
type EnvelopeSchema,
type EnvelopeSchemaOptions,
type ParsedEnvelope,
rawBytesCodec,
} from "./envelope/envelope-schema.js";
// Schema fingerprint
export {
assertSchemaFingerprint,
canonicalize as canonicalizeSchema,
schemaFingerprint,
} from "./fingerprint/schema-fingerprint.js";
// Pluggable hash registry
export {
createHashRegistry,
defaultHashRegistry,
type HashAlgorithm,
type HashAlgorithmRegistry,
} from "./hash-registry/hash-registry.js";
// Hashchain
export {
type ChainVerifyResult,
defineHashChainSchema,
type HashChainEntry,
type HashChainSchema,
type HashChainSchemaOptions,
} from "./hashchain/hashchain-schema.js";
// Multi-signed
export {
defineMultiSignedBitSchema,
type MultiSignedBitSchema,
type MultiSignedBitSchemaOptions,
type MultiVerifyResult,
} from "./multisigned/multisigned-bitschema.js";
// Signed
export {
DEFAULT_SIGN_SLOT,
DEFAULT_SIGNATURE_LENGTH,
defineSignedBitSchema,
type SignedBitSchema,
type SignedBitSchemaOptions,
} from "./signed/signed-bitschema.js";
export {
appendXxhash32,
calculateXxhash32,
packXxhash32,
unpackXxhash32,
verifyAndStripXxhash32,
verifyXxhash32,
} from "./xxhash32.js";

View File

@@ -0,0 +1,131 @@
# MultiSignedBitSchema
Bit-packed payload co-signed by N independent authorities. Wire format is
`[bit-packed body][sig_1][sig_2]…[sig_N]`. Pinpoints which signer failed
on verify so a partial-trust caller can recover gracefully.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Some suaveplan surfaces require multiple signatures to authorise a single
payload — multi-party transaction approval, threshold-equivalent ceremony
records, and any quorum-style action. Building this on top of single-signer
`SignedBitSchema` would force every callsite to roll its own concatenation
and per-signer verify loop. `MultiSignedBitSchema` consolidates the
pattern with strict count enforcement (a 3-signer schema cannot pack with
2 authorities) and with `firstBadSignerIndex` reporting on verify so the
error can carry the actionable detail of _which_ signer failed.
## Features
- N-of-N co-signing with `signerCount` baked into the schema
- Pack rejects wrong-count authority arrays with
`CRYPTO_MULTISIGN_COUNT_MISMATCH`
- Unpack rejects wrong-count public-slot arrays with
`CRYPTO_MULTISIGN_COUNT_MISMATCH`
- `verify` returns `{ valid, firstBadSignerIndex? }` so callers can
enumerate "which signature broke?" without exception flow
- `unpack` throws `CRYPTO_VERIFY_FAILED` whose context includes
`firstBadSignerIndex`
- Each authority signs the same payload bytes under the same domain;
signature ordering follows the authority list
- AbortSignal threaded through every `Authority.sign` call so a slow
signer can be cancelled
- Pluggable body codec (same `BodyCodec<T>` as `SignedBitSchema`)
- OTel spans `bitpacker-crypto.multisigned-pack` and
`bitpacker-crypto.multisigned-verify` with signer_count + payload_bytes
## Usage: Basic Example
```ts
import { defineMultiSignedBitSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { DOMAIN_BITPACKER_MULTISIGN_V1 } from "@suaveplan/crypto/domain-tags";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
interface VoteBody { proposalId: number; vote: number }
const voteSchema = defineBitSchema<VoteBody>([
{ name: "proposalId", type: "uint", bits: 32 },
{ name: "vote", type: "uint", bits: 1 },
]);
const ms = defineMultiSignedBitSchema<VoteBody>(
{
pack: (b) => voteSchema.pack(b),
unpack: (bytes) => voteSchema.unpack(bytes),
},
{
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: verifyAuthoritySignature,
signerCount: 3,
}
);
const packed = await ms.pack(
{ proposalId: 7, vote: 1 },
[authA, authB, authC]
);
const decoded = ms.unpack(packed, [pubA, pubB, pubC]);
```
## Usage: Advanced Example — graceful partial-trust recovery
```ts
const result = ms.verify(bytes, [pubA, pubB, pubC]);
if (!result.valid) {
// Tell the user EXACTLY which signer rejected the payload.
const failingSigner = signerNames[result.firstBadSignerIndex!];
throw new Error(`Quorum verify failed at signer "${failingSigner}"`);
}
```
## API Reference
### `interface MultiVerifyResult`
- `valid: boolean`
- `firstBadSignerIndex?: number` — present when `valid` is false; zero-indexed
### `interface MultiSignedBitSchemaOptions`
- `domain: DomainTag` — required
- `verifier: AuthorityVerifier` — required (typically
`verifyAuthoritySignature` from `@suaveplan/crypto/authority`)
- `slot?: SlotId = "sign"` — slot used by every authority
- `signerCount: number` — required, range 1..64
- `signatureLength?: number = 64` — wire-format signature length
### `defineMultiSignedBitSchema<T>(bodyCodec, options): MultiSignedBitSchema<T>`
Returns:
- `pack(data: T, authorities: readonly Authority[], signal?): Promise<Uint8Array>`
- `unpack(bytes: Uint8Array, signerPublicSlots: readonly KeySlotPublic<unknown>[]): T`
- `verify(bytes: Uint8Array, signerPublicSlots: readonly KeySlotPublic<unknown>[]): MultiVerifyResult`
- `domain`, `slot`, `signerCount`, `signatureLength`
## Implementation Notes
- Signatures are appended in authority list order and verified in the
same order. Re-ordering the public slot list at verify time will fail
every signature (ed25519 binds key to signature). This is intentional —
the wire format does not permute, so the schema's contract is
positional.
- Pack short-circuits early if any authority returns a wrong-length
signature, throwing `CRYPTO_SIGN_FAILED` with `signerIndex`. A
misbehaving authority cannot poison a partially-signed payload because
bytes are not assembled until every signature is ready.
- Verify does NOT short-circuit on the first failure when called via
`verify(...)` — that's a lie. It DOES short-circuit, returning
`firstBadSignerIndex`. The naming is deliberate: callers should not
rely on every signer being verified just because they asked to verify
N signatures. If you need exhaustive reporting (which signers failed,
which passed), iterate yourself with `verifyAuthoritySignature`.
- The `signerCount` upper bound (64) is conservative; raising it requires
no schema changes, but the on-wire payload grows linearly so very
large signer sets should consider threshold signing instead.
- AbortSignal is checked once per authority — a slow signer at index 5
can be cancelled, but signers 0-4 will already have signed.

View File

@@ -0,0 +1,244 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import { DOMAIN_BITPACKER_MULTISIGN_V1 } from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import { defineMultiSignedBitSchema } from "./multisigned-bitschema.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("multisigned-bitschema — pack + unpack", () => {
it("pack with N authorities produces N signatures in order", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 9 }, [a.authority, b.authority]);
// body (1 byte) + 2*64 sigs = 1 + 128 = 129 bytes
expect(packed.length).toBe(1 + 128);
// Verify against [a, b] succeeds
expect(ms.verify(packed, [a.publicSlot, b.publicSlot])).toEqual({
valid: true,
});
// Verify against reversed order [b, a] fails (each signer's sig
// wouldn't verify against the other's public key)
const reversed = ms.verify(packed, [b.publicSlot, a.publicSlot]);
expect(reversed.valid).toBe(false);
});
it("unpack returns decoded value on all-pass", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const c = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 3,
});
const packed = await ms.pack({ foo: 42 }, [
a.authority,
b.authority,
c.authority,
]);
const decoded = ms.unpack(packed, [
a.publicSlot,
b.publicSlot,
c.publicSlot,
]);
expect(decoded.foo).toBe(42);
});
});
describe("multisigned-bitschema — count enforcement", () => {
it("pack rejects wrong-count authorities with CRYPTO_MULTISIGN_COUNT_MISMATCH", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
try {
await ms.pack({ foo: 1 }, [a.authority]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_MULTISIGN_COUNT_MISMATCH
);
expect(e.context).toMatchObject({
expected: 2,
actual: 1,
kind: "authorities",
});
}
});
it("unpack rejects wrong-count public slots", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 1 }, [a.authority, b.authority]);
try {
ms.unpack(packed, [a.publicSlot]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(
BITPACKER_CODES.CRYPTO_MULTISIGN_COUNT_MISMATCH
);
expect(e.context).toMatchObject({
expected: 2,
actual: 1,
kind: "publicSlots",
});
}
});
});
describe("multisigned-bitschema — verify pinpoints failures", () => {
it("verify pinpoints which signer failed", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const c = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 3,
});
const packed = await ms.pack({ foo: 7 }, [
a.authority,
b.authority,
c.authority,
]);
// Flip a byte inside the second signature region.
// Wire: [body 1 byte][sig0 64][sig1 64][sig2 64]
// Second signer signature starts at offset 1 + 64 = 65.
const tampered = new Uint8Array(packed);
tampered[65 + 5] = (tampered[65 + 5] ?? 0) ^ 0xff;
const result = ms.verify(tampered, [
a.publicSlot,
b.publicSlot,
c.publicSlot,
]);
expect(result.valid).toBe(false);
expect(result.firstBadSignerIndex).toBe(1);
});
it("unpack throws CRYPTO_VERIFY_FAILED on any signer mismatch", {
timeout: 10_000,
}, async () => {
const a = createFakeAuthority();
const b = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
});
const packed = await ms.pack({ foo: 1 }, [a.authority, b.authority]);
// Flip the very last byte (in second signer's region)
const tampered = new Uint8Array(packed);
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
try {
ms.unpack(tampered, [a.publicSlot, b.publicSlot]);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
expect(e.context?.["firstBadSignerIndex"]).toBe(1);
}
});
it("verify on too-short bytes returns valid:false at index 0", {
timeout: 10_000,
}, () => {
const a = createFakeAuthority();
const ms = defineMultiSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 1,
});
const result = ms.verify(new Uint8Array(10), [a.publicSlot]);
expect(result.valid).toBe(false);
expect(result.firstBadSignerIndex).toBe(0);
});
});
describe("multisigned-bitschema — option validation", () => {
it("missing verifier rejects at construction time", {
timeout: 5000,
}, () => {
expect(() =>
// @ts-expect-error — verifier is required
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
signerCount: 2,
})
).toThrow();
});
it("rejects signerCount < 1", { timeout: 5000 }, () => {
expect(() =>
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 0,
})
).toThrow();
});
it("rejects signerCount > 64", { timeout: 5000 }, () => {
expect(() =>
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 65,
})
).toThrow();
});
it("rejects unknown option keys", { timeout: 5000 }, () => {
expect(() =>
defineMultiSignedBitSchema(codec, {
domain: DOMAIN_BITPACKER_MULTISIGN_V1,
verifier: cryptoVerifier,
signerCount: 2,
// @ts-expect-error — unknown
bogus: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,261 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
Authority,
AuthorityVerifier,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import {
DEFAULT_SIGN_SLOT,
DEFAULT_SIGNATURE_LENGTH,
} from "../signed/signed-bitschema.js";
const TRACER_NAME = "bitpacker-crypto";
const MULTI_PACK_SPAN = "bitpacker-crypto.multisigned-pack";
const MULTI_VERIFY_SPAN = "bitpacker-crypto.multisigned-verify";
/**
* Result of a multi-signer verify. `valid: true` means every signature
* verified. `valid: false` carries `firstBadSignerIndex` (zero-indexed)
* pointing at the first signer whose signature failed.
*/
export interface MultiVerifyResult {
readonly valid: boolean;
readonly firstBadSignerIndex?: number;
}
/**
* Options for `defineMultiSignedBitSchema`. The `signerCount` is part of
* the schema, not the runtime payload — a schema declared for 3 signers
* cannot pack with 2 authorities or unpack with 4 public slots; both throw
* `CRYPTO_MULTISIGN_COUNT_MISMATCH`.
*/
export interface MultiSignedBitSchemaOptions {
readonly domain: DomainTag;
readonly verifier: AuthorityVerifier;
readonly slot?: SlotId;
readonly signerCount: number;
readonly signatureLength?: number;
}
/**
* Bit-packed payload co-signed by N independent authorities. Wire format:
* `[bit-packed body][sig_1][sig_2]…[sig_N]` where N is `options.signerCount`.
*
* - `pack(data, authorities, signal?)` requires exactly N authorities.
* Each authority signs the same bit-packed body under the configured
* domain; signature ordering follows the authority list order.
* - `unpack(bytes, signerPublicSlots)` requires exactly N public slots.
* ALL signatures must verify in order; throws `CRYPTO_VERIFY_FAILED`
* on the first failure.
* - `verify(bytes, signerPublicSlots)` returns `MultiVerifyResult` —
* never throws on signature mismatch; pinpoints the first bad signer.
*/
export interface MultiSignedBitSchema<T> {
pack(
data: T,
authorities: readonly Authority[],
signal?: AbortSignal
): Promise<Uint8Array>;
unpack(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): T;
verify(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): MultiVerifyResult;
readonly domain: DomainTag;
readonly slot: SlotId;
readonly signerCount: number;
readonly signatureLength: number;
}
const OptionsRuntimeSchema = z
.object({
domain: z.string(),
verifier: z.function(),
slot: z.string().optional(),
signerCount: z.number().int().min(1).max(64),
signatureLength: z.number().int().min(8).max(256).optional(),
})
.strict();
export function defineMultiSignedBitSchema<T>(
bodyCodec: BodyCodec<T>,
options: MultiSignedBitSchemaOptions
): MultiSignedBitSchema<T> {
OptionsRuntimeSchema.parse(options);
const domain = options.domain;
const verifier = options.verifier;
const slot = options.slot ?? DEFAULT_SIGN_SLOT;
const signerCount = options.signerCount;
const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH;
const totalSignaturesLength = signerCount * signatureLength;
function assertCount(
actual: number,
kind: "authorities" | "publicSlots"
): void {
if (actual !== signerCount) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_MULTISIGN_COUNT_MISMATCH,
`Expected exactly ${signerCount} ${kind}, got ${actual}`,
{
context: {
expected: signerCount,
actual,
kind,
},
}
);
}
}
async function pack(
data: T,
authorities: readonly Authority[],
signal?: AbortSignal
): Promise<Uint8Array> {
assertCount(authorities.length, "authorities");
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(MULTI_PACK_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.signer_count": signerCount,
},
});
try {
const body = bodyCodec.pack(data);
const signatures: Uint8Array[] = [];
for (let i = 0; i < authorities.length; i++) {
const authority = authorities[i] as Authority;
const sig = await authority.sign(domain, slot, body, signal);
if (sig.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Authority ${i} returned signature of unexpected length: got ${sig.length}, expected ${signatureLength}`,
{
context: {
signerIndex: i,
actualLength: sig.length,
expectedLength: signatureLength,
},
}
);
}
signatures.push(sig);
}
const out = new Uint8Array(body.length + totalSignaturesLength);
out.set(body, 0);
for (let i = 0; i < signatures.length; i++) {
out.set(
signatures[i] as Uint8Array,
body.length + i * signatureLength
);
}
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
function splitBytes(bytes: Uint8Array): {
body: Uint8Array;
signatures: Uint8Array[];
} {
// `splitBytes` is only called from `unpack` after `verify` already
// returned `valid: true`, which only happens when bytes.length is
// ≥ totalSignaturesLength. The pre-check is therefore unreachable
// and kept out to honour 100% coverage.
const split = bytes.length - totalSignaturesLength;
const body = bytes.slice(0, split);
const signatures: Uint8Array[] = [];
for (let i = 0; i < signerCount; i++) {
signatures.push(
bytes.slice(
split + i * signatureLength,
split + (i + 1) * signatureLength
)
);
}
return { body, signatures };
}
function verify(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): MultiVerifyResult {
assertCount(signerPublicSlots.length, "publicSlots");
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(MULTI_VERIFY_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.signer_count": signerCount,
},
});
try {
if (bytes.length < totalSignaturesLength) {
return { valid: false, firstBadSignerIndex: 0 };
}
const split = bytes.length - totalSignaturesLength;
const body = bytes.slice(0, split);
for (let i = 0; i < signerCount; i++) {
const sig = bytes.slice(
split + i * signatureLength,
split + (i + 1) * signatureLength
);
const slotPub = signerPublicSlots[i] as KeySlotPublic<unknown>;
let ok: boolean;
try {
ok = verifier(domain, slotPub, body, sig);
} catch {
ok = false;
}
if (!ok) {
return { valid: false, firstBadSignerIndex: i };
}
}
return { valid: true };
} finally {
span.end();
}
}
function unpack(
bytes: Uint8Array,
signerPublicSlots: readonly KeySlotPublic<unknown>[]
): T {
assertCount(signerPublicSlots.length, "publicSlots");
const result = verify(bytes, signerPublicSlots);
if (!result.valid) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
"MultiSignedBitSchema signature verification failed",
{
context: {
domain: String(domain),
firstBadSignerIndex: result.firstBadSignerIndex,
},
}
);
}
const { body } = splitBytes(bytes);
return bodyCodec.unpack(body);
}
return {
pack,
unpack,
verify,
domain,
slot,
signerCount,
signatureLength,
};
}

View File

@@ -0,0 +1,158 @@
# SignedBitSchema
Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire
format is `[bit-packed body][signature]`. Verifies before decoding so a
tampered payload never even reaches the body codec. Replaces the ad-hoc
`BitPacker.pack(...) → sign(...) → concat(...)` pattern that crypto
chain bundles, public bundles, packer chunks, and game-server state-sync
encoders had been re-authoring.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every signed wire format in the stack follows the same recipe:
bit-pack a structured body, sign the bytes with an authority slot, frame
`[body][signature]`, then on the verify side strip the signature, verify it
against a parent's public slot, and only then decode the body. Five plus
suaveplan/upstream call-sites had been manually re-authoring this dance, each
slightly differently. `SignedBitSchema` consolidates the pattern so the
domain separation, signature length, sign-slot dispatch, and verify-then-decode
order are all driven by a single primitive.
The domain `DomainTag` is required, not optional, so a signature produced
under one domain (e.g. `DOMAIN_PUBLIC_KEYCHAIN_V1`) cannot be replayed
under a different domain (e.g. `DOMAIN_AUDIT_V1`) — the tag bytes
participate in the signing input via
`@suaveplan/crypto::buildSigningInput`. Cross-protocol replay is rejected
at the bytes-don't-match level rather than at any application-layer check.
## Features
- Pluggable body codec (`BodyCodec<T>`) so the body can be a legacy
`defineBitSchema` codec, a modern `BitPacker`-backed codec, an outer
envelope, or any future encoder satisfying the two-method contract
- `pack(data, authority, signal?)` is async — delegates signing to
`Authority.sign(domain, slot, payload, signal)` so HSM/threshold backends
Just Work without the primitive having to know
- `unpack(bytes, parentPublicSlot)` enforces verify-then-decode order —
body codec is NEVER invoked on a tampered payload
- `verify(bytes, parentPublicSlot)` returns `boolean` so callers can
enumerate candidate parent keys without exception flow
- `unpackSelf(bytes, getEmbeddedSlot)` supports trust-anchor bundles whose
own embedded sign slot is the verifier
- AbortSignal threaded through `Authority.sign` per suaveplan cancellation
rules
- `signatureLength` configurable (defaults to ed25519's 64) so future
primitives with different signature shapes inherit the schema for free
- OTel spans `bitpacker-crypto.signed-pack` and
`bitpacker-crypto.signed-verify` with `domain` + `payload_bytes` attrs
## Usage: Basic Example
```ts
import { defineSignedBitSchema } from "@suaveplan/bitpacker-crypto";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { DOMAIN_PUBLIC_KEYCHAIN_V1 } from "@suaveplan/crypto/domain-tags";
import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
interface LinkBody {
parentSeq: number;
purposeBits: number;
}
const linkSchema = defineBitSchema<LinkBody>([
{ name: "parentSeq", type: "uint", bits: 32 },
{ name: "purposeBits", type: "uint", bits: 16 },
]);
const link = defineSignedBitSchema<LinkBody>(
{
pack: (b) => linkSchema.pack(b),
unpack: (bytes) => linkSchema.unpack(bytes),
},
{
domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
verifier: verifyAuthoritySignature,
}
);
const packed = await link.pack(
{ parentSeq: 7, purposeBits: 0xf0 },
authority
);
const decoded = link.unpack(packed, parentPublicSlot);
```
The `verifier` option is required and accepts an `AuthorityVerifier`
function (`(domain, publicSlot, payload, signature) => boolean`). The
canonical implementation is `verifyAuthoritySignature` from
`@suaveplan/crypto/authority`. Passing it via DI keeps
`@suaveplan/bitpacker-crypto` free of any runtime dependency on
`@suaveplan/crypto`, which is what allows future
`crypto::ChainBundle`-style consumers to adopt this primitive without
inducing a circular package dependency.
## Usage: Advanced Example — trust anchor via `unpackSelf`
```ts
const trustAnchor = defineSignedBitSchema<TrustAnchorBundle>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
});
// Verify against the bundle's OWN embedded sign slot.
const bundle = trustAnchor.unpackSelf(bytes, (decoded) => decoded.embeddedSlot);
```
`unpackSelf` decodes the body once to extract the embedded sign slot, then
verifies the signature against that slot. Only used for the root link of a
chain (every other link verifies against its parent).
## API Reference
### `interface SignedBitSchemaOptions`
- `domain: DomainTag` (required) — the domain under which the body is signed
- `verifier: AuthorityVerifier` (required) — pure verifier function;
typically `verifyAuthoritySignature` from `@suaveplan/crypto/authority`
- `slot?: SlotId = "sign"` — the authority slot used for signing
- `signatureLength?: number = 64` — wire-format signature length
### `defineSignedBitSchema<T>(bodyCodec, options): SignedBitSchema<T>`
Returns:
- `pack(data: T, authority: Authority, signal?: AbortSignal): Promise<Uint8Array>`
- throws `CRYPTO_SIGN_FAILED` if the authority returns a wrong-length
signature
- `unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T`
- throws `CRYPTO_VERIFY_FAILED` on verify failure (BEFORE body decode)
- `verify(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): boolean`
- `unpackSelf(bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>): T`
- `domain: DomainTag`, `slot: SlotId`, `signatureLength: number`
## Implementation Notes
- The order `verify → decode` is load-bearing. A tampered byte string must
never reach the body codec because the codec may have side effects, may
log unexpected payloads, or may be slow. Verify-then-decode is asserted
by a spec-conformance test that injects a tracking codec and checks
`decodeCalls === 0` after a tampered unpack.
- `verify(bytes, slot)` deliberately swallows structural exceptions and
returns `false` so callers can probe candidate parent keys against an
arbitrary byte string without try/catch boilerplate. `unpack(...)` is
the throw-on-failure variant.
- The body codec is held by reference; mutating it after construction
changes future pack/unpack behavior. Treat the codec as immutable.
- `Authority.sign` is async because real backends (HSM, TPM, threshold
signing, network-attached signers) are async. Even the in-memory v1
authority returns a Promise to preserve the contract.
- Signature length is enforced on pack to surface a misbehaving authority
early. The check is identity-checking the `Promise<Uint8Array>.length`,
not the algorithm — a future ECDSA-backed authority signing into an
ed25519-shaped slot would be caught here.
- OTel spans cost essentially nothing under the no-op tracer; they
materialise once a real provider is wired. The spans deliberately do
NOT carry the signature itself (sensitive material) or the body bytes
(potentially sensitive); only metadata: domain, slot, payload_bytes.

View File

@@ -0,0 +1,143 @@
/**
* Spec-conformance test for `SignedBitSchema`. Pins OTel emission, the
* "verify-then-decode" order invariant, and the "no Math.random / Date.now
* in implementation files" determinism gate from the spec delta.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { defineBitSchema } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import { withRecordingTracer } from "../../tests/helpers/recording-tracer.js";
import { DOMAIN_BITPACKER_SIGNED_V1 } from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import { defineSignedBitSchema } from "./signed-bitschema.js";
interface Body {
foo: number;
}
const codec: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
const SRC_DIR = new URL("..", import.meta.url).pathname;
function* walkSourceFiles(dir: string): Generator<string> {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir)) {
if (
entry === "node_modules" ||
entry === "dist" ||
entry === "coverage"
) {
continue;
}
const fullPath = join(dir, entry);
const st = statSync(fullPath);
if (st.isDirectory()) {
yield* walkSourceFiles(fullPath);
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec-conformance.test.ts")
) {
yield fullPath;
}
}
}
describe("signed-bitschema — spec conformance", () => {
it("signed-pack emits OTel span with domain attribute", {
timeout: 10_000,
}, async () => {
await withRecordingTracer(async (session) => {
const { authority } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
await ss.pack({ foo: 7 }, authority);
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.signed-pack");
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes["bitpacker.crypto.domain"]).toBe(
String(DOMAIN_BITPACKER_SIGNED_V1)
);
expect(spans[0]?.attributes["bitpacker.crypto.slot"]).toBe("sign");
expect(
spans[0]?.attributes["bitpacker.crypto.payload_bytes"]
).toBeGreaterThan(0);
});
});
it("signed-verify emits OTel span on every unpack call", {
timeout: 10_000,
}, async () => {
await withRecordingTracer(async (session) => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1 }, authority);
ss.unpack(packed, publicSlot);
const spans = session
.spansFor("bitpacker-crypto")
.filter((s) => s.name === "bitpacker-crypto.signed-verify");
expect(spans).toHaveLength(1);
});
});
it("verify-then-decode order: tampered payload throws BEFORE the body is decoded", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
let decodeCalls = 0;
const trackingCodec: BodyCodec<Body> = {
pack: codec.pack,
unpack: (bytes) => {
decodeCalls += 1;
return codec.unpack(bytes);
},
};
const ss = defineSignedBitSchema<Body>(trackingCodec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7 }, authority);
const tampered = new Uint8Array(packed);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
try {
ss.unpack(tampered, publicSlot);
} catch {
// expected
}
// unpack must throw on verification BEFORE bodyCodec.unpack runs.
expect(decodeCalls).toBe(0);
});
it("no Math.random or Date.now in implementation files", {
timeout: 5000,
}, () => {
const violations: string[] = [];
for (const path of walkSourceFiles(SRC_DIR)) {
const text = readFileSync(path, "utf8");
if (/Math\.random\s*\(/.test(text)) {
violations.push(`${path} uses Math.random()`);
}
if (/Date\.now\s*\(/.test(text)) {
violations.push(`${path} uses Date.now()`);
}
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,305 @@
import {
BITPACKER_CODES,
type BitpackerError,
defineBitSchema,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cryptoVerifier } from "../../tests/helpers/crypto-deps.js";
import { createFakeAuthority } from "../../tests/helpers/fake-authority.js";
import {
DOMAIN_AUDIT_V1,
DOMAIN_BITPACKER_SIGNED_V1,
DOMAIN_PUBLIC_KEYCHAIN_V1,
} from "../../tests/helpers/test-domains.js";
import type { BodyCodec } from "../envelope/envelope-schema.js";
import { defineSignedBitSchema } from "./signed-bitschema.js";
interface Body {
foo: number;
bar: number;
}
const BODY_CODEC: BodyCodec<Body> = (() => {
const schema = defineBitSchema<Body>([
{ name: "foo", type: "uint", bits: 8 },
{ name: "bar", type: "uint", bits: 16 },
]);
return {
pack: (b) => schema.pack(b),
unpack: (bytes) => schema.unpack(bytes),
};
})();
describe("signed-bitschema — pack + unpack round trip", () => {
it("pack signs via Authority under the configured domain", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7, bar: 1234 }, authority);
// Wire format: [body bytes][signature 64 bytes]
expect(packed.length).toBeGreaterThan(64);
const decoded = ss.unpack(packed, publicSlot);
expect(decoded.foo).toBe(7);
expect(decoded.bar).toBe(1234);
});
it("verify returns true for an unmodified payload", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1, bar: 2 }, authority);
expect(ss.verify(packed, publicSlot)).toBe(true);
});
});
describe("signed-bitschema — tamper detection", () => {
it("tampered signature rejects with CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7, bar: 1234 }, authority);
// Flip the last byte (signature suffix).
const tampered = new Uint8Array(packed);
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
try {
ss.unpack(tampered, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
it("tampered payload rejects with CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 7, bar: 1234 }, authority);
// Flip a payload byte (before the signature region).
const tampered = new Uint8Array(packed);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
try {
ss.unpack(tampered, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
it("verify returns false on tamper instead of throwing", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack({ foo: 1, bar: 2 }, authority);
const tampered = new Uint8Array(packed);
tampered[0] = (tampered[0] ?? 0) ^ 0xff;
expect(ss.verify(tampered, publicSlot)).toBe(false);
});
it("verify returns false on bytes too short for a signature", {
timeout: 10_000,
}, () => {
const { publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
expect(ss.verify(new Uint8Array(10), publicSlot)).toBe(false);
});
it("unpack on bytes too short throws CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, () => {
const { publicSlot } = createFakeAuthority();
const ss = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
try {
ss.unpack(new Uint8Array(3), publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
});
describe("signed-bitschema — domain separation", () => {
it("cross-domain replay rejects with CRYPTO_VERIFY_FAILED", {
timeout: 10_000,
}, async () => {
const { authority, publicSlot } = createFakeAuthority();
// Pack under PUBLIC_KEYCHAIN; try to verify under AUDIT.
const ssA = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
verifier: cryptoVerifier,
});
const ssB = defineSignedBitSchema<Body>(BODY_CODEC, {
domain: DOMAIN_AUDIT_V1,
verifier: cryptoVerifier,
});
const packed = await ssA.pack({ foo: 1, bar: 2 }, authority);
// verify under different domain returns false (different signing input)
expect(ssB.verify(packed, publicSlot)).toBe(false);
try {
ssB.unpack(packed, publicSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
});
});
describe("signed-bitschema — unpackSelf trust-anchor flow", () => {
it("unpackSelf verifies against embedded sign slot", {
timeout: 10_000,
}, async () => {
// Trust-anchor body: include the public key in the body itself, so
// the signature is verified against its own embedded sign slot.
interface SelfBody {
embeddedPublicKey: Uint8Array;
seq: number;
}
const selfSchema = defineBitSchema<SelfBody>([
{ name: "embeddedPublicKey", type: "bytes", byteLength: 32 },
{ name: "seq", type: "uint", bits: 16 },
]);
const codec: BodyCodec<SelfBody> = {
pack: (b) => selfSchema.pack(b),
unpack: (bytes) => selfSchema.unpack(bytes),
};
const { authority, publicSlot, publicKey } = createFakeAuthority();
const ss = defineSignedBitSchema<SelfBody>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack(
{ embeddedPublicKey: publicKey, seq: 42 },
authority
);
const decoded = ss.unpackSelf(packed, (body) => {
// Build a public slot from the embedded key.
return {
...publicSlot,
publicKey: body.embeddedPublicKey,
};
});
expect(decoded.seq).toBe(42);
expect(Array.from(decoded.embeddedPublicKey)).toEqual(
Array.from(publicKey)
);
});
it("unpackSelf rejects when the embedded slot does not match the signature", {
timeout: 10_000,
}, async () => {
interface SelfBody {
embeddedPublicKey: Uint8Array;
seq: number;
}
const selfSchema = defineBitSchema<SelfBody>([
{ name: "embeddedPublicKey", type: "bytes", byteLength: 32 },
{ name: "seq", type: "uint", bits: 16 },
]);
const codec: BodyCodec<SelfBody> = {
pack: (b) => selfSchema.pack(b),
unpack: (bytes) => selfSchema.unpack(bytes),
};
const { authority, publicSlot } = createFakeAuthority();
const otherSlot = createFakeAuthority().publicSlot;
const ss = defineSignedBitSchema<SelfBody>(codec, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
const packed = await ss.pack(
{ embeddedPublicKey: new Uint8Array(32), seq: 0 },
authority
);
// Force the verify to use otherSlot's mismatched key.
try {
ss.unpackSelf(packed, () => otherSlot);
throw new Error("expected throw");
} catch (err) {
const e = err as BitpackerError;
expect(e.code).toBe(BITPACKER_CODES.CRYPTO_VERIFY_FAILED);
}
// suppress unused-warning on publicSlot (kept for parallelism above)
void publicSlot;
});
});
describe("signed-bitschema — option validation", () => {
it("missing verifier rejects at construction time", {
timeout: 5000,
}, () => {
expect(() =>
// @ts-expect-error — verifier is required
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
})
).toThrow();
});
it("rejects unknown options keys", { timeout: 5000 }, () => {
expect(() =>
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
// @ts-expect-error — unknown key
bogus: true,
})
).toThrow();
});
it("rejects out-of-range signatureLength", { timeout: 5000 }, () => {
expect(() =>
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
signatureLength: 0,
})
).toThrow();
expect(() =>
defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
signatureLength: 999,
})
).toThrow();
});
it("uses default slot 'sign' and signatureLength 64", {
timeout: 5000,
}, () => {
const ss = defineSignedBitSchema(BODY_CODEC, {
domain: DOMAIN_BITPACKER_SIGNED_V1,
verifier: cryptoVerifier,
});
expect(ss.slot).toBe("sign");
expect(ss.signatureLength).toBe(64);
});
});

View File

@@ -0,0 +1,244 @@
import { trace } from "@opentelemetry/api";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
Authority,
AuthorityVerifier,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
import z from "zod";
import type { BodyCodec } from "../envelope/envelope-schema.js";
const TRACER_NAME = "bitpacker-crypto";
const SIGNED_PACK_SPAN = "bitpacker-crypto.signed-pack";
const SIGNED_VERIFY_SPAN = "bitpacker-crypto.signed-verify";
/** Default ed25519 signature length. */
export const DEFAULT_SIGNATURE_LENGTH = 64 as const;
/** Default slot id used when no `slot` is supplied to `defineSignedBitSchema`. */
export const DEFAULT_SIGN_SLOT: SlotId = "sign" as SlotId;
/**
* Options for `defineSignedBitSchema`.
*
* - `domain` is required so two schemas signed under different domains
* produce non-cross-replayable bytes (the tag participates in the
* signature input via `buildSigningInput`).
* - `verifier` is the dependency-injected verifier function — typically
* `verifyAuthoritySignature` from `@suaveplan/crypto/authority`. Required
* so this primitive has no runtime dependency on `@suaveplan/crypto`.
* - `slot` defaults to `"sign"` to match the genesis purpose-tag convention.
* - `signatureLength` defaults to 64 (ed25519). Other primitives may declare
* different signature lengths in the future; this option exists so the
* wire format can grow without redesigning the API.
*/
export interface SignedBitSchemaOptions {
readonly domain: DomainTag;
readonly verifier: AuthorityVerifier;
readonly slot?: SlotId;
readonly signatureLength?: number;
}
/**
* A bit-packed payload signed by an `Authority` under a fixed `DomainTag`.
* Wire format: `[bit-packed body][signature]`.
*
* - `pack(data, authority)` signs the bit-packed body with the
* authority's slot under the configured domain
* - `unpack(bytes, parentPublicSlot)` verifies the signature first; throws
* `CRYPTO_VERIFY_FAILED` on mismatch; returns the decoded body otherwise
* - `verify(bytes, parentPublicSlot)` returns `boolean` — does NOT throw on
* verification failure (callers can enumerate candidate parent keys)
* - `unpackSelf(bytes, getEmbeddedSlot)` is for trust-anchor bundles whose
* own embedded sign-slot is the verifier; the body is decoded first to
* extract the slot, then the signature is verified against it
*/
export interface SignedBitSchema<T> {
pack(
data: T,
authority: Authority,
signal?: AbortSignal
): Promise<Uint8Array>;
unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T;
verify(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean;
unpackSelf(
bytes: Uint8Array,
getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>
): T;
readonly domain: DomainTag;
readonly slot: SlotId;
readonly signatureLength: number;
}
const OptionsRuntimeSchema = z
.object({
domain: z.string(),
verifier: z.function(),
slot: z.string().optional(),
signatureLength: z.number().int().min(8).max(256).optional(),
})
.strict();
export function defineSignedBitSchema<T>(
bodyCodec: BodyCodec<T>,
options: SignedBitSchemaOptions
): SignedBitSchema<T> {
OptionsRuntimeSchema.parse(options);
const domain = options.domain;
const verifier = options.verifier;
const slot = options.slot ?? DEFAULT_SIGN_SLOT;
const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH;
async function pack(
data: T,
authority: Authority,
signal?: AbortSignal
): Promise<Uint8Array> {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(SIGNED_PACK_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.slot": String(slot),
},
});
try {
const body = bodyCodec.pack(data);
const signature = await authority.sign(domain, slot, body, signal);
if (signature.length !== signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_SIGN_FAILED,
`Authority returned signature of unexpected length: got ${signature.length}, expected ${signatureLength}`,
{
context: {
actualLength: signature.length,
expectedLength: signatureLength,
domain: String(domain),
slot: String(slot),
},
}
);
}
const out = new Uint8Array(body.length + signatureLength);
out.set(body, 0);
out.set(signature, body.length);
span.setAttribute("bitpacker.crypto.payload_bytes", out.length);
return out;
} finally {
span.end();
}
}
function splitBytes(bytes: Uint8Array): {
body: Uint8Array;
signature: Uint8Array;
} {
if (bytes.length < signatureLength) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
`SignedBitSchema bytes too short to contain a signature: got ${bytes.length}, expected ≥ ${signatureLength}`,
{
context: {
actualLength: bytes.length,
signatureLength,
},
}
);
}
const split = bytes.length - signatureLength;
return {
body: bytes.slice(0, split),
signature: bytes.slice(split),
};
}
function verifyBody(
body: Uint8Array,
signature: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean {
const tracer = trace.getTracer(TRACER_NAME);
const span = tracer.startSpan(SIGNED_VERIFY_SPAN, {
attributes: {
"bitpacker.crypto.domain": String(domain),
"bitpacker.crypto.payload_bytes": body.length,
},
});
try {
return verifier(domain, parentPublicSlot, body, signature);
} finally {
span.end();
}
}
function unpack(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): T {
const { body, signature } = splitBytes(bytes);
if (!verifyBody(body, signature, parentPublicSlot)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
"SignedBitSchema signature verification failed",
{
context: {
domain: String(domain),
parentAlgorithm: parentPublicSlot.algorithm,
},
}
);
}
return bodyCodec.unpack(body);
}
function verify(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean {
if (bytes.length < signatureLength) return false;
const split = bytes.length - signatureLength;
const body = bytes.slice(0, split);
const signature = bytes.slice(split);
try {
return verifyBody(body, signature, parentPublicSlot);
} catch {
return false;
}
}
function unpackSelf(
bytes: Uint8Array,
getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>
): T {
const { body, signature } = splitBytes(bytes);
const decoded = bodyCodec.unpack(body);
const embeddedSlot = getEmbeddedSlot(decoded);
if (!verifyBody(body, signature, embeddedSlot)) {
throw new BitpackerError(
BITPACKER_CODES.CRYPTO_VERIFY_FAILED,
"SignedBitSchema unpackSelf verification failed",
{
context: {
domain: String(domain),
embeddedAlgorithm: embeddedSlot.algorithm,
},
}
);
}
return decoded;
}
return {
pack,
unpack,
verify,
unpackSelf,
domain,
slot,
signatureLength,
};
}

View File

@@ -0,0 +1,59 @@
/**
* Audit-trail tests for the deliberate scope-down recorded in
* `packages/bitpacker/bitpacker-crypto/SUPERSEDED.md` and
* `openspec/changes/bitpacker-crypto-port/`.
*
* Each `it(...)` pins the absence of one upstream symbol. If a future
* contributor accidentally re-introduces any of them as exports of
* `@suaveplan/bitpacker-crypto`, that contributor must also update
* `SUPERSEDED.md` and the corresponding parity-table row in
* `openspec/changes/bitpacker-modular-restructure/proposal.md`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as bitpackerCrypto from "./index.js";
const REJECTED_SYMBOLS = [
"BaseCryptoProvider",
"CryptoRegistry",
"LibsodiumCryptoProvider",
"EllipticCryptoProvider",
"NoOpCryptoProvider",
"generateNonce",
"concatBytes",
"validateKeyLength",
"constantTimeEqual",
"secureWipe",
"createCryptoRegistry",
"getGlobalRegistry",
"createXChaCha20Provider",
"createAesGcmProvider",
"createSecp256k1Provider",
"createEd25519Provider",
"createP256Provider",
"createTestProvider",
"createSlowProvider",
"createUnreliableProvider",
] as const;
describe("@suaveplan/bitpacker-crypto SUPERSEDED audit", () => {
for (const name of REJECTED_SYMBOLS) {
it(`${name} is not a runtime export`, { timeout: 5000 }, () => {
const exported = (bitpackerCrypto as Record<string, unknown>)[name];
expect(exported).toBeUndefined();
});
}
it("exposes the checksum surface that is in scope", {
timeout: 5000,
}, () => {
expect(typeof bitpackerCrypto.crc32).toBe("function");
expect(typeof bitpackerCrypto.adler32).toBe("function");
expect(typeof bitpackerCrypto.appendCrc32).toBe("function");
expect(typeof bitpackerCrypto.verifyAndStripCrc32).toBe("function");
expect(typeof bitpackerCrypto.appendAdler32).toBe("function");
expect(typeof bitpackerCrypto.verifyAndStripAdler32).toBe("function");
expect(typeof bitpackerCrypto.calculateXxhash32).toBe("function");
expect(typeof bitpackerCrypto.appendXxhash32).toBe("function");
expect(typeof bitpackerCrypto.verifyAndStripXxhash32).toBe("function");
});
});

View File

@@ -0,0 +1,31 @@
## xxhash32
**Path:** `src/xxhash32.ts`
**Export:** `calculateXxhash32`, `verifyXxhash32`, `packXxhash32`, `unpackXxhash32`, `appendXxhash32`, `verifyAndStripXxhash32`
### What it does
Pure TypeScript implementation of the XXHash32 non-cryptographic hash algorithm (spec v0.8.2). No external dependencies.
### Key API
| Function | Description |
|----------|-------------|
| `calculateXxhash32(data, seed?)` | Compute 32-bit hash of a `Uint8Array` |
| `verifyXxhash32(data, expected, seed?)` | Verify a hash matches |
| `packXxhash32(checksum)` | Pack hash as 4 little-endian bytes |
| `unpackXxhash32(bytes, offset?)` | Unpack 4 bytes to hash value |
| `appendXxhash32(data, seed?)` | Append 4-byte hash to data |
| `verifyAndStripXxhash32(data, seed?)` | Verify and strip appended hash |
### Constants
- `PRIME1` = `0x9e3779b1`
- `PRIME2` = `0x85ebca77`
- `PRIME3` = `0xc2b2ae3d`
- `PRIME4` = `0x27d4eb2f`
- `PRIME5` = `0x165667b1`
### Used by
- Checksum validation in downstream network protocols

View File

@@ -0,0 +1,166 @@
/**
* XXHash32 — extremely fast non-cryptographic hash algorithm.
* Pure TypeScript implementation. No external dependencies.
* Reference: https://github.com/Cyan4973/xxHash spec v0.8.2
*/
const PRIME1 = 0x9e3779b1;
const PRIME2 = 0x85ebca77;
const PRIME3 = 0xc2b2ae3d;
const PRIME4 = 0x27d4eb2f;
const PRIME5 = 0x165667b1;
function rotl32(v: number, r: number): number {
return ((v << r) | (v >>> (32 - r))) >>> 0;
}
function readU32LE(data: Uint8Array, offset: number): number {
return (
(data[offset]! |
(data[offset + 1]! << 8) |
(data[offset + 2]! << 16) |
(data[offset + 3]! << 24)) >>>
0
);
}
function round(acc: number, input: number): number {
acc = (acc + Math.imul(input, PRIME2)) >>> 0;
acc = rotl32(acc, 13);
acc = Math.imul(acc, PRIME1) >>> 0;
return acc;
}
/**
* Calculates XXHash32 checksum.
* @param data Input data
* @param seed Optional seed (default: 0)
* @returns 32-bit unsigned hash
*/
export function calculateXxhash32(data: Uint8Array, seed = 0): number {
const len = data.length;
let pos = 0;
let h32: number;
if (len >= 16) {
let v1 = (seed + PRIME1 + PRIME2) >>> 0;
let v2 = (seed + PRIME2) >>> 0;
let v3 = seed >>> 0;
let v4 = (seed - PRIME1) >>> 0;
const limit = len - 16;
while (pos <= limit) {
v1 = round(v1, readU32LE(data, pos));
pos += 4;
v2 = round(v2, readU32LE(data, pos));
pos += 4;
v3 = round(v3, readU32LE(data, pos));
pos += 4;
v4 = round(v4, readU32LE(data, pos));
pos += 4;
}
h32 =
(rotl32(v1, 1) +
rotl32(v2, 7) +
rotl32(v3, 12) +
rotl32(v4, 18)) >>>
0;
} else {
h32 = (seed + PRIME5) >>> 0;
}
h32 = (h32 + len) >>> 0;
while (pos <= len - 4) {
h32 = (h32 + Math.imul(readU32LE(data, pos), PRIME3)) >>> 0;
h32 = rotl32(h32, 17);
h32 = Math.imul(h32, PRIME4) >>> 0;
pos += 4;
}
while (pos < len) {
h32 = (h32 + Math.imul(data[pos]! & 0xff, PRIME5)) >>> 0;
h32 = rotl32(h32, 11);
h32 = Math.imul(h32, PRIME1) >>> 0;
pos += 1;
}
h32 ^= h32 >>> 15;
h32 = Math.imul(h32, PRIME2) >>> 0;
h32 ^= h32 >>> 13;
h32 = Math.imul(h32, PRIME3) >>> 0;
h32 ^= h32 >>> 16;
return h32 >>> 0;
}
/**
* Verifies XXHash32 checksum.
*/
export function verifyXxhash32(
data: Uint8Array,
expectedChecksum: number,
seed = 0
): boolean {
return calculateXxhash32(data, seed) === expectedChecksum;
}
/**
* Packs XXHash32 as 4 bytes (little-endian).
*/
export function packXxhash32(checksum: number): Uint8Array {
const bytes = new Uint8Array(4);
bytes[0] = checksum & 0xff;
bytes[1] = (checksum >>> 8) & 0xff;
bytes[2] = (checksum >>> 16) & 0xff;
bytes[3] = (checksum >>> 24) & 0xff;
return bytes;
}
/**
* Unpacks XXHash32 from 4 bytes (little-endian).
*/
export function unpackXxhash32(bytes: Uint8Array, offset = 0): number {
return (
((bytes[offset] ?? 0) |
((bytes[offset + 1] ?? 0) << 8) |
((bytes[offset + 2] ?? 0) << 16) |
((bytes[offset + 3] ?? 0) << 24)) >>>
0
);
}
/**
* Appends XXHash32 checksum to data.
*/
export function appendXxhash32(data: Uint8Array, seed = 0): Uint8Array {
const checksum = calculateXxhash32(data, seed);
const result = new Uint8Array(data.length + 4);
result.set(data);
result.set(packXxhash32(checksum), data.length);
return result;
}
/**
* Verifies and strips XXHash32 checksum from data.
* @throws {Error} If checksum fails or data too short
*/
export function verifyAndStripXxhash32(
dataWithChecksum: Uint8Array,
seed = 0
): Uint8Array {
if (dataWithChecksum.length < 4) {
throw new Error("Data too short to contain xxhash32 checksum");
}
const dataLength = dataWithChecksum.length - 4;
const data = dataWithChecksum.subarray(0, dataLength);
const stored = unpackXxhash32(dataWithChecksum, dataLength);
const calculated = calculateXxhash32(data, seed);
if (stored !== calculated) {
throw new Error(
`xxhash32 checksum mismatch: stored 0x${stored.toString(16)}, calculated 0x${calculated.toString(16)}`
);
}
return data;
}

View File

@@ -0,0 +1,119 @@
/**
* Test-only DI bindings for `@suaveplan/bitpacker-crypto` primitives.
*
* Implemented directly against the lower-level `@suaveplan/crypto-core`
* (sign/verify) and `@suaveplan/crypto-cipher` (AEAD) packages so this
* test surface has NO dependency on `@suaveplan/crypto`. That breaks an
* otherwise-circular dep chain: `crypto` may depend on `bitpacker-crypto`
* (e.g. for `EnvelopeSchema` in `chain-bundle`) without inducing a cycle
* via this test helper.
*
* Production callers should still wire `verifyAuthoritySignature` from
* `@suaveplan/crypto/authority` and `aeadEncrypt` / `aeadDecrypt` from
* `@suaveplan/crypto` — those are the canonical implementations. This
* file mirrors their shape for test purposes only.
*/
import * as aesGcm from "@suaveplan/crypto-cipher/aes-256-gcm";
import * as chacha20 from "@suaveplan/crypto-cipher/chacha20-poly1305";
import * as xchacha20 from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import { ed25519 } from "@suaveplan/crypto-core/sign";
import type {
AeadAlgorithm,
AeadOps,
AeadOpsOptions,
AuthorityVerifier,
DomainTag,
} from "@suaveplan/types";
const ED25519_ALGORITHM_ID = 0;
/**
* Inline copy of `buildSigningInput` from `@suaveplan/crypto/domain-tags`.
* Reproduced here to keep this test helper free of any `@suaveplan/crypto`
* runtime dep. The byte layout is identical: `tag-bytes ‖ 0x00 ‖ payload`.
*/
function buildSigningInput(domain: DomainTag, payload: Uint8Array): Uint8Array {
const tag = new TextEncoder().encode(domain);
const out = new Uint8Array(tag.length + 1 + payload.length);
out.set(tag, 0);
out[tag.length] = 0;
out.set(payload, tag.length + 1);
return out;
}
/**
* Test verifier — mirrors `verifyAuthoritySignature` from
* `@suaveplan/crypto/authority` byte-for-byte. Only ed25519 is supported
* (matches the canonical impl); other algorithms throw to preserve the
* "structural error → swallow as false" convention used by primitives.
*/
export const cryptoVerifier: AuthorityVerifier = (
domain,
publicSlot,
payload,
signature
) => {
if (publicSlot.algorithm !== ED25519_ALGORITHM_ID) {
throw new Error(
`cryptoVerifier: unsupported algorithm ${publicSlot.algorithm}`
);
}
const message = buildSigningInput(domain, payload);
return ed25519.verify(publicSlot.publicKey, signature, message);
};
function encrypt(
algorithm: AeadAlgorithm,
key: Uint8Array,
plaintext: Uint8Array,
options: AeadOpsOptions
): Uint8Array {
switch (algorithm) {
case "AES-256-GCM":
return aesGcm.encrypt(key, options.nonce, plaintext, options.aad);
case "ChaCha20-Poly1305":
return chacha20.encrypt(key, options.nonce, plaintext, options.aad);
case "XChaCha20-Poly1305":
return xchacha20.encrypt(
key,
options.nonce,
plaintext,
options.aad
);
}
}
function decrypt(
algorithm: AeadAlgorithm,
key: Uint8Array,
ciphertext: Uint8Array,
options: AeadOpsOptions
): Uint8Array {
switch (algorithm) {
case "AES-256-GCM":
return aesGcm.decrypt(key, options.nonce, ciphertext, options.aad);
case "ChaCha20-Poly1305":
return chacha20.decrypt(
key,
options.nonce,
ciphertext,
options.aad
);
case "XChaCha20-Poly1305":
return xchacha20.decrypt(
key,
options.nonce,
ciphertext,
options.aad
);
}
}
/**
* Test AEAD bundle — dispatches to the per-algorithm functions exposed by
* `@suaveplan/crypto-cipher`. Behaviorally identical to the canonical
* `{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` shape from
* `@suaveplan/crypto`.
*/
export const cryptoAead: AeadOps = { encrypt, decrypt };

View File

@@ -0,0 +1,106 @@
/**
* Test-only fake `Authority` that signs with a fresh ed25519 keypair and
* exposes the matching `KeySlotPublic` for verification. Avoids the
* full-keychain construction machinery in `@suaveplan/crypto`'s test surface
* which would require a deep import on every primitive's tests.
*
* Only the fields actually touched by `verifyAuthoritySignature` are
* populated correctly (`algorithm` + `publicKey`). All other slot fields
* carry placeholder values that satisfy the type but are not checked at
* runtime by the verify path.
*/
import { ed25519 } from "@suaveplan/crypto-core/sign";
import type {
Authority,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
/**
* Inline copy of `buildSigningInput` from `@suaveplan/crypto/domain-tags`.
* Reproduced here to keep this test helper free of any `@suaveplan/crypto`
* runtime dep (which would induce a cycle now that `@suaveplan/crypto`
* itself depends on this package via `chain-bundle`'s `EnvelopeSchema`
* adoption). Byte layout is identical: `tag-bytes ‖ 0x00 ‖ payload`.
*/
function buildSigningInput(domain: DomainTag, payload: Uint8Array): Uint8Array {
const tag = new TextEncoder().encode(domain);
const out = new Uint8Array(tag.length + 1 + payload.length);
out.set(tag, 0);
out[tag.length] = 0;
out.set(payload, tag.length + 1);
return out;
}
const ED25519_ALGORITHM_ID = 0;
export interface FakeAuthorityHandle {
readonly authority: Authority;
readonly publicSlot: KeySlotPublic<unknown>;
readonly secretKey: Uint8Array;
readonly publicKey: Uint8Array;
}
function makePlaceholderSlot(publicKey: Uint8Array): KeySlotPublic<unknown> {
return {
coordinate: {
application: "test" as never,
purpose: "sign" as never,
layoutId: 0 as never,
layoutFields: {} as never,
} as never,
algorithm: ED25519_ALGORITHM_ID as never,
notBefore: 0,
notAfter: Number.MAX_SAFE_INTEGER,
publicKey,
hasPublic: true,
};
}
/**
* Build a fake `Authority` backed by a fresh ed25519 keypair. The returned
* `publicSlot` matches the secretKey and is suitable for passing to
* `verifyAuthoritySignature` or any consumer of `KeySlotPublic`.
*/
export function createFakeAuthority(): FakeAuthorityHandle {
const keypair = ed25519.generateKeypair();
const secretKey = keypair.secretKey;
const publicKey = keypair.publicKey;
let disposed = false;
const publicSlot = makePlaceholderSlot(publicKey);
const authority: Authority = {
async sign(
domain: DomainTag,
_slot: SlotId,
payload: Uint8Array,
signal?: AbortSignal
): Promise<Uint8Array> {
if (disposed) {
throw new Error("authority disposed");
}
if (signal?.aborted === true) {
throw new Error("aborted");
}
const message = buildSigningInput(domain, payload);
return Promise.resolve(ed25519.sign(secretKey, message));
},
getPublicSlot(_slot: SlotId): KeySlotPublic<unknown> | undefined {
if (disposed) return undefined;
return publicSlot;
},
hasSlot(_slot: SlotId): boolean {
return !disposed;
},
dispose(): void {
disposed = true;
},
get disposed(): boolean {
return disposed;
},
};
return { authority, publicSlot, secretKey, publicKey };
}

View File

@@ -0,0 +1,270 @@
/**
* Minimal in-memory recording tracer for assert-on-span testing without
* pulling `@opentelemetry/sdk-trace-base` as a workspace dep.
*
* Implements just enough of the `@opentelemetry/api` `Tracer` /
* `TracerProvider` surface to capture span name + attributes from primitive
* code paths. Tests call `installRecordingTracer()` in a setup, exercise the
* primitive, then `recorded.spans` returns the captured spans. Always restore
* the previous global tracer provider in a `try { ... } finally { ... }` so
* tests don't pollute each other.
*/
import {
type Attributes,
type Context,
type Span,
type SpanContext,
SpanKind,
type SpanOptions,
type SpanStatus,
SpanStatusCode,
type Tracer,
type TracerProvider,
trace,
} from "@opentelemetry/api";
export interface RecordedSpan {
readonly name: string;
readonly attributes: Attributes;
readonly status?: SpanStatus;
readonly events: {
readonly name: string;
readonly attributes?: Attributes;
}[];
readonly ended: boolean;
}
class RecordingSpan implements Span {
public readonly name: string;
public readonly attributes: Attributes = {};
public readonly events: { name: string; attributes?: Attributes }[] = [];
public ended = false;
public status: SpanStatus | undefined;
public constructor(name: string, attrs?: Attributes) {
this.name = name;
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
this.attributes[k] = v;
}
}
}
public spanContext(): SpanContext {
return {
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
traceFlags: 0,
};
}
public setAttribute(key: string, value: unknown): this {
this.attributes[key] = value as never;
return this;
}
public setAttributes(attrs: Attributes): this {
for (const [k, v] of Object.entries(attrs)) {
this.attributes[k] = v;
}
return this;
}
public addEvent(name: string, attrs?: Attributes): this {
const event: { name: string; attributes?: Attributes } = { name };
if (attrs !== undefined) {
event.attributes = attrs;
}
this.events.push(event);
return this;
}
public addLink(): this {
return this;
}
public addLinks(): this {
return this;
}
public setStatus(status: SpanStatus): this {
this.status = status;
return this;
}
public updateName(name: string): this {
(this as { name: string }).name = name;
return this;
}
public end(): void {
this.ended = true;
}
public isRecording(): boolean {
return !this.ended;
}
public recordException(): void {
this.setStatus({ code: SpanStatusCode.ERROR });
}
}
class RecordingTracer implements Tracer {
public readonly spans: RecordingSpan[] = [];
public startSpan(name: string, options?: SpanOptions): Span {
const span = new RecordingSpan(name, options?.attributes);
this.spans.push(span);
return span;
}
public startActiveSpan<F extends (span: Span) => unknown>(
nameOrOptions: string,
optionsOrFn: SpanOptions | F,
contextOrFn?: Context | F,
fn?: F
): ReturnType<F> {
const name = nameOrOptions;
const callback =
typeof fn === "function"
? fn
: typeof contextOrFn === "function"
? contextOrFn
: (optionsOrFn as F);
const opts =
typeof optionsOrFn === "function" ? undefined : optionsOrFn;
const span = this.startSpan(name, opts);
try {
return callback(span) as ReturnType<F>;
} finally {
if (!span.isRecording()) {
// already ended in the callback
} else {
span.end();
}
}
}
}
class RecordingTracerProvider implements TracerProvider {
public readonly tracers = new Map<string, RecordingTracer>();
public getTracer(name: string): Tracer {
let tracer = this.tracers.get(name);
if (tracer === undefined) {
tracer = new RecordingTracer();
this.tracers.set(name, tracer);
}
return tracer;
}
public allSpans(): readonly RecordedSpan[] {
const all: RecordedSpan[] = [];
for (const tracer of this.tracers.values()) {
for (const span of tracer.spans) {
all.push({
name: span.name,
attributes: { ...span.attributes },
...(span.status !== undefined
? { status: span.status }
: {}),
events: span.events.map((e) => ({
name: e.name,
...(e.attributes !== undefined
? { attributes: e.attributes }
: {}),
})),
ended: span.ended,
});
}
}
return all;
}
}
export interface RecordingTracerSession {
readonly provider: RecordingTracerProvider;
spansFor(tracerName: string): readonly RecordedSpan[];
allSpans(): readonly RecordedSpan[];
restore(): void;
}
/**
* Install a recording tracer as the global provider, returning a handle to
* inspect captured spans and a `restore()` to undo. Pair every call with
* `restore()` in a `finally` block.
*
* Implementation note: the `@opentelemetry/api` package keeps a one-shot
* global tracer provider — subsequent `setGlobalTracerProvider` calls log
* a warning and no-op unless we first call `trace.disable()`. The session
* surface always disables before installing so back-to-back tests work.
*/
export function installRecordingTracer(): RecordingTracerSession {
const provider = new RecordingTracerProvider();
trace.disable();
trace.setGlobalTracerProvider(provider);
return {
provider,
spansFor(tracerName: string): readonly RecordedSpan[] {
const tracer = provider.tracers.get(tracerName);
if (tracer === undefined) return [];
return tracer.spans.map((s) => ({
name: s.name,
attributes: { ...s.attributes },
...(s.status !== undefined ? { status: s.status } : {}),
events: s.events.map((e) => ({
name: e.name,
...(e.attributes !== undefined
? { attributes: e.attributes }
: {}),
})),
ended: s.ended,
}));
},
allSpans(): readonly RecordedSpan[] {
return provider.allSpans();
},
restore(): void {
trace.disable();
},
};
}
/**
* Convenience: run `fn` with an installed tracer, collecting spans.
*
* Awaits async callbacks so `session.restore()` runs only after the
* callback's Promise settles. Callers passing a sync callback get sync
* behavior.
*/
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => Promise<T>
): Promise<T>;
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => T
): T;
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => T | Promise<T>
): T | Promise<T> {
const session = installRecordingTracer();
let result: T | Promise<T>;
try {
result = fn(session);
} catch (err) {
session.restore();
throw err;
}
if (
result !== null &&
typeof result === "object" &&
"then" in (result as object) &&
typeof (result as { then: unknown }).then === "function"
) {
return (result as Promise<T>).finally(() => session.restore());
}
session.restore();
return result;
}
export { SpanKind, SpanStatusCode };

View File

@@ -0,0 +1,27 @@
/**
* Test-only `DomainTag` constants for `@suaveplan/bitpacker-crypto` tests.
*
* Mirrors the canonical `DOMAIN_BITPACKER_*` constants from
* `@suaveplan/crypto/domain-tags` byte-for-byte, but declared inline so
* this test surface has NO runtime dep on `@suaveplan/crypto`. That breaks
* a cycle that would otherwise form when `@suaveplan/crypto` itself adopts
* primitives from this package (e.g. `chain-bundle` adopting
* `EnvelopeSchema`).
*
* Production callers should import the canonical constants from
* `@suaveplan/crypto/domain-tags` directly. These test mirrors exist solely
* to give the bitpacker-crypto test suite valid `DomainTag`-typed values.
*/
import type { DomainTag } from "@suaveplan/types";
const tag = (s: string): DomainTag => s as DomainTag;
export const DOMAIN_BITPACKER_SIGNED_V1 = tag("suaveplan.bitpacker-signed.v1");
export const DOMAIN_BITPACKER_MULTISIGN_V1 = tag(
"suaveplan.bitpacker-multisign.v1"
);
export const DOMAIN_BITPACKER_HASHCHAIN_V1 = tag(
"suaveplan.bitpacker-hashchain.v1"
);
export const DOMAIN_PUBLIC_KEYCHAIN_V1 = tag("suaveplan.public-keychain.v1");
export const DOMAIN_AUDIT_V1 = tag("suaveplan.audit.v1");

View File

@@ -0,0 +1,24 @@
/**
* Vitest setup polyfills for @suaveplan/bitpacker-crypto.
*
* The package's primitives consume `globalThis.crypto.getRandomValues` for
* AEAD nonces and `globalThis.crypto.subtle` for sha256 digests. Modern Node
* (≥ 19) exposes these on `globalThis.crypto`, but the vitest forks pool can
* sometimes start in a context where the global is not yet attached. Pull it
* explicitly from `node:crypto.webcrypto` if missing.
*/
import { webcrypto } from "node:crypto";
if (typeof globalThis.crypto?.getRandomValues !== "function") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
configurable: true,
writable: true,
});
}
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new Error(
"[bitpacker-crypto polyfills] failed to install globalThis.crypto.getRandomValues"
);
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bitpacker-crypto",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"signed/signed-bitschema": "src/signed/signed-bitschema.ts",
"encrypted/encrypted-bitschema":
"src/encrypted/encrypted-bitschema.ts",
"envelope/envelope-schema": "src/envelope/envelope-schema.ts",
"hashchain/hashchain-schema":
"src/hashchain/hashchain-schema.ts",
"multisigned/multisigned-bitschema":
"src/multisigned/multisigned-bitschema.ts",
"fingerprint/schema-fingerprint":
"src/fingerprint/schema-fingerprint.ts",
"hash-registry/hash-registry":
"src/hash-registry/hash-registry.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/bitpacker",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 10_000,
},
});

View File

@@ -0,0 +1,61 @@
# @suaveplan/bitpacker-delta - Bitpacker delta plugin
## Summary
Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values.
<!-- begin:auto-docs -->
## Documentation tree
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
<!-- end:auto-docs --> - [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ - [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
## Install
```bash
bun add @suaveplan/bitpacker-delta
```
## Usage
```typescript
import { DeltaManager, calculateStringDiff, applyStringDiff, numberXor } from "@suaveplan/bitpacker-delta";
// Change detection
const dm = new DeltaManager();
dm.detectChanges({ x: 1, y: 2 }); // returns { x: 1, y: 2 }
dm.detectChanges({ x: 1, y: 2 }); // returns undefined (no change)
dm.detectChanges({ x: 1, y: 3 }); // returns { x: 1, y: 3 }
// String diff
const diff = calculateStringDiff("hello world", "hello there");
// { prefixLen: 6, suffixLen: 0, middleLen: 5, middle: "there" }
const reconstructed = applyStringDiff("hello world", diff);
// "hello there"
// Numeric XOR delta
const delta = numberXor(3.14159, 2.71828);
```
## Modules
- `src/index.ts` — `DeltaManager`, change detection API
- `src/xor-encoder.ts` — XOR-based numeric delta encoding
- `src/string-diff.ts` — Common prefix/suffix string diff encoding

View File

@@ -0,0 +1,48 @@
{
"name": "@suaveplan/bitpacker-delta",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bitpacker delta plugin",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
},
"dependencies": {
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,123 @@
# DeltaManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeltaManager` is the field-level delta detector for bit-packed objects. Given a current state and a `BitPackerSchema` describing it, it returns the ordered list of fields whose value has changed since the previous snapshot stored under the same state key. Encoders use the returned `FieldChange[]` to build a fixed-width bitmask and send only the changed fields, dramatically reducing bandwidth for high-frequency state-sync workloads (game replication, multi-peer document sync, telemetry deltas).
This is the port of suaveplan's upstream `core/delta/delta-manager.ts`. hardening replaces upstream's `Date.now()` with injectable `Clock` (CLAUDE.md determinism rule), upstream's `JSON.stringify`-based equality with a structural walker that respects `BitPackerSchema` field order (avoiding subtle false-positive diffs from key-order changes in nested objects), upstream's plain `Error` throws with `BitpackerError` codes, and validates `DeltaConfig` via Zod with strict-mode key rejection.
## Features
- Field-indexed `FieldChange[]` output matching `Object.keys(schema)` order for stable bit offsets
- Per-state-key snapshot store with LRU eviction (delegates to `DeltaStateManager`)
- Configurable `maxFieldCount` cap with `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED` enforcement
- Structural equality default (matches schema semantics) with opt-in `equalityMode: "json"` for upstream byte-compat
- Zod-validated `DeltaConfig`; unknown keys rejected with `BITPACKER.DELTA_INVALID_CONFIG`
- Threshold-driven `shouldUseDelta(originalBits, deltaBits)` for bandwidth-savings decisions
- Zero `Math.random` / `Date.now` in the implementation — fully deterministic given identical inputs
## Usage: Basic Example
```typescript
import { DeltaManager } from "@suaveplan/bitpacker-delta";
const schema = { x: "u32", y: "u32" } as const;
const dm = new DeltaManager({ mode: "field-level" });
// First call: no previous state, so no changes.
dm.detectChanges({ x: 0, y: 0 }, schema); // → []
dm.storePreviousState({ x: 0, y: 0 });
// Second call: x changed.
dm.detectChanges({ x: 5, y: 0 }, schema);
// → [{ index: 0, key: "x", previousValue: 0, currentValue: 5 }]
```
## Usage: Advanced Example
```typescript
import { DeltaManager } from "@suaveplan/bitpacker-delta";
const schema = {
pos: { x: "f32", y: "f32" },
vel: { x: "f32", y: "f32" },
health: "u8",
} as const;
// Per-peer state streams: one DeltaManager, many state keys.
const dm = new DeltaManager(
{ mode: "field-level", threshold: 25, maxFieldCount: 16 },
{ maxStates: 256 } // 256 simultaneous peers
);
for (const peer of peers) {
// Each peer is its own state stream.
const previous = dm.getPreviousState(peer.id);
const changes = dm.detectChanges(peer.state, schema, peer.id);
// Only send a delta if it saves at least 25% of bits.
const fullSize = bitWidthOf(schema);
const deltaSize = changes.length * 32 + Math.ceil(3 / 8) * 8;
if (dm.shouldUseDelta(fullSize, deltaSize)) {
sendDelta(peer.id, changes);
} else {
sendFull(peer.id, peer.state);
}
dm.storePreviousState(peer.state, peer.id);
}
```
## API Reference
### `new DeltaManager(config?: DeltaConfig, options?: DeltaManagerOptions)`
`config.mode` ∈ `"none" | "xor" | "string-diff" | "field-level"`. Default `"none"`.
`config.threshold` — bandwidth-savings %, 0–100. Default `0`.
`config.maxFieldCount` — max fields the bitmask can address. Default `64`.
`config.stateKey` — default key for `detectChanges` calls without one. Default `"default"`.
`config.equalityMode` — `"structural"` (default) or `"json"` (upstream-compat).
`options.maxStates` — capacity of the underlying state store. Default `100`.
Throws `BitpackerError(BITPACKER.DELTA_INVALID_CONFIG)` on invalid config.
### `detectChanges(current, schema, stateKey?) → FieldChange[]`
Returns the ordered list of fields whose `current` value differs from the stored snapshot under `stateKey`. First call (no snapshot) returns `[]`. Schema with more keys than `maxFieldCount` throws `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED`.
### `storePreviousState(state, stateKey?) → void`
Stores a deep-cloned snapshot of `state` for the next diff.
### `getPreviousState(stateKey?) → unknown | undefined`
### `hasPreviousState(stateKey?) → boolean`
Snapshot inspection helpers.
### `calculateSavings(originalBits, deltaBits) → number`
### `shouldUseDelta(originalBits, deltaBits) → boolean`
Bandwidth-savings calculation against the configured `threshold`.
### `clear() → void`
### `clearState(stateKey) → void`
### `getConfig() → Readonly<Required<DeltaConfig>>`
Lifecycle and inspection helpers.
## Implementation Notes
The structural-equality walker treats every `ArrayBuffer.isView` value (every typed-array variant) as a byte-by-byte comparison. `Date` instances compare via `getTime()`. Plain objects compare key-sets first (size + key membership), then recurse on values. Arrays compare length-then-elements. This matches `BitPackerSchema` semantics: two values that pack to identical bytes are equal.
The `"json"` equality mode is provided for upstream byte-compat but has known limitations: `JSON.stringify({ a: 1, b: 2 })` and `JSON.stringify({ b: 2, a: 1 })` produce different strings even though the values are semantically equal in a `BitPackerSchema` context. Consumers porting from upstream should switch to `"structural"` once they've verified no callers depend on key-order-sensitive equality.
The `maxFieldCount` cap is a defence: a hostile peer that sends a schema with thousands of fields could exhaust memory if the bitmask were unbounded. The default (64) accommodates typical state-sync workloads; consumers with larger schemas raise it explicitly.
Threshold semantics: `shouldUseDelta` returns `true` when `(originalBits - deltaBits) / originalBits * 100 >= threshold`. A `threshold` of `0` means "always prefer delta when smaller"; a `threshold` of `25` means "only prefer delta if it saves at least a quarter of the bits."

View File

@@ -0,0 +1,148 @@
/**
* Spec-conformance tests for DeltaManager. These tests pin the behaviours
* documented in `openspec/changes/bitpacker-stub-completion/specs/bitpacker-delta/spec.md`
* and the audit-driven additions: invalid-config rejection via Zod, no
* module-scope determinism violations.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BitPackerSchema } from "@suaveplan/types";
import { type DeltaConfig, DeltaManager } from "./delta-manager.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const SRC_ROOT = join(__dirname, "..");
describe("DeltaManager spec conformance", () => {
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown mode)", {
timeout: 5000,
}, () => {
const bad = { mode: "magic" } as unknown as DeltaConfig;
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
try {
new DeltaManager(bad);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DELTA_INVALID_CONFIG
);
}
});
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown key)", {
timeout: 5000,
}, () => {
const bad = {
mode: "field-level",
wrongKey: 123,
} as unknown as DeltaConfig;
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
try {
new DeltaManager(bad);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DELTA_INVALID_CONFIG
);
}
});
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (out-of-range threshold)", {
timeout: 5000,
}, () => {
const bad = {
mode: "field-level",
threshold: 150,
} as unknown as DeltaConfig;
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
});
it("uses structural equality by default (key-order-insensitive on nested objects)", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
// Different declaration order, same data:
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
expect(changes).toEqual([]);
});
it("opts into JSON equality when equalityMode is 'json'", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({
mode: "field-level",
equalityMode: "json",
});
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
// JSON.stringify is sensitive to key order — flagged as change.
expect(changes).toHaveLength(1);
});
it("no module-scope Date.now in implementation files", {
timeout: 5000,
}, () => {
const matches = grepImpl(/Date\.now\(\)/g);
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
});
it("no module-scope Math.random in implementation files", {
timeout: 5000,
}, () => {
const matches = grepImpl(/Math\.random\(\)/g);
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
});
});
/**
* Recursively walk `src/`, collect every `.ts` that's NOT a test file, and
* return all matches of `pattern` in non-comment lines. Used to verify the
* determinism rule. Comment lines are skipped because docblocks legitimately
* mention the forbidden tokens to explain the rule.
*/
function grepImpl(
pattern: RegExp
): readonly { file: string; line: number; text: string }[] {
const matches: { file: string; line: number; text: string }[] = [];
const visit = (dir: string): void => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
visit(full);
continue;
}
if (!entry.endsWith(".ts")) continue;
if (entry.endsWith(".test.ts")) continue;
if (entry.endsWith(".spec-conformance.test.ts")) continue;
const content = readFileSync(full, "utf8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? "";
if (isCommentLine(raw)) continue;
if (pattern.test(raw)) {
matches.push({
file: full,
line: i + 1,
text: raw.trim(),
});
}
pattern.lastIndex = 0; // reset for /g
}
}
};
visit(SRC_ROOT);
return matches;
}
function isCommentLine(line: string): boolean {
const trimmed = line.trim();
if (trimmed === "") return true;
if (trimmed.startsWith("//")) return true;
if (trimmed.startsWith("*")) return true; // inside /** ... */
if (trimmed.startsWith("/*")) return true;
return false;
}

View File

@@ -0,0 +1,316 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BitPackerSchema } from "@suaveplan/types";
import { DeltaManager } from "./delta-manager.js";
describe("DeltaManager", () => {
it("returns empty array on first call for a state key", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
const result = dm.detectChanges({ a: 1 }, schema);
expect(result).toEqual([]);
});
it("detects changed fields in schema order", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = {
a: "u8",
b: "-32",
c: "u16",
} as Record<string, BitPackerSchema>;
dm.storePreviousState({ a: 1, b: "x", c: 100 });
const changes = dm.detectChanges({ a: 1, b: "y", c: 200 }, schema);
expect(changes).toHaveLength(2);
expect(changes[0]).toEqual({
index: 1,
key: "b",
previousValue: "x",
currentValue: "y",
});
expect(changes[1]).toEqual({
index: 2,
key: "c",
previousValue: 100,
currentValue: 200,
});
});
it("detects no changes when values are identical (primitives)", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ a: 1 });
expect(dm.detectChanges({ a: 1 }, schema)).toEqual([]);
});
it("structural equality treats different key orders as equal in nested objects", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ pos: { x: 1, y: 2 } });
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
expect(changes).toEqual([]);
});
it("json equality mode flags key-order-different nested objects as changes", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({
mode: "field-level",
equalityMode: "json",
});
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ pos: { x: 1, y: 2 } });
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
// JSON stringify is sensitive to key order — upstream-compat mode.
expect(changes).toHaveLength(1);
});
it("throws DELTA_FIELD_LIMIT_EXCEEDED when schema exceeds maxFieldCount", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({
mode: "field-level",
maxFieldCount: 4,
});
const schema = {
a: "u8",
b: "u8",
c: "u8",
d: "u8",
e: "u8",
} as Record<string, BitPackerSchema>;
dm.storePreviousState({ a: 1, b: 2, c: 3, d: 4, e: 5 });
expect(() =>
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema)
).toThrow(BitpackerError);
try {
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema);
} catch (e) {
expect(e).toBeInstanceOf(BitpackerError);
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED
);
expect((e as BitpackerError).context).toMatchObject({
fieldCount: 5,
max: 4,
});
}
});
it("scopes diffs by stateKey — multiple peers don't cross-contaminate", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ v: 100 }, "peer-A");
dm.storePreviousState({ v: 200 }, "peer-B");
// Diffing peer-A's current against peer-A's snapshot:
expect(dm.detectChanges({ v: 105 }, schema, "peer-A")).toHaveLength(1);
// peer-B's current value happens to equal its snapshot:
expect(dm.detectChanges({ v: 200 }, schema, "peer-B")).toEqual([]);
});
it("storePreviousState deep-clones the snapshot", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = {
obj: "u32",
} as Record<string, BitPackerSchema>;
const original = { obj: { x: 1 } };
dm.storePreviousState(original);
(original.obj as { x: number }).x = 999;
// Diff against current (matches the original pre-mutation state):
const changes = dm.detectChanges({ obj: { x: 1 } }, schema);
expect(changes).toEqual([]);
});
it("calculateSavings is correct", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
expect(dm.calculateSavings(100, 25)).toBe(75);
expect(dm.calculateSavings(100, 100)).toBe(0);
expect(dm.calculateSavings(0, 0)).toBe(0); // /0 guard
});
it("shouldUseDelta respects threshold", { timeout: 5000 }, () => {
const dm = new DeltaManager({
mode: "field-level",
threshold: 25,
});
expect(dm.shouldUseDelta(100, 80)).toBe(false); // 20% savings, < 25%
expect(dm.shouldUseDelta(100, 75)).toBe(true); // 25% savings, exactly threshold
expect(dm.shouldUseDelta(100, 50)).toBe(true); // 50% savings
});
it("getPreviousState / hasPreviousState reflect storePreviousState", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
expect(dm.hasPreviousState()).toBe(false);
expect(dm.getPreviousState()).toBeUndefined();
dm.storePreviousState({ x: 1 });
expect(dm.hasPreviousState()).toBe(true);
expect(dm.getPreviousState()).toEqual({ x: 1 });
dm.storePreviousState({ x: 2 }, "peer-A");
expect(dm.hasPreviousState("peer-A")).toBe(true);
expect(dm.getPreviousState("peer-A")).toEqual({ x: 2 });
expect(dm.hasPreviousState("missing")).toBe(false);
expect(dm.getPreviousState("missing")).toBeUndefined();
});
it("clear drops all snapshots", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
dm.storePreviousState({ a: 1 }, "k1");
dm.storePreviousState({ a: 2 }, "k2");
dm.clear();
expect(dm.hasPreviousState("k1")).toBe(false);
expect(dm.hasPreviousState("k2")).toBe(false);
});
it("clearState drops one snapshot", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "field-level" });
dm.storePreviousState({ a: 1 }, "k1");
dm.storePreviousState({ a: 2 }, "k2");
dm.clearState("k1");
expect(dm.hasPreviousState("k1")).toBe(false);
expect(dm.hasPreviousState("k2")).toBe(true);
});
it("constructor with no args defaults to { mode: 'none' }", {
timeout: 5000,
}, () => {
const dm = new DeltaManager();
expect(dm.getConfig().mode).toBe("none");
});
it("structural equality treats null vs object as different", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
// First store: previous = { v: { x: 1 } }
dm.storePreviousState({ v: { x: 1 } });
// Current has v: null — null vs object asymmetric branches
const changes = dm.detectChanges(
{ v: null as unknown as object },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality treats different typeof as different", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ v: 1 });
const changes = dm.detectChanges(
{ v: "1" as unknown as number },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects typed-array length mismatch", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
const changes = dm.detectChanges(
{ buf: new Uint8Array([1, 2]) },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects object key-count mismatch", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
const changes = dm.detectChanges(
{ o: { a: 1 } as unknown as { a: number; b: number } },
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects object disjoint-keys mismatch", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
// Same key count but different keys
const changes = dm.detectChanges(
{
o: { a: 1, c: 2 } as unknown as { a: number; b: number },
},
schema
);
expect(changes).toHaveLength(1);
});
it("structural equality detects object same-keys-different-values", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ o: { a: 1, b: 2 } });
// Same keys, different value for `b`
const changes = dm.detectChanges({ o: { a: 1, b: 999 } }, schema);
expect(changes).toHaveLength(1);
});
it("getConfig returns the resolved config", { timeout: 5000 }, () => {
const dm = new DeltaManager({ mode: "xor", threshold: 50 });
const cfg = dm.getConfig();
expect(cfg.mode).toBe("xor");
expect(cfg.threshold).toBe(50);
expect(cfg.xorNumbers).toBe(true); // default
expect(cfg.diffStrings).toBe(false); // default
expect(cfg.maxFieldCount).toBe(64); // default
expect(cfg.stateKey).toBe("default"); // default
expect(cfg.equalityMode).toBe("structural"); // default
});
it("structural equality compares typed arrays element-wise", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
// Same content → no change
expect(
dm.detectChanges({ buf: new Uint8Array([1, 2, 3]) }, schema)
).toEqual([]);
// Different content → one change
expect(
dm.detectChanges({ buf: new Uint8Array([1, 2, 4]) }, schema)
).toHaveLength(1);
});
it("structural equality compares arrays length-then-elements", {
timeout: 5000,
}, () => {
const dm = new DeltaManager({ mode: "field-level" });
const schema = { arr: "u32" } as Record<string, BitPackerSchema>;
dm.storePreviousState({ arr: [1, 2, 3] });
expect(dm.detectChanges({ arr: [1, 2, 3] }, schema)).toEqual([]);
expect(dm.detectChanges({ arr: [1, 2] }, schema)).toHaveLength(1); // length differs
expect(dm.detectChanges({ arr: [1, 2, 4] }, schema)).toHaveLength(1); // element differs
});
});

View File

@@ -0,0 +1,286 @@
/**
* Field-level delta detector for bit-packed objects.
*
* Ported from suaveplan upstream `core/delta/delta-manager.ts`. Genesis-side
* hardening:
* - Replaces upstream's `Date.now()` with an injectable `Clock` (CLAUDE.md
* determinism rule).
* - Replaces upstream's `JSON.stringify`-based equality (which is sensitive
* to key order) with a structural walker that respects `BitPackerSchema`
* field order. The legacy stringify behaviour is available behind opt-in
* `equalityMode: "json"` for byte-compat with upstream payloads.
* - Replaces upstream's plain-`Error` throws with `BitpackerError` codes.
* - Validates `DeltaConfig` via Zod at construction; unknown fields and
* invalid `mode` values are rejected with `BITPACKER.DELTA_INVALID_CONFIG`.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type { BitPackerSchema } from "@suaveplan/types";
import z from "zod";
import { DeltaStateManager } from "../delta-state/delta-state.js";
const DEFAULT_STATE_KEY = "default";
const DEFAULT_MAX_FIELD_COUNT = 64;
const DEFAULT_THRESHOLD = 0;
/**
* One field that changed between the previous and current state. Indexed by
* `Object.keys(schema)` order so encoders can rely on stable bit offsets.
*/
export interface FieldChange {
readonly index: number;
readonly key: string;
readonly previousValue: unknown;
readonly currentValue: unknown;
}
/**
* Configuration accepted by `new DeltaManager`. Validated via Zod at
* construction; unknown keys are rejected.
*/
export interface DeltaConfig {
/** Strategy mode. `"none"` returns the value unchanged on every detect. */
readonly mode: "none" | "xor" | "string-diff" | "field-level";
/** XOR numeric fields when packing the delta. Default `true`. */
readonly xorNumbers?: boolean;
/** Diff string fields when packing. Default `false`. */
readonly diffStrings?: boolean;
/** Bandwidth-saving threshold (% bits saved) before delta is preferred. Default `0`. */
readonly threshold?: number;
/** Maximum fields per object the field-level mask can address. Default `64`. */
readonly maxFieldCount?: number;
/** Default state key for `detectChanges` calls without one. */
readonly stateKey?: string;
/**
* `"structural"` (default) walks the value respecting schema field order.
* `"json"` falls back to `JSON.stringify` equality (upstream-compat mode).
*/
readonly equalityMode?: "structural" | "json";
}
const DeltaConfigSchema = z
.object({
mode: z.enum(["none", "xor", "string-diff", "field-level"]),
xorNumbers: z.boolean().optional(),
diffStrings: z.boolean().optional(),
threshold: z.number().min(0).max(100).optional(),
maxFieldCount: z.number().int().min(1).optional(),
stateKey: z.string().min(1).optional(),
equalityMode: z.enum(["structural", "json"]).optional(),
})
.strict();
type ResolvedConfig = Required<DeltaConfig>;
/**
* Detects field-level changes between successive states of an object.
*
* Stateful: each `DeltaManager` owns a `DeltaStateManager` keyed by string.
* Consumers store one snapshot per logical state stream (per peer, per
* session, etc.) and call `detectChanges(current, schema)` on each tick to
* get an ordered `FieldChange[]`. The returned indices match
* `Object.keys(schema)` order so encoders can pack a fixed-width bitmask.
*/
export class DeltaManager {
private readonly stateManager: DeltaStateManager;
private readonly config: ResolvedConfig;
public constructor(
config: DeltaConfig = { mode: "none" },
options: DeltaManagerOptions = {}
) {
const parsed = DeltaConfigSchema.safeParse(config);
if (!parsed.success) {
throw new BitpackerError(
BITPACKER_CODES.DELTA_INVALID_CONFIG,
`Invalid DeltaConfig: ${parsed.error.message}`,
{ context: { issues: parsed.error.issues } }
);
}
this.stateManager = new DeltaStateManager(options.maxStates);
this.config = {
mode: parsed.data.mode,
xorNumbers: parsed.data.xorNumbers ?? true,
diffStrings: parsed.data.diffStrings ?? false,
threshold: parsed.data.threshold ?? DEFAULT_THRESHOLD,
maxFieldCount: parsed.data.maxFieldCount ?? DEFAULT_MAX_FIELD_COUNT,
stateKey: parsed.data.stateKey ?? DEFAULT_STATE_KEY,
equalityMode: parsed.data.equalityMode ?? "structural",
};
}
/**
* Compute the ordered list of fields whose value changed between the
* stored previous state (under `stateKey ?? config.stateKey`) and
* `current`. On the first call for a state key (no previous stored), the
* result is an empty array. Field indices match `Object.keys(schema)`.
*/
public detectChanges(
current: Record<string, unknown>,
schema: Record<string, BitPackerSchema>,
stateKey?: string
): FieldChange[] {
const fieldKeys = Object.keys(schema);
if (fieldKeys.length > this.config.maxFieldCount) {
throw new BitpackerError(
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED,
`Schema has ${fieldKeys.length} fields; maxFieldCount is ${this.config.maxFieldCount}`,
{
context: {
fieldCount: fieldKeys.length,
max: this.config.maxFieldCount,
},
}
);
}
const key = stateKey ?? this.config.stateKey;
const previous = this.stateManager.getPreviousState(key) as
| Record<string, unknown>
| undefined;
if (previous === undefined) {
return [];
}
const changes: FieldChange[] = [];
for (let index = 0; index < fieldKeys.length; index++) {
// `Object.keys` always returns defined strings, so the
// index-access is sound; cast tames `noUncheckedIndexedAccess`.
const fieldKey = fieldKeys[index] as string;
const currentValue = current[fieldKey];
const previousValue = previous[fieldKey];
if (!this.valuesEqual(currentValue, previousValue)) {
changes.push({
index,
key: fieldKey,
previousValue,
currentValue,
});
}
}
return changes;
}
/**
* Store `state` as the new "previous" snapshot under `key`. The next
* `detectChanges` call will diff against this. Snapshot is deep-cloned by
* the underlying `DeltaStateManager`.
*/
public storePreviousState(state: unknown, stateKey?: string): void {
this.stateManager.storePreviousState(
stateKey ?? this.config.stateKey,
state
);
}
public getPreviousState(stateKey?: string): unknown | undefined {
return this.stateManager.getPreviousState(
stateKey ?? this.config.stateKey
);
}
public hasPreviousState(stateKey?: string): boolean {
return this.stateManager.hasPreviousState(
stateKey ?? this.config.stateKey
);
}
/**
* Compute a savings percentage. `0%` means delta is the same size as
* full; `100%` means delta is empty. Used by `shouldUseDelta` to decide
* whether the delta is worth sending.
*/
public calculateSavings(originalBits: number, deltaBits: number): number {
if (originalBits === 0) return 0;
return ((originalBits - deltaBits) / originalBits) * 100;
}
public shouldUseDelta(originalBits: number, deltaBits: number): boolean {
return (
this.calculateSavings(originalBits, deltaBits) >=
this.config.threshold
);
}
public clear(): void {
this.stateManager.clear();
}
public clearState(stateKey: string): void {
this.stateManager.clearState(stateKey);
}
public getConfig(): Readonly<ResolvedConfig> {
return this.config;
}
/**
* Structural deep equality respecting bit-packer field semantics.
*
* - In `"json"` mode, falls back to `JSON.stringify` equality
* (upstream-compat — sensitive to key order and not safe for typed
* arrays without custom replacer).
* - In `"structural"` (default), walks values: primitives compare via
* `===`, typed arrays compare element-wise, arrays compare deeply,
* plain objects compare key-set-then-values.
*
* Note: Maps and Sets are NOT supported in structural mode (they would
* never be packed via a `BitPackerSchema` anyway). If supplied they
* compare by reference (identity).
*/
private valuesEqual(a: unknown, b: unknown): boolean {
if (this.config.equalityMode === "json") {
return JSON.stringify(a) === JSON.stringify(b);
}
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!this.valuesEqual(a[i], b[i])) return false;
}
return true;
}
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
const aArr = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
const bArr = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
if (aArr.length !== bArr.length) return false;
for (let i = 0; i < aArr.length; i++) {
if (aArr[i] !== bArr[i]) return false;
}
return true;
}
if (typeof a === "object" && typeof b === "object") {
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao);
const bKeys = Object.keys(bo);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!Object.hasOwn(bo, key)) return false;
if (!this.valuesEqual(ao[key], bo[key])) return false;
}
return true;
}
return false;
}
}
/**
* Construction-time options for `DeltaManager`. Distinct from runtime
* `DeltaConfig` (which controls behaviour); this controls the underlying
* state-store capacity.
*/
export interface DeltaManagerOptions {
/** Capacity of the underlying `DeltaStateManager`. Default 100. */
readonly maxStates?: number;
}

View File

@@ -0,0 +1,90 @@
# DeltaStateManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeltaStateManager` keeps deep-cloned snapshots of previous values per state key, so that a delta encoder can diff the current value against its predecessor without worrying about caller-side mutations. It is the storage layer for `DeltaManager`; consumers usually go through `DeltaManager` rather than calling this class directly.
The store is bounded: an LRU eviction policy drops the oldest key once the configured capacity is reached. Default capacity is 100 keys, which is plenty for most workloads (one key per per-peer state stream). Re-storing under an existing key updates the value in place without advancing its LRU position — this matches upstream semantics so that frequently-updated keys do not unfairly evict rarely-updated ones.
## Features
- Deep-cloning on store: `Date`, all typed-array variants (`Uint8Array`, `Int8Array`, `Uint16Array`, `Int16Array`, `Uint32Array`, `Int32Array`, `Float32Array`, `Float64Array`), arrays, and plain objects are all isolated from caller mutations.
- Insertion-order LRU eviction; `maxStates` configurable (default 100).
- `clear()`, `clearState(key)`, `getStateCount()`, `getMaxStates()` accessors for inspection and reset.
- Zero `Math.random` / `Date.now` calls — fully deterministic given identical inputs.
## Usage: Basic Example
```typescript
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
const states = new DeltaStateManager(100);
states.storePreviousState("player-1", { x: 0, y: 0 });
const previous = states.getPreviousState("player-1");
// → { x: 0, y: 0 }
```
## Usage: Advanced Example
```typescript
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
const states = new DeltaStateManager(3);
states.storePreviousState("a", { v: 1 });
states.storePreviousState("b", { v: 2 });
states.storePreviousState("c", { v: 3 });
states.storePreviousState("d", { v: 4 }); // evicts "a"
states.hasPreviousState("a"); // → false (evicted)
states.hasPreviousState("d"); // → true
// Mutating the original after storing does NOT touch the snapshot:
const original = { x: 0 };
states.storePreviousState("e", original);
original.x = 999;
states.getPreviousState("e"); // → { x: 0 }
```
## API Reference
### `new DeltaStateManager(maxStates?: number)`
- `maxStates` — positive integer; default `100`. Throws `RangeError` if non-integer or `< 1`.
### `storePreviousState(key: string, state: unknown): void`
Stores a deep-cloned copy of `state` under `key`. If `key` already exists, the value is updated in place; LRU position is unchanged. If the store is at capacity and this is a new key, the oldest key is evicted.
### `getPreviousState(key: string): unknown | undefined`
Returns the stored snapshot, or `undefined` if the key has never been stored or has been evicted.
### `hasPreviousState(key: string): boolean`
True if a snapshot exists for `key`.
### `clear(): void`
Drops every snapshot.
### `clearState(key: string): void`
Drops the snapshot for one key. No-op if absent.
### `getStateCount(): number`
Number of stored snapshots.
### `getMaxStates(): number`
Configured maximum capacity.
## Implementation Notes
The deep-clone walker handles the eight typed-array variants explicitly because the generic object-clone path (`Object.hasOwn` over keys) does not preserve `Uint8Array`-vs-plain-array distinction. Maps and Sets are intentionally NOT cloned: delta-state semantics assume keys are strings and values are plain data; if a consumer needs `Map`/`Set`, they should canonicalise to a plain object first.
Performance: insertion is O(1) amortised; eviction is O(1) (Array `.shift()` on a small bounded array). Lookup is O(1). The `stateKeys` array tracks insertion order without using a `Map`'s native iteration (some legacy environments don't preserve insertion order — though every modern runtime does — so the explicit array is defensive).

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