143 lines
5.9 KiB
Markdown
143 lines
5.9 KiB
Markdown
# 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.
|