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:
162
relay-contracts/src/mux/cbor.ts
Normal file
162
relay-contracts/src/mux/cbor.ts
Normal 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
|
||||
}
|
||||
64
relay-contracts/src/mux/control.ts
Normal file
64
relay-contracts/src/mux/control.ts
Normal 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 }
|
||||
}
|
||||
97
relay-contracts/src/mux/frame.ts
Normal file
97
relay-contracts/src/mux/frame.ts
Normal 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 }
|
||||
}
|
||||
41
relay-contracts/src/mux/open.ts
Normal file
41
relay-contracts/src/mux/open.ts
Normal 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
|
||||
}
|
||||
106
relay-contracts/src/mux/types.ts
Normal file
106
relay-contracts/src/mux/types.ts
Normal 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',
|
||||
}
|
||||
Reference in New Issue
Block a user