feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

1312
relay-contracts/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "relay-contracts",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Frozen shared Zod schemas + inferred TS types and binary codecs for the rendezvous-relay service (W0-CONTRACTS). Dependency-free coordination point — no ws/pg/DOM ambient. See docs/PLAN_RELAY_INDEX.md §4.",
"engines": {
"node": ">=18"
},
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.9.3",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}

View File

@@ -0,0 +1,66 @@
/**
* Isomorphic base64url (RFC 4648 §5, no padding) over bytes and UTF-8 strings.
* Hand-rolled (no Buffer / no atob) so both the Node relay (P1) and the browser
* bundle (P6) import it unchanged and agree byte-for-byte on the §4.3 token subprotocol.
*/
import { ContractDecodeError } from './errors.js'
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
const DECODE_MAP: Readonly<Record<string, number>> = Object.fromEntries(
[...ALPHABET].map((ch, i) => [ch, i]),
)
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder('utf-8', { fatal: true })
/** Encode raw bytes to a base64url string (no `=` padding). */
export function encodeBase64UrlBytes(bytes: Uint8Array): string {
let out = ''
for (let i = 0; i < bytes.length; i += 3) {
const b0 = bytes[i]!
const b1 = i + 1 < bytes.length ? bytes[i + 1]! : 0
const b2 = i + 2 < bytes.length ? bytes[i + 2]! : 0
const triple = (b0 << 16) | (b1 << 8) | b2
const remaining = bytes.length - i
out += ALPHABET[(triple >> 18) & 0x3f]
out += ALPHABET[(triple >> 12) & 0x3f]
if (remaining > 1) out += ALPHABET[(triple >> 6) & 0x3f]
if (remaining > 2) out += ALPHABET[triple & 0x3f]
}
return out
}
/** Decode a base64url string (padding tolerated) back to raw bytes. */
export function decodeBase64UrlBytes(value: string): Uint8Array {
const clean = value.replace(/=+$/, '')
if (/[^A-Za-z0-9\-_]/.test(clean)) {
throw new ContractDecodeError('invalid base64url: contains non-alphabet characters')
}
const bytes: number[] = []
for (let i = 0; i < clean.length; i += 4) {
const c0 = DECODE_MAP[clean[i]!]!
const c1 = i + 1 < clean.length ? DECODE_MAP[clean[i + 1]!]! : 0
const c2 = i + 2 < clean.length ? DECODE_MAP[clean[i + 2]!] : undefined
const c3 = i + 3 < clean.length ? DECODE_MAP[clean[i + 3]!] : undefined
const triple = (c0 << 18) | (c1 << 12) | ((c2 ?? 0) << 6) | (c3 ?? 0)
bytes.push((triple >> 16) & 0xff)
if (c2 !== undefined) bytes.push((triple >> 8) & 0xff)
if (c3 !== undefined) bytes.push(triple & 0xff)
}
return Uint8Array.from(bytes)
}
/** Encode a UTF-8 string to base64url. */
export function encodeBase64UrlString(value: string): string {
return encodeBase64UrlBytes(textEncoder.encode(value))
}
/** Decode a base64url string to a UTF-8 string. */
export function decodeBase64UrlString(value: string): string {
try {
return textDecoder.decode(decodeBase64UrlBytes(value))
} catch (err) {
if (err instanceof ContractDecodeError) throw err
throw new ContractDecodeError('base64url payload is not valid UTF-8')
}
}

View File

@@ -0,0 +1,83 @@
/**
* Big-endian (network order) integer read/write helpers shared by every binary codec.
* Kept dependency-free (no Buffer) so the browser bundle (P6) imports it unchanged.
*/
import { ContractDecodeError, ContractEncodeError } from './errors.js'
export const U32_MAX = 0xffff_ffff
export const U64_MAX = 0xffff_ffff_ffff_ffffn
/** Assert a value is a non-negative integer that fits in uint32; throw otherwise. */
export function assertUint32(value: number, field: string): void {
if (!Number.isInteger(value) || value < 0 || value > U32_MAX) {
throw new ContractEncodeError(`${field} must be a uint32 (0..${U32_MAX}); got ${value}`)
}
}
/** Write a uint32 big-endian into `out` at `offset`. Returns the next offset. */
export function writeUint32BE(out: Uint8Array, offset: number, value: number): number {
assertUint32(value, 'uint32')
out[offset] = (value >>> 24) & 0xff
out[offset + 1] = (value >>> 16) & 0xff
out[offset + 2] = (value >>> 8) & 0xff
out[offset + 3] = value & 0xff
return offset + 4
}
/** Read a uint32 big-endian from `buf` at `offset`. */
export function readUint32BE(buf: Uint8Array, offset: number): number {
if (offset + 4 > buf.length) {
throw new ContractDecodeError(`truncated uint32 at offset ${offset} (len ${buf.length})`)
}
return (
((buf[offset]! << 24) >>> 0) +
(buf[offset + 1]! << 16) +
(buf[offset + 2]! << 8) +
buf[offset + 3]!
)
}
/** Write a uint64 big-endian (from a bigint) into `out` at `offset`. Returns next offset. */
export function writeUint64BE(out: Uint8Array, offset: number, value: bigint): number {
if (value < 0n || value > U64_MAX) {
throw new ContractEncodeError(`uint64 out of range: ${value}`)
}
for (let i = 7; i >= 0; i--) {
out[offset + i] = Number(value & 0xffn)
value >>= 8n
}
return offset + 8
}
/** Read a uint64 big-endian as a bigint from `buf` at `offset`. */
export function readUint64BE(buf: Uint8Array, offset: number): bigint {
if (offset + 8 > buf.length) {
throw new ContractDecodeError(`truncated uint64 at offset ${offset} (len ${buf.length})`)
}
let value = 0n
for (let i = 0; i < 8; i++) {
value = (value << 8n) | BigInt(buf[offset + i]!)
}
return value
}
/** Read a uint64 that must fit in a JS safe integer (§4.1 payloadLen safe-int guard). */
export function readSafeUint64BE(buf: Uint8Array, offset: number, field: string): number {
const value = readUint64BE(buf, offset)
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new ContractDecodeError(`${field} exceeds Number.MAX_SAFE_INTEGER: ${value}`)
}
return Number(value)
}
/** Concatenate byte chunks into one Uint8Array. */
export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
const total = chunks.reduce((n, c) => n + c.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const c of chunks) {
out.set(c, offset)
offset += c.length
}
return out
}

View File

@@ -0,0 +1,33 @@
/**
* §4.3 FIX 5 — WS-upgrade capability-token transport over `Sec-WebSocket-Protocol`.
*
* The browser's native WebSocket API cannot set request headers, so the token rides the
* subprotocol list: the client opens
* new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)])
* The relay reads the token from the `term.token.<b64u>` entry, strips it before verify, and
* echoes ONLY `term.relay.v1` back — echoing the token would leak the bearer secret.
*/
import { encodeBase64UrlString, decodeBase64UrlString } from '../base64url.js'
/** The app subprotocol — the ONLY value ever accepted/echoed in the handshake response. */
export const APP_SUBPROTOCOL = 'term.relay.v1' as const
/** The token entry = this prefix + base64url(rawToken). */
export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const
/** Build the token subprotocol entry: `term.token.` + base64url(rawToken). */
export function encodeTokenSubprotocol(rawToken: string): string {
return TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString(rawToken)
}
/**
* Extract the raw token from a subprotocol list: find the `term.token.` entry, strip the
* prefix, base64url-decode. Returns null if absent (caller rejects with 401). Ignores the
* app-subprotocol entry. Throws ContractDecodeError if the entry exists but is malformed.
*/
export function extractTokenFromSubprotocols(values: readonly string[]): string | null {
const entry = values.find((v) => v.startsWith(TOKEN_SUBPROTOCOL_PREFIX))
if (entry === undefined) return null
const encoded = entry.slice(TOKEN_SUBPROTOCOL_PREFIX.length)
return decodeBase64UrlString(encoded)
}

View File

@@ -0,0 +1,61 @@
/**
* §4.3 Capability token — signed, stateless, short-TTL (Ed25519-signed PASETO/JWS).
*
* `sub`/`host` come from the authenticated principal + registry (INV3) — NEVER client input.
* Scopes exactly one host and a least-privilege rights subset ('attach' ⊉ 'kill'). Revocable
* by `jti` (INV12). relay-contracts owns the SHAPE + Zod validation; the signature VERIFY is
* crypto owned by P5 (`relay-auth`) — `verifyCapabilityToken` is a frozen signature here.
*/
import { z } from 'zod'
import { NotImplementedInContractsError } from '../errors.js'
/** Rights a token may carry (string-literal union, §4.3). */
export type CapabilityRight = 'attach' | 'manage' | 'kill'
export const CapabilityRightSchema = z.enum(['attach', 'manage', 'kill'])
export interface CapabilityToken {
readonly sub: string // principal id — from authenticated session, NOT client
readonly aud: string // subdomain this token is valid at (Host-confusion guard, INV1)
readonly host: string // exact host_id scope — single host, never wildcard
readonly rights: readonly CapabilityRight[] // least-privilege subset
readonly iat: number
readonly exp: number // short TTL
readonly jti: string // unique id for revocation-list lookup
}
/**
* Shape/claims validation for a DECODED token body (structural checks only — the Ed25519
* signature is verified by P5). `rights` must be non-empty and unique.
*/
export const CapabilityTokenSchema = z
.object({
sub: z.string().min(1),
aud: z.string().min(1),
host: z.string().min(1),
rights: z.array(CapabilityRightSchema).nonempty(),
iat: z.number().int().nonnegative(),
exp: z.number().int().nonnegative(),
jti: z.string().min(1),
})
.strict()
.superRefine((tok, ctx) => {
if (new Set(tok.rights).size !== tok.rights.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'rights must be unique' })
}
if (tok.exp <= tok.iat) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'exp must be after iat' })
}
})
/**
* FROZEN SIGNATURE (§4.3). Verifies the Ed25519 signature, `aud === expectedAud`, and
* `now < exp`, returning the validated claims. The crypto IMPLEMENTATION lives in P5
* `relay-auth`; relay-contracts owns only the shape. Do not call from relay-contracts.
*/
export function verifyCapabilityToken(
_raw: string,
_expectedAud: string,
_now: number,
): CapabilityToken {
throw new NotImplementedInContractsError('verifyCapabilityToken', 'P5 relay-auth')
}

View File

@@ -0,0 +1,61 @@
/**
* §4.4 E2E crypto — FROZEN SIGNATURES ONLY. The AEAD/ECDH/HKDF implementations live in P4
* `relay-e2e/` (which re-exports these exact signatures); relay-contracts owns the SHAPE so
* P2/P4/P6 type-check against one surface. Calling any of these from relay-contracts throws.
*
* Per PLAN_RELAY_INDEX §2.1: "Crypto implementations of the §4.4 functions live in P4's
* relay-e2e/ … relay-contracts owns the shapes."
*/
import { NotImplementedInContractsError } from '../errors.js'
import type {
AeadKey,
AuthorizedDeviceContext,
AeadAlg,
ClientHandshake,
E2EEnvelope,
E2ESession,
HandshakeResult,
ReplayKeyParams,
SessionRole,
} from './types.js'
const OWNER = 'P4 relay-e2e'
/** SYNCHRONOUS AEAD seal — aad = directionLabel‖seq (§4.4). Impl: P4. */
export function sealFrame(_key: AeadKey, _seq: bigint, _plaintext: Uint8Array): E2EEnvelope {
throw new NotImplementedInContractsError('sealFrame', OWNER)
}
/** SYNCHRONOUS AEAD open — verifies tag + seq, returns plaintext (§4.4). Impl: P4. */
export function openFrame(_key: AeadKey, _env: E2EEnvelope, _expectedSeq: bigint): Uint8Array {
throw new NotImplementedInContractsError('openFrame', OWNER)
}
/** Build a stateful directional session from a handshake result (§4.4). Impl: P4. */
export function createE2ESession(_role: SessionRole, _result: HandshakeResult): E2ESession {
throw new NotImplementedInContractsError('createE2ESession', OWNER)
}
/** Client-side handshake factory P6 calls (§4.4). Impl: P4. */
export function buildClientHandshake(
_ctx: AuthorizedDeviceContext,
_hostId: string,
_aeadOffer: readonly AeadAlg[],
): ClientHandshake {
throw new NotImplementedInContractsError('buildClientHandshake', OWNER)
}
/** FIX 3 recoverable replay content-key: HKDF(hostContentSecret, salt=sessionId, REPLAY_KDF_INFO). Impl: P4. */
export function deriveContentKey(_p: ReplayKeyParams): AeadKey {
throw new NotImplementedInContractsError('deriveContentKey', OWNER)
}
/** FIX 3 agent seals replay-bound output under K_content (§4.4, P2 uses). Impl: P4. */
export function sealReplayFrame(_k: AeadKey, _seq: bigint, _plaintext: Uint8Array): E2EEnvelope {
throw new NotImplementedInContractsError('sealReplayFrame', OWNER)
}
/** FIX 3 browser client-side replay/preview decrypt under K_content (§4.4, P6 uses). Impl: P4. */
export function openReplayCiphertext(_k: AeadKey, _dataPayload: Uint8Array): Uint8Array {
throw new NotImplementedInContractsError('openReplayCiphertext', OWNER)
}

View File

@@ -0,0 +1,95 @@
/**
* §4.4 E2EEnvelope binary codec + deterministic nonce helper.
*
* relay-contracts owns the WIRE SHAPE of the envelope (so P2 agent and P6 browser frame it
* identically); the AEAD that fills `ciphertext`/`tag` lives in P4. This is a pure byte layout.
*
* Envelope wire layout (big-endian), fixed 14-byte header then three variable regions:
* offset size field
* 0 8 seq uint64 (strictly monotonic per direction, INV13)
* 8 1 nonceLen uint8
* 9 4 ciphertextLen uint32
* 13 1 tagLen uint8
* 14 nonceLen nonce
* .. ciphertextLen ciphertext
* .. tagLen tag
*/
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
import {
U32_MAX,
concatBytes,
readUint32BE,
readUint64BE,
writeUint32BE,
writeUint64BE,
} from '../bytes.js'
import { NONCE_BYTES, type AeadAlg, type E2EEnvelope } from './types.js'
const ENVELOPE_HEADER_BYTES = 14
const U8_MAX = 0xff
/**
* Deterministic per-`seq` nonce = f(seq) (§4.4): the 8-byte big-endian sequence number in the
* LAST 8 bytes of a zero-filled nonce of the alg's width. No random branch (INV13). The
* direction-split subkeys (c2h/h2c) ensure the same (seq → nonce) never reuses a (key, nonce).
*/
export function nonceForSeq(seq: bigint, alg: AeadAlg): Uint8Array {
const width = NONCE_BYTES[alg]
const nonce = new Uint8Array(width)
writeUint64BE(nonce, width - 8, seq)
return nonce
}
/** Encode an E2EEnvelope to its wire bytes. */
export function encodeEnvelope(env: E2EEnvelope): Uint8Array {
if (env.nonce.length > U8_MAX) {
throw new ContractEncodeError(`nonce too long: ${env.nonce.length} > ${U8_MAX}`)
}
if (env.tag.length > U8_MAX) {
throw new ContractEncodeError(`tag too long: ${env.tag.length} > ${U8_MAX}`)
}
if (env.ciphertext.length > U32_MAX) {
throw new ContractEncodeError(`ciphertext too long: ${env.ciphertext.length} > ${U32_MAX}`)
}
const header = new Uint8Array(ENVELOPE_HEADER_BYTES)
let offset = writeUint64BE(header, 0, env.seq)
header[offset] = env.nonce.length
offset += 1
offset = writeUint32BE(header, offset, env.ciphertext.length)
header[offset] = env.tag.length
return concatBytes([header, env.nonce, env.ciphertext, env.tag])
}
/** Decode wire bytes into an E2EEnvelope. Rejects truncated or trailing-byte inputs. */
export function decodeEnvelope(buf: Uint8Array): E2EEnvelope {
if (buf.length < ENVELOPE_HEADER_BYTES) {
throw new ContractDecodeError(
`envelope shorter than header: ${buf.length} < ${ENVELOPE_HEADER_BYTES}`,
)
}
const seq = readUint64BE(buf, 0)
const nonceLen = buf[8]!
const ciphertextLen = readUint32BE(buf, 9)
const tagLen = buf[13]!
const total = ENVELOPE_HEADER_BYTES + nonceLen + ciphertextLen + tagLen
if (buf.length !== total) {
throw new ContractDecodeError(
`envelope length mismatch: buffer ${buf.length}, header implies ${total}`,
)
}
let offset = ENVELOPE_HEADER_BYTES
const nonce = buf.slice(offset, offset + nonceLen)
offset += nonceLen
const ciphertext = buf.slice(offset, offset + ciphertextLen)
offset += ciphertextLen
const tag = buf.slice(offset, offset + tagLen)
return { seq, nonce, ciphertext, tag }
}
/** Read the uint32 ciphertext length directly (helper for callers that pre-parse). */
export function readCiphertextLen(buf: Uint8Array): number {
if (buf.length < ENVELOPE_HEADER_BYTES) {
throw new ContractDecodeError('envelope truncated before ciphertextLen')
}
return readUint32BE(buf, 9)
}

View File

@@ -0,0 +1,126 @@
/**
* §4.4 E2E type surface (FIX 2 + FIX 3). relay-contracts owns the SHAPES + the ENVELOPE
* binary codec; the AEAD/ECDH crypto IMPLEMENTATIONS live in P4 `relay-e2e/` (which
* re-exports these signatures). No plan redefines any of these locally.
*/
import { z } from 'zod'
/** AEAD algorithm (string-literal union, §4.4). */
export type AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305'
export const AeadAlgSchema = z.enum(['aes-256-gcm', 'xchacha20-poly1305'])
/** Nonce widths per AEAD (§4.4: 12B GCM / 24B XChaCha). */
export const NONCE_BYTES: Readonly<Record<AeadAlg, number>> = {
'aes-256-gcm': 12,
'xchacha20-poly1305': 24,
}
/** AEAD key length in bytes (both algs use a 256-bit key). */
export const AEAD_KEY_BYTES = 32 as const
/**
* Opaque wrapper over a 32-byte key (NOT a WebCrypto CryptoKey) — FIX 2. The `unique symbol`
* makes it nominal so a raw Uint8Array can't be passed where an AeadKey is required.
*/
export type AeadKey = { readonly __aeadKey: unique symbol }
export type SessionRole = 'client' | 'host'
/** Direction-split subkeys (FIX 2): client seals c2h/opens h2c; host seals h2c/opens c2h. */
export interface DirectionalKeys {
readonly c2h: AeadKey
readonly h2c: AeadKey
}
/** HKDF label constants (§4.4) — frozen so both sides derive identical subkeys. */
export const HKDF_INFO = 'relay-e2e/v1' as const
export const HKDF_INFO_C2H = 'relay-e2e/v1/c2h' as const
export const HKDF_INFO_H2C = 'relay-e2e/v1/h2c' as const
/** client_hello (§4.4). `deviceAuthProof` is minted by P5, shape-validated by P4 (FIX 6b). */
export interface ClientHello {
readonly clientEphPub: Uint8Array
readonly clientNonce: Uint8Array
readonly aeadOffer: readonly AeadAlg[]
readonly deviceAuthProof: string
}
export const ClientHelloSchema = z
.object({
clientEphPub: z.instanceof(Uint8Array),
clientNonce: z.instanceof(Uint8Array),
aeadOffer: z.array(AeadAlgSchema).nonempty(),
deviceAuthProof: z.string().min(1),
})
.strict()
/** host_hello (§4.4). `sig` = Ed25519(agent_pubkey) over the transcript. */
export interface HostHello {
readonly hostEphPub: Uint8Array
readonly hostNonce: Uint8Array
readonly aeadChoice: AeadAlg
readonly enrollFpr: string
readonly sig: Uint8Array
}
export const HostHelloSchema = z
.object({
hostEphPub: z.instanceof(Uint8Array),
hostNonce: z.instanceof(Uint8Array),
aeadChoice: AeadAlgSchema,
enrollFpr: z.string().min(1),
sig: z.instanceof(Uint8Array),
})
.strict()
/** Encrypted frame envelope carried inside every §4.1 DATA payload once E2E is live (§4.4). */
export interface E2EEnvelope {
readonly seq: bigint // strictly monotonic per direction (anti-replay, INV13)
readonly nonce: Uint8Array // DETERMINISTIC f(seq)
readonly ciphertext: Uint8Array // AEAD(subkey, plaintext, aad = directionLabel‖seq)
readonly tag: Uint8Array // AEAD auth tag; failure ⇒ drop + tear down
}
export interface HandshakeResult {
readonly keys: DirectionalKeys
readonly aead: AeadAlg
readonly transcript: Uint8Array
}
/** Stateful directional wrapper over a stream (holds BOTH subkeys + role + seq guards). */
export interface E2ESession {
readonly role: SessionRole
seal(plaintext: Uint8Array): Uint8Array
open(dataPayload: Uint8Array): Uint8Array
rederive(next: HandshakeResult): void
}
export interface ClientHandshake {
start(): Promise<ClientHello>
onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult>
}
/** P4-internal helper (declared here only to type AuthorizedDeviceContext) — TOFU/pin store. */
export interface DevicePinStore {
get(hostId: string): Promise<string | null>
pin(hostId: string, enrollFpr: string): Promise<void>
}
export interface DeviceAuthProofProvider {
proofFor(
hostId: string,
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
): Promise<string>
}
export interface AuthorizedDeviceContext {
readonly deviceAuthProofProvider: DeviceAuthProofProvider // P5 — per-handshake proof (FIX 6b)
readonly pinStore: DevicePinStore // P6 — TOFU/pin over recomputed fingerprints
readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay
}
/** FIX 3 replay content-key derivation params (§4.4). */
export interface ReplayKeyParams {
readonly hostContentSecret: Uint8Array
readonly sessionId: string
readonly alg: AeadAlg
}
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const

View File

@@ -0,0 +1,37 @@
/**
* Shared explicit error types for relay-contracts.
*
* `coding-style.md`: "Never silently swallow errors" — every codec throws a typed,
* descriptive error at the parse boundary instead of returning a partial/ambiguous value.
*/
/** Thrown when raw bytes / strings fail to decode into a frozen contract shape. */
export class ContractDecodeError extends Error {
constructor(message: string) {
super(message)
this.name = 'ContractDecodeError'
}
}
/** Thrown when a value violates a frozen contract invariant before encoding. */
export class ContractEncodeError extends Error {
constructor(message: string) {
super(message)
this.name = 'ContractEncodeError'
}
}
/**
* Thrown by crypto-shaped functions whose *implementation* lives in another relay
* package (P4 `relay-e2e/`, P5 `relay-auth/`). `relay-contracts` owns only the SHAPE
* (signature + types) per PLAN_RELAY_INDEX §2.1; the runtime is provided downstream.
*/
export class NotImplementedInContractsError extends Error {
constructor(fnName: string, owner: string) {
super(
`${fnName} is a frozen SIGNATURE only in relay-contracts; its crypto implementation lives in ${owner}. ` +
`Import the implementation from that package — do not call it from relay-contracts.`,
)
this.name = 'NotImplementedInContractsError'
}
}

View File

@@ -0,0 +1,33 @@
/**
* relay-contracts — public barrel. The FROZEN shared surface (PLAN_RELAY_INDEX §4) imported
* read-only by every relay plan (P1P6). Dependency-free (only zod). Changed ONLY via the INDEX.
*/
// Shared primitives
export * from './errors.js'
export * from './bytes.js'
export * from './base64url.js'
// §4.1 Mux frame codec + stream lifecycle
export * from './mux/types.js'
export * from './mux/cbor.js'
export * from './mux/frame.js'
export * from './mux/open.js'
export * from './mux/control.js'
// §4.2 Account / host data model + revocation teardown + INV8 DDL
export * from './model/account.js'
export * from './model/revocation.js'
export * from './model/ddl.js'
// §4.3 Capability token + FIX 5 subprotocol transport
export * from './capability/token.js'
export * from './capability/subprotocol.js'
// §4.4 E2E type surface + envelope codec + crypto signatures (impls in P4)
export * from './e2e/types.js'
export * from './e2e/envelope.js'
export * from './e2e/crypto.js'
// §4.5 Pairing / enrollment
export * from './pairing/enroll.js'

View File

@@ -0,0 +1,77 @@
/**
* §4.2 Account / host data model — Zod schemas + inferred TS types.
* Postgres = ownership source of truth; Redis = live location (RouteEntry).
* Records are immutable (INV8): updates insert a new row / swap a snapshot.
*/
import { z } from 'zod'
/** account.status (string-literal union, §4.2). */
export type AccountStatus = 'active' | 'suspended'
/** account.plan tier (string-literal union, §4.2). */
export type PlanTier = 'free' | 'personal' | 'pro' | 'team'
/** host.status (string-literal union, §4.2). */
export type HostStatus = 'online' | 'offline' | 'draining' | 'revoked'
export const AccountStatusSchema = z.enum(['active', 'suspended'])
export const PlanTierSchema = z.enum(['free', 'personal', 'pro', 'team'])
export const HostStatusSchema = z.enum(['online', 'offline', 'draining', 'revoked'])
/** ISO-8601 timestamptz as a string (control-plane serialization boundary). */
const IsoTimestamp = z.string().datetime({ offset: true })
/** Unguessable UUID identifiers (INV1: host_id never recycled). */
const Uuid = z.string().uuid()
/** accounts row (§4.2). */
export const AccountRecordSchema = z
.object({
accountId: Uuid,
plan: PlanTierSchema,
createdAt: IsoTimestamp,
status: AccountStatusSchema,
})
.strict()
.readonly()
export type AccountRecord = z.infer<typeof AccountRecordSchema>
/**
* hosts row (§4.2). `agentPubkey` is the Ed25519 PUBLIC key only (INV4); the private
* key never leaves the host. `enrollFpr` is pinned by the browser for E2E TOFU (§4.4).
*/
export const HostRecordSchema = z
.object({
hostId: Uuid,
accountId: Uuid,
subdomain: z.string().min(1),
agentPubkey: z.instanceof(Uint8Array),
enrollFpr: z.string().min(1),
status: HostStatusSchema,
lastSeen: IsoTimestamp,
createdAt: IsoTimestamp,
revokedAt: IsoTimestamp.nullable(),
})
.strict()
.readonly()
export type HostRecord = z.infer<typeof HostRecordSchema>
/** sessions row (§4.2). `accountId` is DENORMALIZED for O(1) deny-by-default authz (INV3/INV6). */
export const SessionRecordSchema = z
.object({
sessionId: Uuid,
hostId: Uuid,
accountId: Uuid,
createdAt: IsoTimestamp,
lastAttachAt: IsoTimestamp,
})
.strict()
.readonly()
export type SessionRecord = z.infer<typeof SessionRecordSchema>
/** Redis `route:{host_id}` value — live routing table, NOT source of truth (INV7). */
export const RouteEntrySchema = z
.object({
relayNodeId: z.string().min(1),
updatedAt: IsoTimestamp,
})
.strict()
.readonly()
export type RouteEntry = z.infer<typeof RouteEntrySchema>

View File

@@ -0,0 +1,28 @@
/**
* §4.2 INV8 version-table DDL contract — immutable records + atomic snapshot swap.
*
* Mutable columns (hosts.status/last_seen, sessions.last_attach_at) are NEVER updated in
* place. A new version row is inserted into `host_versions` and the `hosts_current` pointer
* is CAS-swapped, so a concurrent read sees whole-old or whole-new — never a torn read.
*
* These DDL strings are the FROZEN contract P3 must apply verbatim (the migration owner is
* P3; relay-contracts owns the shape so every plan agrees on the version-chain semantics).
*/
/** Immutable per-host snapshot rows (append-only; `supersedes` forms an audit chain). */
export const HOST_VERSIONS_DDL = `CREATE TABLE IF NOT EXISTS host_versions (
version_id uuid PRIMARY KEY,
host_id uuid NOT NULL REFERENCES hosts(host_id),
snapshot jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
supersedes uuid NULL REFERENCES host_versions(version_id)
);` as const
/** CAS-swapped pointer to the live snapshot (atomic current-version indirection). */
export const HOSTS_CURRENT_DDL = `CREATE TABLE IF NOT EXISTS hosts_current (
host_id uuid PRIMARY KEY REFERENCES hosts(host_id),
version_id uuid NOT NULL REFERENCES host_versions(version_id)
);` as const
/** Ordered DDL statements for the INV8 version tables. */
export const INV8_VERSION_TABLE_DDL = [HOST_VERSIONS_DDL, HOSTS_CURRENT_DDL] as const

View File

@@ -0,0 +1,48 @@
/**
* §4.2 FIX 4 — control→data-plane revocation teardown shapes (INV12).
*
* Frozen HERE (not in relay-auth) so P1 relay nodes can SUBSCRIBE without importing P5,
* keeping the dependency DAG acyclic. P3/P5 PUBLISH a KillSignal on the one named bus;
* every P1 relay node subscribes and injects a §4.1 CLOSE+RST (host/account scope) or a
* connection-level GOAWAY (global scope) — no new frame type.
*/
import { z } from 'zod'
/** The single named control→data-plane Redis pub/sub channel (§4.2 FIX 4). */
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
/** Teardown budget: a live tunnel must drop within this many ms of revocation (INV12). */
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
/** What a KillSignal targets (§4.2). */
export type RevocationScope =
| { readonly kind: 'host'; readonly hostId: string }
| { readonly kind: 'account'; readonly accountId: string }
| { readonly kind: 'global' }
export const RevocationScopeSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('host'), hostId: z.string().uuid() }).strict(),
z.object({ kind: z.literal('account'), accountId: z.string().uuid() }).strict(),
z.object({ kind: z.literal('global') }).strict(),
])
/** Signal published on `relay:revocations` (§4.2). `reason` is metadata only, ZERO payload (INV10). */
export interface KillSignal {
readonly scope: RevocationScope
readonly at: number // epoch seconds the revocation was issued
readonly reason: string
}
export const KillSignalSchema = z
.object({
scope: RevocationScopeSchema,
at: z.number().int().nonnegative(),
reason: z.string(),
})
.strict()
.readonly()
/** Transport contract: P3/P1 implement the `relay:revocations` publish side. */
export interface RevocationBus {
publish(signal: KillSignal): Promise<void>
}

View File

@@ -0,0 +1,162 @@
/**
* Minimal deterministic CBOR (RFC 8949) subset — just enough for the §4.1 MuxOpen
* payload: text strings (major 3), unsigned integers (major 0), and one flat map
* (major 5). Hand-rolled (KISS / dependency-free) so the browser and agent bundles
* agree byte-for-byte without pulling a full CBOR library.
*
* Determinism: maps are encoded in a caller-fixed key order (MuxOpen has a frozen
* field order), giving a stable byte vector for KAT tests. Decoding is order-independent
* (reads into an object, then Zod validates).
*/
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
import { concatBytes } from '../bytes.js'
export type CborScalar = string | number
export type CborMap = Readonly<Record<string, CborScalar>>
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder('utf-8', { fatal: true })
/** Encode a CBOR length/value head for the given major type (0..7). */
function encodeHead(major: number, n: number): Uint8Array {
if (!Number.isInteger(n) || n < 0) {
throw new ContractEncodeError(`CBOR head requires a non-negative integer; got ${n}`)
}
const prefix = major << 5
if (n < 24) return Uint8Array.of(prefix | n)
if (n < 0x100) return Uint8Array.of(prefix | 24, n)
if (n < 0x1_0000) return Uint8Array.of(prefix | 25, (n >> 8) & 0xff, n & 0xff)
if (n < 0x1_0000_0000) {
return Uint8Array.of(
prefix | 26,
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
)
}
throw new ContractEncodeError(`CBOR head value too large for this subset: ${n}`)
}
function encodeUint(n: number): Uint8Array {
return encodeHead(0, n)
}
function encodeText(s: string): Uint8Array {
const utf8 = textEncoder.encode(s)
return concatBytes([encodeHead(3, utf8.length), utf8])
}
function encodeScalar(value: CborScalar): Uint8Array {
if (typeof value === 'string') return encodeText(value)
if (typeof value === 'number') {
if (!Number.isInteger(value) || value < 0) {
throw new ContractEncodeError(`CBOR scalar number must be a non-negative integer; got ${value}`)
}
return encodeUint(value)
}
throw new ContractEncodeError(`unsupported CBOR scalar type: ${typeof value}`)
}
/**
* Encode a flat map in the caller-provided key order (deterministic).
* `keyOrder` must list exactly the keys present in `map`.
*/
export function encodeCborMap(map: CborMap, keyOrder: readonly string[]): Uint8Array {
const keys = Object.keys(map)
if (keys.length !== keyOrder.length) {
throw new ContractEncodeError(
`CBOR map has ${keys.length} keys but keyOrder lists ${keyOrder.length}`,
)
}
const chunks: Uint8Array[] = [encodeHead(5, keyOrder.length)]
for (const key of keyOrder) {
if (!Object.prototype.hasOwnProperty.call(map, key)) {
throw new ContractEncodeError(`CBOR map missing key '${key}' named in keyOrder`)
}
chunks.push(encodeText(key))
chunks.push(encodeScalar(map[key]!))
}
return concatBytes(chunks)
}
interface Cursor {
readonly buf: Uint8Array
offset: number
}
function readHead(c: Cursor): { major: number; value: number } {
if (c.offset >= c.buf.length) {
throw new ContractDecodeError('CBOR truncated: expected a head byte')
}
const first = c.buf[c.offset]!
c.offset += 1
const major = first >> 5
const info = first & 0x1f
if (info < 24) return { major, value: info }
if (info === 24) {
if (c.offset + 1 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint8 arg')
const v = c.buf[c.offset]!
c.offset += 1
return { major, value: v }
}
if (info === 25) {
if (c.offset + 2 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint16 arg')
const v = (c.buf[c.offset]! << 8) | c.buf[c.offset + 1]!
c.offset += 2
return { major, value: v }
}
if (info === 26) {
if (c.offset + 4 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint32 arg')
const v =
((c.buf[c.offset]! << 24) >>> 0) +
(c.buf[c.offset + 1]! << 16) +
(c.buf[c.offset + 2]! << 8) +
c.buf[c.offset + 3]!
c.offset += 4
return { major, value: v }
}
throw new ContractDecodeError(`unsupported CBOR additional-info ${info} in this subset`)
}
function readScalar(c: Cursor): CborScalar {
const { major, value } = readHead(c)
if (major === 0) return value // unsigned int
if (major === 3) {
// text string
const end = c.offset + value
if (end > c.buf.length) throw new ContractDecodeError('CBOR truncated text string')
const slice = c.buf.subarray(c.offset, end)
c.offset = end
try {
return textDecoder.decode(slice)
} catch {
throw new ContractDecodeError('CBOR text string is not valid UTF-8')
}
}
throw new ContractDecodeError(`unsupported CBOR scalar major type ${major} in this subset`)
}
/** Decode a flat CBOR map into a plain object of scalars. Rejects trailing bytes. */
export function decodeCborMap(buf: Uint8Array): Record<string, CborScalar> {
const c: Cursor = { buf, offset: 0 }
const { major, value: count } = readHead(c)
if (major !== 5) {
throw new ContractDecodeError(`expected CBOR map (major 5); got major ${major}`)
}
const out: Record<string, CborScalar> = {}
for (let i = 0; i < count; i++) {
const key = readScalar(c)
if (typeof key !== 'string') {
throw new ContractDecodeError('CBOR map key must be a text string')
}
if (Object.prototype.hasOwnProperty.call(out, key)) {
throw new ContractDecodeError(`duplicate CBOR map key '${key}'`)
}
out[key] = readScalar(c)
}
if (c.offset !== buf.length) {
throw new ContractDecodeError(`trailing bytes after CBOR map: ${buf.length - c.offset} extra`)
}
return out
}

View File

@@ -0,0 +1,64 @@
/**
* §4.1 control-frame payload codecs: WINDOW_UPDATE and GOAWAY.
*
* Per §4.1's explicit byte spec these are fixed-width big-endian integers (NOT CBOR):
* WINDOW_UPDATE payload = uint32 credit
* GOAWAY payload = uint32 lastStreamId ‖ uint32 reason
* (PLAN_RELAY_INDEX §2.1 loosely groups them under "CBOR payload codecs"; §4.1 is the
* authoritative wire and wins — see report deviation note.)
*/
import { ContractDecodeError } from '../errors.js'
import { assertUint32, readUint32BE, writeUint32BE } from '../bytes.js'
import {
GOAWAY_CODE_TO_REASON,
GOAWAY_REASON_TO_CODE,
type GoAwayReason,
} from './types.js'
/** Map a GOAWAY wire code to its frozen reason label; throws on unknown code. */
export function decodeGoAwayReason(code: number): GoAwayReason {
const reason = GOAWAY_CODE_TO_REASON[code]
if (reason === undefined) {
throw new ContractDecodeError(`unknown GOAWAY reason code ${code}`)
}
return reason
}
/** WINDOW_UPDATE: encode a uint32 credit increment. */
export function encodeWindowUpdate(credit: number): Uint8Array {
assertUint32(credit, 'credit')
const out = new Uint8Array(4)
writeUint32BE(out, 0, credit)
return out
}
/** WINDOW_UPDATE: decode the uint32 credit increment. */
export function decodeWindowUpdate(payload: Uint8Array): number {
if (payload.length !== 4) {
throw new ContractDecodeError(`WINDOW_UPDATE payload must be 4 bytes; got ${payload.length}`)
}
return readUint32BE(payload, 0)
}
/** GOAWAY: encode uint32 lastStreamId ‖ uint32 reason-code. */
export function encodeGoaway(lastStreamId: number, reason: GoAwayReason): Uint8Array {
assertUint32(lastStreamId, 'lastStreamId')
const code = GOAWAY_REASON_TO_CODE[reason]
if (code === undefined) {
throw new ContractDecodeError(`unknown GOAWAY reason label '${reason}'`)
}
const out = new Uint8Array(8)
let offset = writeUint32BE(out, 0, lastStreamId)
writeUint32BE(out, offset, code)
return out
}
/** GOAWAY: decode uint32 lastStreamId ‖ uint32 reason-code. */
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: GoAwayReason } {
if (payload.length !== 8) {
throw new ContractDecodeError(`GOAWAY payload must be 8 bytes; got ${payload.length}`)
}
const lastStreamId = readUint32BE(payload, 0)
const reason = decodeGoAwayReason(readUint32BE(payload, 4))
return { lastStreamId, reason }
}

View File

@@ -0,0 +1,97 @@
/**
* §4.1 Mux frame codec — the 15-byte fixed header + opaque payload.
*
* Layout (big-endian):
* offset size field
* 0 1 version 0x01
* 1 1 type 0x01 OPEN … 0x07 GOAWAY
* 2 1 flags bit0 FIN · bit1 RST · others reserved=0
* 3 4 streamId uint32 (0 = connection-level)
* 7 8 payloadLen uint64 (safe-int guarded)
* 15 var payload opaque bytes
*/
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
import { assertUint32, readSafeUint64BE, writeUint32BE, writeUint64BE } from '../bytes.js'
import {
FLAG_FIN,
FLAG_RST,
FLAG_RESERVED_MASK,
MUX_CODE_TO_TYPE,
MUX_HEADER_BYTES,
MUX_TYPE_TO_CODE,
MUX_VERSION,
type MuxFrameHeader,
} from './types.js'
function encodeFlags(fin: boolean, rst: boolean): number {
return (fin ? FLAG_FIN : 0) | (rst ? FLAG_RST : 0)
}
/** Encode a full mux frame. `header.payloadLen` MUST equal `payload.length`. */
export function encodeMuxFrame(header: MuxFrameHeader, payload: Uint8Array): Uint8Array {
if (header.version !== MUX_VERSION) {
throw new ContractEncodeError(`unsupported mux version ${header.version} (expected ${MUX_VERSION})`)
}
const typeCode = MUX_TYPE_TO_CODE[header.type]
if (typeCode === undefined) {
throw new ContractEncodeError(`unknown mux frame type '${header.type}'`)
}
assertUint32(header.streamId, 'streamId')
if (header.payloadLen !== payload.length) {
throw new ContractEncodeError(
`header.payloadLen (${header.payloadLen}) must equal payload.length (${payload.length})`,
)
}
const out = new Uint8Array(MUX_HEADER_BYTES + payload.length)
out[0] = MUX_VERSION
out[1] = typeCode
out[2] = encodeFlags(header.fin, header.rst)
let offset = writeUint32BE(out, 3, header.streamId)
offset = writeUint64BE(out, offset, BigInt(payload.length))
out.set(payload, MUX_HEADER_BYTES)
return out
}
/** Decode ONLY the 15-byte header (no payload slice). */
export function decodeHeader(buf: Uint8Array): MuxFrameHeader {
if (buf.length < MUX_HEADER_BYTES) {
throw new ContractDecodeError(
`mux frame shorter than header: ${buf.length} < ${MUX_HEADER_BYTES}`,
)
}
const version = buf[0]!
if (version !== MUX_VERSION) {
throw new ContractDecodeError(`unsupported mux version ${version} (expected ${MUX_VERSION})`)
}
const type = MUX_CODE_TO_TYPE[buf[1]!]
if (type === undefined) {
throw new ContractDecodeError(`unknown mux frame type code 0x${buf[1]!.toString(16)}`)
}
const flags = buf[2]!
if ((flags & FLAG_RESERVED_MASK) !== 0) {
throw new ContractDecodeError(`reserved flag bits set: 0x${flags.toString(16)}`)
}
const streamId = ((buf[3]! << 24) >>> 0) + (buf[4]! << 16) + (buf[5]! << 8) + buf[6]!
const payloadLen = readSafeUint64BE(buf, 7, 'payloadLen')
return {
version: MUX_VERSION,
type,
fin: (flags & FLAG_FIN) !== 0,
rst: (flags & FLAG_RST) !== 0,
streamId,
payloadLen,
}
}
/** Decode a full mux frame into its header + payload slice. Rejects a truncated payload. */
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array } {
const header = decodeHeader(buf)
const end = MUX_HEADER_BYTES + header.payloadLen
if (buf.length < end) {
throw new ContractDecodeError(
`truncated payload: have ${buf.length - MUX_HEADER_BYTES} bytes, header says ${header.payloadLen}`,
)
}
const payload = buf.slice(MUX_HEADER_BYTES, end)
return { header, payload }
}

View File

@@ -0,0 +1,41 @@
/**
* §4.1 OPEN payload codec — CBOR of MuxOpen, Zod-guarded on decode.
* Deterministic fixed field order so the byte vector is stable for KAT.
*/
import { ContractDecodeError } from '../errors.js'
import { decodeCborMap, encodeCborMap, type CborMap } from './cbor.js'
import { MuxOpenSchema, type MuxOpen } from './types.js'
/** Frozen deterministic CBOR key order for MuxOpen (§4.1). */
export const MUX_OPEN_KEY_ORDER = [
'streamId',
'subdomain',
'requestPath',
'originHeader',
'remoteAddrHash',
'capabilityTokenRef',
] as const
/** Encode a MuxOpen into its CBOR payload. Validates the shape before encoding. */
export function encodeOpen(open: MuxOpen): Uint8Array {
const parsed = MuxOpenSchema.parse(open)
const map: CborMap = {
streamId: parsed.streamId,
subdomain: parsed.subdomain,
requestPath: parsed.requestPath,
originHeader: parsed.originHeader,
remoteAddrHash: parsed.remoteAddrHash,
capabilityTokenRef: parsed.capabilityTokenRef,
}
return encodeCborMap(map, MUX_OPEN_KEY_ORDER)
}
/** Decode a CBOR OPEN payload into a validated MuxOpen (throws on bad shape). */
export function decodeOpen(payload: Uint8Array): MuxOpen {
const raw = decodeCborMap(payload)
const result = MuxOpenSchema.safeParse(raw)
if (!result.success) {
throw new ContractDecodeError(`invalid MuxOpen payload: ${result.error.message}`)
}
return result.data
}

View File

@@ -0,0 +1,106 @@
/**
* §4.1 Mux frame format — shared types & frozen wire codes.
*
* Frame header is 15 bytes fixed (big-endian / network order), then `payloadLen` bytes.
* All numeric wire codes below are FROZEN by PLAN_RELAY_INDEX §4.1 and MUST NOT be
* redefined by any plan.
*/
import { z } from 'zod'
/** Fixed header size in bytes (§4.1). */
export const MUX_HEADER_BYTES = 15 as const
/** Protocol version byte (§4.1: version = 0x01). */
export const MUX_VERSION = 1 as const
/** Frame type as a string-literal union (NOT an enum), per §4.1. */
export type MuxFrameType =
| 'open'
| 'data'
| 'close'
| 'ping'
| 'pong'
| 'windowUpdate'
| 'goaway'
/** Frozen label ⇄ wire-code maps (§4.1: 0x01 OPEN … 0x07 GOAWAY). */
export const MUX_TYPE_TO_CODE: Readonly<Record<MuxFrameType, number>> = {
open: 0x01,
data: 0x02,
close: 0x03,
ping: 0x04,
pong: 0x05,
windowUpdate: 0x06,
goaway: 0x07,
}
export const MUX_CODE_TO_TYPE: Readonly<Record<number, MuxFrameType>> = {
0x01: 'open',
0x02: 'data',
0x03: 'close',
0x04: 'ping',
0x05: 'pong',
0x06: 'windowUpdate',
0x07: 'goaway',
}
/** flags byte bit positions (§4.1: bit0 FIN, bit1 RST, others reserved=0). */
export const FLAG_FIN = 0b0000_0001 as const
export const FLAG_RST = 0b0000_0010 as const
export const FLAG_RESERVED_MASK = 0b1111_1100 as const
/** streamId 0 is connection-level control (PING/PONG/GOAWAY/WINDOW_UPDATE for the whole link). */
export const CONNECTION_STREAM_ID = 0 as const
export const UINT32_MAX = 0xffff_ffff as const
/** Frame header — 15 bytes fixed (§4.1). */
export interface MuxFrameHeader {
readonly version: 1
readonly type: MuxFrameType
readonly fin: boolean
readonly rst: boolean
readonly streamId: number // uint32; 0 = connection-level
readonly payloadLen: number // uint64 (safe-int guarded ≤ maxFrameBytes)
}
/** OPEN payload (relay→agent), carried as CBOR (§4.1). Carries NO terminal bytes. */
export interface MuxOpen {
readonly streamId: number
readonly subdomain: string
readonly requestPath: string // e.g. '/term?join=<id>' — opaque passthrough
readonly originHeader: string // real browser Origin, validated end-to-end
readonly remoteAddrHash: string // salted hash of client IP (audit only, INV10)
readonly capabilityTokenRef: string // jti of the token that authorized this stream (P5)
}
/**
* Zod schema for MuxOpen — validated at the decode boundary (§4.1, "Input Validation").
* streamId is a uint32 > 0 (0 is reserved for connection-level control, never a stream OPEN).
*/
export const MuxOpenSchema = z
.object({
streamId: z.number().int().gt(0).max(UINT32_MAX),
subdomain: z.string(),
requestPath: z.string(),
originHeader: z.string(),
remoteAddrHash: z.string(),
capabilityTokenRef: z.string(),
})
.strict()
/** GOAWAY reason as a string-literal union (NOT an enum); wire code ⇄ label (§4.1). */
export type GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'
/** Frozen reason label ⇄ wire-code maps (§4.1: 1 operatorDrain · 2 revoked · 3 shutdown). */
export const GOAWAY_REASON_TO_CODE: Readonly<Record<GoAwayReason, number>> = {
operatorDrain: 1,
revoked: 2,
shutdown: 3,
}
export const GOAWAY_CODE_TO_REASON: Readonly<Record<number, GoAwayReason>> = {
1: 'operatorDrain',
2: 'revoked',
3: 'shutdown',
}

View File

@@ -0,0 +1,50 @@
/**
* §4.5 Pairing / enrollment shapes. P3 issues pairing codes and returns EnrollResult at BIND;
* P2 redeems. `hostContentSecret` (FIX 3) is wrapped to the agent's enrolled Ed25519 identity
* by P3 and unwrapped locally by P2 (private key never leaves the host, INV4).
*/
import { z } from 'zod'
/**
* Result of a successful `POST /enroll` (§4.5). `hostContentSecret` is the wrapped-to-agent
* input to §4.4 `deriveContentKey`; NEVER logged, NEVER sent to the relay.
*/
export interface EnrollResult {
readonly hostId: string
readonly subdomain: string
readonly cert: string
readonly caChain: string
readonly hostContentSecret: Uint8Array // FIX 3 — wrapped to agent_pubkey by P3 at BIND
}
export const EnrollResultSchema = z
.object({
hostId: z.string().uuid(),
subdomain: z.string().min(1),
cert: z.string().min(1),
caChain: z.string().min(1),
hostContentSecret: z.instanceof(Uint8Array),
})
.strict()
.readonly()
/**
* pairing_codes row (§4.2/§4.5). Only the HASH of the single-use code is stored (INV5);
* a non-null `redeemedAt` marks it spent (single-use, atomic compare-and-set at redeem).
*/
export interface PairingCodeRecord {
readonly codeHash: string
readonly accountId: string
readonly expiresAt: string
readonly redeemedAt: string | null
}
export const PairingCodeRecordSchema = z
.object({
codeHash: z.string().min(1),
accountId: z.string().uuid(),
expiresAt: z.string().datetime({ offset: true }),
redeemedAt: z.string().datetime({ offset: true }).nullable(),
})
.strict()
.readonly()

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
APP_SUBPROTOCOL,
ContractDecodeError,
TOKEN_SUBPROTOCOL_PREFIX,
decodeBase64UrlBytes,
encodeBase64UrlBytes,
encodeBase64UrlString,
decodeBase64UrlString,
encodeTokenSubprotocol,
extractTokenFromSubprotocols,
} from '../src/index.js'
describe('base64url (isomorphic)', () => {
it('KAT: encodes "hello" to "aGVsbG8" (no padding)', () => {
expect(encodeBase64UrlString('hello')).toBe('aGVsbG8')
})
it('KAT: uses url-safe "-" and "_" instead of "+" and "/"', () => {
expect(encodeBase64UrlBytes(Uint8Array.of(0xfb, 0xff))).toBe('-_8')
})
it('round-trips arbitrary bytes', () => {
const bytes = Uint8Array.from({ length: 40 }, (_, i) => (i * 37) & 0xff)
expect([...decodeBase64UrlBytes(encodeBase64UrlBytes(bytes))]).toEqual([...bytes])
})
it('round-trips a unicode string', () => {
const s = 'café-☕-relay'
expect(decodeBase64UrlString(encodeBase64UrlString(s))).toBe(s)
})
it('rejects non-alphabet characters', () => {
expect(() => decodeBase64UrlBytes('abc*def')).toThrow(ContractDecodeError)
})
})
describe('§4.3 FIX 5 token subprotocol transport', () => {
it('encodeTokenSubprotocol prefixes with term.token. + base64url', () => {
expect(encodeTokenSubprotocol('hello')).toBe(`${TOKEN_SUBPROTOCOL_PREFIX}aGVsbG8`)
})
it('round-trips a raw token through the subprotocol list', () => {
const raw = 'v4.public.aBcD_-123'
const entry = encodeTokenSubprotocol(raw)
expect(extractTokenFromSubprotocols([APP_SUBPROTOCOL, entry])).toBe(raw)
})
it('returns null when no token entry is present', () => {
expect(extractTokenFromSubprotocols([APP_SUBPROTOCOL])).toBeNull()
})
it('ignores the app subprotocol and picks the token entry regardless of order', () => {
const entry = encodeTokenSubprotocol('tok')
expect(extractTokenFromSubprotocols([entry, APP_SUBPROTOCOL])).toBe('tok')
})
it('throws when the token entry is malformed base64url', () => {
expect(() => extractTokenFromSubprotocols([`${TOKEN_SUBPROTOCOL_PREFIX}not*valid`])).toThrow(
ContractDecodeError,
)
})
it('APP_SUBPROTOCOL is the single frozen value', () => {
expect(APP_SUBPROTOCOL).toBe('term.relay.v1')
})
})

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
CapabilityTokenSchema,
EnrollResultSchema,
NotImplementedInContractsError,
PairingCodeRecordSchema,
verifyCapabilityToken,
} from '../src/index.js'
const UUID = '22222222-2222-4222-8222-222222222222'
const TS = '2026-07-01T00:00:00.000Z'
describe('§4.3 CapabilityToken shape validation', () => {
const valid = {
sub: 'device:1',
aud: 'alice',
host: UUID,
rights: ['attach'],
iat: 1000,
exp: 2000,
jti: 'jti-1',
}
it('accepts a well-formed token body', () => {
expect(CapabilityTokenSchema.parse(valid).rights).toEqual(['attach'])
})
it('rejects empty rights', () => {
expect(CapabilityTokenSchema.safeParse({ ...valid, rights: [] }).success).toBe(false)
})
it('rejects duplicate rights', () => {
expect(
CapabilityTokenSchema.safeParse({ ...valid, rights: ['attach', 'attach'] }).success,
).toBe(false)
})
it('rejects exp <= iat', () => {
expect(CapabilityTokenSchema.safeParse({ ...valid, exp: 1000 }).success).toBe(false)
})
it('rejects an unknown right', () => {
expect(CapabilityTokenSchema.safeParse({ ...valid, rights: ['destroy'] }).success).toBe(false)
})
it('verifyCapabilityToken is a frozen stub (crypto verify owned by P5)', () => {
expect(() => verifyCapabilityToken('raw', 'alice', Date.now())).toThrow(
NotImplementedInContractsError,
)
})
})
describe('§4.5 pairing / enroll shapes', () => {
it('accepts a valid EnrollResult', () => {
const rec = {
hostId: UUID,
subdomain: 'alice',
cert: '-----BEGIN CERTIFICATE-----',
caChain: '-----BEGIN CERTIFICATE-----',
hostContentSecret: new Uint8Array([9, 8, 7]),
}
expect(EnrollResultSchema.parse(rec).hostContentSecret).toBeInstanceOf(Uint8Array)
})
it('rejects an EnrollResult with a non-bytes hostContentSecret', () => {
expect(
EnrollResultSchema.safeParse({
hostId: UUID,
subdomain: 'a',
cert: 'c',
caChain: 'c',
hostContentSecret: 'not-bytes',
}).success,
).toBe(false)
})
it('accepts a PairingCodeRecord (redeemed and unredeemed)', () => {
expect(
PairingCodeRecordSchema.parse({
codeHash: 'h',
accountId: UUID,
expiresAt: TS,
redeemedAt: null,
}).redeemedAt,
).toBeNull()
expect(
PairingCodeRecordSchema.parse({
codeHash: 'h',
accountId: UUID,
expiresAt: TS,
redeemedAt: TS,
}).redeemedAt,
).toBe(TS)
})
})

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
HKDF_INFO,
HKDF_INFO_C2H,
HKDF_INFO_H2C,
NotImplementedInContractsError,
REPLAY_KDF_INFO,
buildClientHandshake,
createE2ESession,
deriveContentKey,
openFrame,
openReplayCiphertext,
sealFrame,
sealReplayFrame,
} from '../src/index.js'
/**
* relay-contracts owns SHAPES only; the crypto implementations live in P4. Each frozen
* signature must exist (importable / type-checked) and throw NotImplementedInContractsError.
*/
describe('§4.4 crypto signatures are frozen stubs (impl in P4)', () => {
const key = {} as never
const env = {} as never
it.each([
['sealFrame', () => sealFrame(key, 0n, new Uint8Array())],
['openFrame', () => openFrame(key, env, 0n)],
['createE2ESession', () => createE2ESession('client', {} as never)],
['buildClientHandshake', () => buildClientHandshake({} as never, 'h', ['aes-256-gcm'])],
['deriveContentKey', () => deriveContentKey({} as never)],
['sealReplayFrame', () => sealReplayFrame(key, 0n, new Uint8Array())],
['openReplayCiphertext', () => openReplayCiphertext(key, new Uint8Array())],
])('%s throws NotImplementedInContractsError', (_name, fn) => {
expect(fn).toThrow(NotImplementedInContractsError)
})
it('exposes the frozen HKDF label constants', () => {
expect(HKDF_INFO).toBe('relay-e2e/v1')
expect(HKDF_INFO_C2H).toBe('relay-e2e/v1/c2h')
expect(HKDF_INFO_H2C).toBe('relay-e2e/v1/h2c')
expect(REPLAY_KDF_INFO).toBe('relay-e2e/replay/v1')
})
})

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
ContractDecodeError,
NONCE_BYTES,
decodeEnvelope,
encodeEnvelope,
nonceForSeq,
type E2EEnvelope,
} from '../src/index.js'
const sample = (over: Partial<E2EEnvelope> = {}): E2EEnvelope => ({
seq: 1n,
nonce: nonceForSeq(1n, 'aes-256-gcm'),
ciphertext: Uint8Array.of(0xde, 0xad, 0xbe, 0xef),
tag: new Uint8Array(16).fill(0x11),
...over,
})
describe('§4.4 deterministic nonce = f(seq)', () => {
it('KAT: seq 0x0102 in the last 8 bytes of a 12-byte GCM nonce', () => {
expect([...nonceForSeq(0x0102n, 'aes-256-gcm')]).toEqual([
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, 0x02,
])
})
it('produces the correct width per alg', () => {
expect(nonceForSeq(1n, 'aes-256-gcm').length).toBe(NONCE_BYTES['aes-256-gcm'])
expect(nonceForSeq(1n, 'xchacha20-poly1305').length).toBe(NONCE_BYTES['xchacha20-poly1305'])
})
it('is deterministic and collision-free across distinct seq', () => {
const a = nonceForSeq(5n, 'xchacha20-poly1305')
const b = nonceForSeq(5n, 'xchacha20-poly1305')
const c = nonceForSeq(6n, 'xchacha20-poly1305')
expect([...a]).toEqual([...b])
expect([...a]).not.toEqual([...c])
})
})
describe('§4.4 E2EEnvelope binary codec', () => {
it('KAT: encodes a known small envelope to a fixed byte vector', () => {
const env: E2EEnvelope = {
seq: 1n,
nonce: Uint8Array.of(1, 2, 3),
ciphertext: Uint8Array.of(0xaa),
tag: Uint8Array.of(0xbb),
}
expect([...encodeEnvelope(env)]).toEqual([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // seq
0x03, // nonceLen
0x00, 0x00, 0x00, 0x01, // ciphertextLen
0x01, // tagLen
0x01, 0x02, 0x03, // nonce
0xaa, // ciphertext
0xbb, // tag
])
})
it('round-trips a realistic envelope (identity)', () => {
const env = sample()
const decoded = decodeEnvelope(encodeEnvelope(env))
expect(decoded.seq).toBe(env.seq)
expect([...decoded.nonce]).toEqual([...env.nonce])
expect([...decoded.ciphertext]).toEqual([...env.ciphertext])
expect([...decoded.tag]).toEqual([...env.tag])
})
it('preserves a large 64-bit seq', () => {
const env = sample({ seq: 0xfffffffffffffff0n })
expect(decodeEnvelope(encodeEnvelope(env)).seq).toBe(0xfffffffffffffff0n)
})
it('rejects a truncated envelope header', () => {
expect(() => decodeEnvelope(Uint8Array.of(0, 1, 2))).toThrow(ContractDecodeError)
})
it('rejects a length mismatch (trailing/short bytes)', () => {
const bytes = encodeEnvelope(sample())
expect(() => decodeEnvelope(bytes.subarray(0, bytes.length - 1))).toThrow(/length mismatch/)
})
})

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import {
AccountRecordSchema,
HostRecordSchema,
KillSignalSchema,
RELAY_REVOCATIONS_CHANNEL,
REVOCATION_PUSH_BUDGET_MS,
RevocationScopeSchema,
RouteEntrySchema,
SessionRecordSchema,
INV8_VERSION_TABLE_DDL,
HOST_VERSIONS_DDL,
HOSTS_CURRENT_DDL,
} from '../src/index.js'
const UUID = '11111111-1111-4111-8111-111111111111'
const TS = '2026-07-01T00:00:00.000Z'
describe('§4.2 account/host/session Zod schemas', () => {
it('accepts a valid HostRecord', () => {
const rec = {
hostId: UUID,
accountId: UUID,
subdomain: 'alice',
agentPubkey: new Uint8Array([1, 2, 3]),
enrollFpr: 'fpr:abc',
status: 'online',
lastSeen: TS,
createdAt: TS,
revokedAt: null,
}
expect(HostRecordSchema.parse(rec)).toEqual(rec)
})
it('rejects a HostRecord with an invalid status', () => {
expect(() =>
HostRecordSchema.parse({
hostId: UUID,
accountId: UUID,
subdomain: 'a',
agentPubkey: new Uint8Array(),
enrollFpr: 'f',
status: 'zombie',
lastSeen: TS,
createdAt: TS,
revokedAt: null,
}),
).toThrow()
})
it('rejects a HostRecord with a non-UUID hostId', () => {
const bad = {
hostId: 'not-a-uuid',
accountId: UUID,
subdomain: 'a',
agentPubkey: new Uint8Array(),
enrollFpr: 'f',
status: 'online',
lastSeen: TS,
createdAt: TS,
revokedAt: null,
}
expect(HostRecordSchema.safeParse(bad).success).toBe(false)
})
it('rejects unknown extra keys (strict)', () => {
expect(
AccountRecordSchema.safeParse({
accountId: UUID,
plan: 'pro',
createdAt: TS,
status: 'active',
extra: 1,
}).success,
).toBe(false)
})
it('accepts a valid AccountRecord across all plan tiers', () => {
for (const plan of ['free', 'personal', 'pro', 'team'] as const) {
expect(AccountRecordSchema.parse({ accountId: UUID, plan, createdAt: TS, status: 'active' }).plan).toBe(
plan,
)
}
})
it('validates SessionRecord and RouteEntry', () => {
expect(
SessionRecordSchema.parse({
sessionId: UUID,
hostId: UUID,
accountId: UUID,
createdAt: TS,
lastAttachAt: TS,
}).sessionId,
).toBe(UUID)
expect(RouteEntrySchema.parse({ relayNodeId: 'node-1', updatedAt: TS }).relayNodeId).toBe('node-1')
})
})
describe('§4.2 FIX 4 revocation teardown shapes', () => {
it('exposes the frozen channel + budget constants', () => {
expect(RELAY_REVOCATIONS_CHANNEL).toBe('relay:revocations')
expect(REVOCATION_PUSH_BUDGET_MS).toBe(2000)
})
it('accepts each RevocationScope variant', () => {
expect(RevocationScopeSchema.parse({ kind: 'host', hostId: UUID }).kind).toBe('host')
expect(RevocationScopeSchema.parse({ kind: 'account', accountId: UUID }).kind).toBe('account')
expect(RevocationScopeSchema.parse({ kind: 'global' }).kind).toBe('global')
})
it('rejects a host scope missing hostId', () => {
expect(RevocationScopeSchema.safeParse({ kind: 'host' }).success).toBe(false)
})
it('validates a full KillSignal', () => {
const signal = { scope: { kind: 'global' }, at: 1719800000, reason: 'operator drain' }
expect(KillSignalSchema.parse(signal)).toEqual(signal)
})
})
describe('§4.2 INV8 version-table DDL contract', () => {
it('exposes host_versions + hosts_current DDL in order', () => {
expect(INV8_VERSION_TABLE_DDL).toEqual([HOST_VERSIONS_DDL, HOSTS_CURRENT_DDL])
expect(HOST_VERSIONS_DDL).toContain('host_versions')
expect(HOST_VERSIONS_DDL).toContain('supersedes')
expect(HOSTS_CURRENT_DDL).toContain('hosts_current')
expect(HOSTS_CURRENT_DDL).toContain('version_id')
})
})

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
ContractDecodeError,
ContractEncodeError,
decodeHeader,
decodeMuxFrame,
encodeMuxFrame,
type MuxFrameHeader,
} from '../src/index.js'
const baseHeader = (over: Partial<MuxFrameHeader> = {}): MuxFrameHeader => ({
version: 1,
type: 'data',
fin: false,
rst: false,
streamId: 1,
payloadLen: 0,
...over,
})
describe('§4.1 mux frame codec', () => {
it('KAT: encodes a known DATA frame to a fixed byte vector', () => {
const header = baseHeader({ type: 'data', fin: true, streamId: 0x01020304, payloadLen: 2 })
const payload = Uint8Array.of(0xaa, 0xbb)
const bytes = encodeMuxFrame(header, payload)
expect([...bytes]).toEqual([
0x01, // version
0x02, // type=data
0x01, // flags: FIN
0x01, 0x02, 0x03, 0x04, // streamId
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, // payloadLen uint64
0xaa, 0xbb, // payload
])
})
it('KAT: fixed bytes decode to fixed header + payload', () => {
const bytes = Uint8Array.of(
0x01, 0x07, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x99,
)
const { header, payload } = decodeMuxFrame(bytes)
expect(header).toEqual({
version: 1,
type: 'goaway',
fin: false,
rst: true,
streamId: 0,
payloadLen: 1,
})
expect([...payload]).toEqual([0x99])
})
it.each(['open', 'data', 'close', 'ping', 'pong', 'windowUpdate', 'goaway'] as const)(
'round-trips type=%s with flags',
(type) => {
const header = baseHeader({ type, fin: true, rst: true, streamId: 42, payloadLen: 3 })
const payload = Uint8Array.of(1, 2, 3)
const decoded = decodeMuxFrame(encodeMuxFrame(header, payload))
expect(decoded.header).toEqual(header)
expect([...decoded.payload]).toEqual([1, 2, 3])
},
)
it('decodeHeader reads only the header of a longer buffer', () => {
const bytes = encodeMuxFrame(baseHeader({ payloadLen: 4 }), Uint8Array.of(9, 9, 9, 9))
expect(decodeHeader(bytes).payloadLen).toBe(4)
})
it('rejects encode when payloadLen != payload.length', () => {
expect(() => encodeMuxFrame(baseHeader({ payloadLen: 5 }), Uint8Array.of(1))).toThrow(
ContractEncodeError,
)
})
it('rejects an unknown frame type code on decode', () => {
const bytes = Uint8Array.of(0x01, 0x7f, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
expect(() => decodeHeader(bytes)).toThrow(ContractDecodeError)
})
it('rejects reserved flag bits', () => {
const bytes = Uint8Array.of(0x01, 0x02, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
expect(() => decodeHeader(bytes)).toThrow(/reserved flag/)
})
it('rejects an unsupported version', () => {
const bytes = Uint8Array.of(0x02, 0x02, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
expect(() => decodeHeader(bytes)).toThrow(/version/)
})
it('rejects a truncated payload', () => {
const bytes = Uint8Array.of(0x01, 0x02, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x05)
expect(() => decodeMuxFrame(bytes)).toThrow(/truncated/)
})
it('rejects a buffer shorter than the header', () => {
expect(() => decodeHeader(Uint8Array.of(0x01, 0x02))).toThrow(ContractDecodeError)
})
})

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
ContractDecodeError,
decodeCborMap,
decodeGoAwayReason,
decodeGoaway,
decodeOpen,
decodeWindowUpdate,
encodeCborMap,
encodeGoaway,
encodeOpen,
encodeWindowUpdate,
type MuxOpen,
} from '../src/index.js'
const sampleOpen = (): MuxOpen => ({
streamId: 7,
subdomain: 'alice',
requestPath: '/term?join=abc',
originHeader: 'https://alice.term.example.com',
remoteAddrHash: 'deadbeef',
capabilityTokenRef: 'jti-123',
})
describe('§4.1 CBOR primitive', () => {
it('KAT: decodes a hand-written CBOR map {"a":"b"} from fixed bytes', () => {
const bytes = Uint8Array.of(0xa1, 0x61, 0x61, 0x61, 0x62)
expect(decodeCborMap(bytes)).toEqual({ a: 'b' })
})
it('KAT: encodes {a:1} to fixed bytes (uint value)', () => {
const bytes = encodeCborMap({ a: 1 }, ['a'])
expect([...bytes]).toEqual([0xa1, 0x61, 0x61, 0x01])
})
it('rejects trailing bytes after the map', () => {
expect(() => decodeCborMap(Uint8Array.of(0xa1, 0x61, 0x61, 0x61, 0x62, 0x00))).toThrow(
/trailing bytes/,
)
})
it('rejects a duplicate key', () => {
// map(2) with key "a" twice
const bytes = Uint8Array.of(0xa2, 0x61, 0x61, 0x01, 0x61, 0x61, 0x02)
expect(() => decodeCborMap(bytes)).toThrow(/duplicate/)
})
})
describe('§4.1 OPEN payload (CBOR of MuxOpen)', () => {
it('round-trips MuxOpen through encode/decode (identity)', () => {
const open = sampleOpen()
expect(decodeOpen(encodeOpen(open))).toEqual(open)
})
it('is deterministic — same input yields identical bytes', () => {
expect([...encodeOpen(sampleOpen())]).toEqual([...encodeOpen(sampleOpen())])
})
it('rejects a MuxOpen with streamId 0 (reserved for connection-level control)', () => {
expect(() => encodeOpen({ ...sampleOpen(), streamId: 0 })).toThrow()
})
it('rejects a decoded payload with a missing field', () => {
const bad = encodeCborMap({ subdomain: 'x' }, ['subdomain'])
expect(() => decodeOpen(bad)).toThrow(ContractDecodeError)
})
})
describe('§4.1 WINDOW_UPDATE codec', () => {
it('KAT: encodes credit=255 to 4 big-endian bytes', () => {
expect([...encodeWindowUpdate(255)]).toEqual([0x00, 0x00, 0x00, 0xff])
})
it('round-trips a credit value', () => {
expect(decodeWindowUpdate(encodeWindowUpdate(0x0abbccdd))).toBe(0x0abbccdd)
})
it('rejects a wrong-length payload', () => {
expect(() => decodeWindowUpdate(Uint8Array.of(1, 2, 3))).toThrow(ContractDecodeError)
})
})
describe('§4.1 GOAWAY codec', () => {
it('KAT: encodes lastStreamId=10, reason=revoked to fixed bytes', () => {
expect([...encodeGoaway(10, 'revoked')]).toEqual([
0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x02,
])
})
it.each([
['operatorDrain', 1],
['revoked', 2],
['shutdown', 3],
] as const)('round-trips reason=%s (code %i)', (reason, code) => {
const bytes = encodeGoaway(1, reason)
expect([...bytes.subarray(4)]).toEqual([0x00, 0x00, 0x00, code])
expect(decodeGoaway(bytes)).toEqual({ lastStreamId: 1, reason })
})
it('decodeGoAwayReason maps frozen wire codes', () => {
expect(decodeGoAwayReason(1)).toBe('operatorDrain')
expect(decodeGoAwayReason(2)).toBe('revoked')
expect(decodeGoAwayReason(3)).toBe('shutdown')
})
it('decodeGoAwayReason throws on an unknown code', () => {
expect(() => decodeGoAwayReason(99)).toThrow(ContractDecodeError)
})
it('rejects a wrong-length GOAWAY payload', () => {
expect(() => decodeGoaway(Uint8Array.of(0, 0, 0, 1))).toThrow(ContractDecodeError)
})
})

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts', 'src/**/*.d.ts'],
},
},
})