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:
67
relay-contracts/test/base64url-subprotocol.test.ts
Normal file
67
relay-contracts/test/base64url-subprotocol.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
95
relay-contracts/test/capability-pairing.test.ts
Normal file
95
relay-contracts/test/capability-pairing.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
43
relay-contracts/test/e2e-crypto-stubs.test.ts
Normal file
43
relay-contracts/test/e2e-crypto-stubs.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
81
relay-contracts/test/e2e-envelope.test.ts
Normal file
81
relay-contracts/test/e2e-envelope.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
130
relay-contracts/test/model.test.ts
Normal file
130
relay-contracts/test/model.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
97
relay-contracts/test/mux-frame.test.ts
Normal file
97
relay-contracts/test/mux-frame.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
113
relay-contracts/test/mux-open-control.test.ts
Normal file
113
relay-contracts/test/mux-open-control.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user