Files
web-terminal/term-relay/test/frame-codec.test.ts
Yaojia Wang 2af57e6686 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.
2026-07-02 06:10:16 +02:00

113 lines
3.6 KiB
TypeScript

import { describe, test, expect } from 'vitest'
import {
MUX_HEADER_BYTES,
encodeMuxFrame,
decodeHeader,
decodeMuxFrame,
encodeOpen,
decodeOpen,
encodeWindowUpdate,
decodeWindowUpdate,
encodeGoaway,
decodeGoaway,
TYPE_TO_BYTE,
BYTE_TO_TYPE,
type MuxFrameHeader,
type MuxFrameType,
type MuxOpen,
} from '../mux/frame-codec.js'
const ALL_TYPES: readonly MuxFrameType[] = [
'open',
'data',
'close',
'ping',
'pong',
'windowUpdate',
'goaway',
]
function header(over: Partial<MuxFrameHeader>): MuxFrameHeader {
return { version: 1, type: 'data', fin: false, rst: false, streamId: 7, payloadLen: 0, ...over }
}
describe('frame codec (T2, §4.1) — re-exported from the frozen relay-contracts codec', () => {
test('round-trips every frame type incl. fin/rst bit packing', () => {
for (const type of ALL_TYPES) {
const payload = new Uint8Array([1, 2, 3])
const h = header({ type, fin: true, rst: false, streamId: 42, payloadLen: payload.length })
const decoded = decodeMuxFrame(encodeMuxFrame(h, payload))
expect(decoded.header.type).toBe(type)
expect(decoded.header.fin).toBe(true)
expect(decoded.header.rst).toBe(false)
expect(decoded.header.streamId).toBe(42)
expect([...decoded.payload]).toEqual([1, 2, 3])
}
})
test('streamId=0 link-level frames decode with streamId===0', () => {
const h = header({ type: 'ping', streamId: 0, payloadLen: 0 })
expect(decodeHeader(encodeMuxFrame(h, new Uint8Array(0))).streamId).toBe(0)
})
test('rst bit round-trips independently of fin', () => {
const h = header({ type: 'close', fin: false, rst: true, payloadLen: 0 })
const d = decodeHeader(encodeMuxFrame(h, new Uint8Array(0)))
expect(d.rst).toBe(true)
expect(d.fin).toBe(false)
})
test('type ⇄ byte maps agree with §4.1 (0x01 OPEN … 0x07 GOAWAY)', () => {
expect(TYPE_TO_BYTE.open).toBe(0x01)
expect(TYPE_TO_BYTE.goaway).toBe(0x07)
expect(BYTE_TO_TYPE[0x02]).toBe('data')
})
test('negative: truncated buffer (< 15B) throws', () => {
expect(() => decodeHeader(new Uint8Array(MUX_HEADER_BYTES - 1))).toThrow()
})
test('negative: payloadLen larger than actual payload throws', () => {
const buf = encodeMuxFrame(header({ payloadLen: 2 }), new Uint8Array([9, 9]))
// Claim a longer payload than present.
buf[14] = 5
expect(() => decodeMuxFrame(buf)).toThrow()
})
test('negative: unknown type byte throws', () => {
const buf = encodeMuxFrame(header({ type: 'data', payloadLen: 0 }), new Uint8Array(0))
buf[1] = 0x08 // unknown type code
expect(() => decodeHeader(buf)).toThrow()
buf[1] = 0x00
expect(() => decodeHeader(buf)).toThrow()
})
test('negative: version ≠ 0x01 throws', () => {
const buf = encodeMuxFrame(header({ payloadLen: 0 }), new Uint8Array(0))
buf[0] = 0x02
expect(() => decodeHeader(buf)).toThrow()
})
test('OPEN payload round-trips via CBOR + Zod guard', () => {
const open: MuxOpen = {
streamId: 3,
subdomain: 'alice',
requestPath: '/term?join=x',
originHeader: 'https://alice.term.example.com',
remoteAddrHash: 'deadbeef',
capabilityTokenRef: 'jti-1',
}
expect(decodeOpen(encodeOpen(open))).toEqual(open)
})
test('decodeOpen rejects a malformed/partial CBOR payload (never returns partial)', () => {
expect(() => decodeOpen(new Uint8Array([0x00, 0x01, 0x02]))).toThrow()
})
test('WINDOW_UPDATE + GOAWAY int codecs round-trip', () => {
expect(decodeWindowUpdate(encodeWindowUpdate(65_536))).toBe(65_536)
const g = decodeGoaway(encodeGoaway(9, 'revoked'))
expect(g).toEqual({ lastStreamId: 9, reason: 'revoked' })
})
})