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

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)
})
})