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.
125 lines
5.1 KiB
TypeScript
125 lines
5.1 KiB
TypeScript
import { describe, test, expect, vi } from 'vitest'
|
|
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
|
import {
|
|
authorizeUpgrade,
|
|
extractCapabilityToken,
|
|
TOKEN_COOKIE_NAME,
|
|
type UpgradeRequest,
|
|
type AuthorizeDeps,
|
|
} from '../data-plane/upgrade.js'
|
|
import type { Authorizer, AuthzOutcome } from '../data-plane/authz-port.js'
|
|
import type { RouteResolver, ResolvedHost } from '../data-plane/subdomain-router.js'
|
|
|
|
const RAW_TOKEN = 'cap-token-abc123'
|
|
const RESOLVED: ResolvedHost = { hostId: 'host-uuid-1', accountId: 'acct-1', subdomain: 'alice' }
|
|
|
|
function resolver(overrides?: Partial<Record<string, ResolvedHost | null>>): RouteResolver {
|
|
return {
|
|
resolveSubdomain: async (sub) => {
|
|
if (overrides && sub in overrides) return overrides[sub] ?? null
|
|
return sub === 'alice' ? RESOLVED : null
|
|
},
|
|
}
|
|
}
|
|
|
|
function okAuthorizer(): Authorizer {
|
|
const ok: AuthzOutcome = { ok: true, hostId: RESOLVED.hostId, principal: 'p-1', jti: 'jti-9' }
|
|
return { onUpgrade: vi.fn(async () => ok), onReattach: vi.fn(async () => ok) }
|
|
}
|
|
|
|
function baseReq(over?: Partial<UpgradeRequest>): UpgradeRequest {
|
|
return {
|
|
host: 'alice.term.example.com',
|
|
origin: 'https://alice.term.example.com',
|
|
url: '/term?join=x',
|
|
subprotocols: [APP_SUBPROTOCOL, encodeTokenSubprotocol(RAW_TOKEN)],
|
|
cookies: {},
|
|
remoteAddr: '203.0.113.7',
|
|
dpop: { proof: 'proof', publicKeyThumbprint: 'jkt' },
|
|
activeSessionCount: 2,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
function deps(authorizer: Authorizer, r: RouteResolver = resolver()): AuthorizeDeps {
|
|
return {
|
|
authorizer,
|
|
resolver: r,
|
|
baseDomain: 'term.example.com',
|
|
now: () => 1000,
|
|
remoteAddrSalt: 'salt',
|
|
requiredRight: 'attach',
|
|
}
|
|
}
|
|
|
|
describe('authorizeUpgrade — thin adapter over P5 (T8, FIX 6a)', () => {
|
|
test('happy: delegates to onUpgrade with a fully-populated ctx; maps to {ok:true}', async () => {
|
|
const auth = okAuthorizer()
|
|
const d = deps(auth)
|
|
const res = await authorizeUpgrade(baseReq(), d)
|
|
expect(res.ok).toBe(true)
|
|
if (!res.ok) return
|
|
expect(res.hostId).toBe(RESOLVED.hostId)
|
|
expect(res.open.subdomain).toBe('alice')
|
|
expect(res.open.capabilityTokenRef).toBe('jti-9')
|
|
expect(res.acceptedSubprotocol).toBe(APP_SUBPROTOCOL)
|
|
expect(auth.onUpgrade).toHaveBeenCalledTimes(1)
|
|
const ctx = (auth.onUpgrade as ReturnType<typeof vi.fn>).mock.calls[0]![0]
|
|
expect(ctx.expectedAud).toBe('alice')
|
|
expect(ctx.requestedHostId).toBe(RESOLVED.hostId)
|
|
expect(ctx.requiredRight).toBe('attach')
|
|
expect(ctx.capabilityRaw).toBe(RAW_TOKEN) // decoded from the subprotocol carrier
|
|
expect(ctx.dpop).toEqual({ proof: 'proof', publicKeyThumbprint: 'jkt' })
|
|
expect(ctx.activeSessionCount).toBe(2)
|
|
expect(ctx.principal).toBeNull() // P5 resolves identity from the signed token (INV3)
|
|
})
|
|
|
|
test('P5 deny is passed through verbatim; no independent allow branch', async () => {
|
|
const deny: AuthzOutcome = { ok: false, status: 401 }
|
|
const auth: Authorizer = { onUpgrade: vi.fn(async () => deny), onReattach: vi.fn(async () => deny) }
|
|
const res = await authorizeUpgrade(baseReq(), deps(auth))
|
|
expect(res).toEqual({ ok: false, status: 401 })
|
|
})
|
|
|
|
test('reattach: sessionId present → onReattach is called (not onUpgrade)', async () => {
|
|
const auth = okAuthorizer()
|
|
await authorizeUpgrade(baseReq({ sessionId: 'sess-1' }), deps(auth))
|
|
expect(auth.onReattach).toHaveBeenCalledTimes(1)
|
|
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
|
const ctx = (auth.onReattach as ReturnType<typeof vi.fn>).mock.calls[0]![0]
|
|
expect(ctx.sessionId).toBe('sess-1')
|
|
})
|
|
|
|
test('token via cookie fallback is accepted', async () => {
|
|
const auth = okAuthorizer()
|
|
const req = baseReq({ subprotocols: [APP_SUBPROTOCOL], cookies: { [TOKEN_COOKIE_NAME]: RAW_TOKEN } })
|
|
const res = await authorizeUpgrade(req, deps(auth))
|
|
expect(res.ok).toBe(true)
|
|
const via = extractCapabilityToken(req)
|
|
expect(via).toEqual({ token: RAW_TOKEN, via: 'cookie' })
|
|
})
|
|
|
|
test('INV15: a token ONLY in the query string → 401 and the authorizer is NEVER called', async () => {
|
|
const auth = okAuthorizer()
|
|
const req = baseReq({ url: `/term?cap=${RAW_TOKEN}`, subprotocols: [APP_SUBPROTOCOL], cookies: {} })
|
|
const res = await authorizeUpgrade(req, deps(auth))
|
|
expect(res).toEqual({ ok: false, status: 401 })
|
|
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
|
expect(extractCapabilityToken(req)).toBeNull() // never reads req.url
|
|
})
|
|
|
|
test('local parse: unparseable Host → 401 without calling the authorizer', async () => {
|
|
const auth = okAuthorizer()
|
|
const res = await authorizeUpgrade(baseReq({ host: 'term.example.com' }), deps(auth))
|
|
expect(res).toEqual({ ok: false, status: 401 })
|
|
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('local parse: unknown subdomain (resolver null) → 403 without calling the authorizer', async () => {
|
|
const auth = okAuthorizer()
|
|
const res = await authorizeUpgrade(baseReq({ host: 'bob.term.example.com' }), deps(auth))
|
|
expect(res).toEqual({ ok: false, status: 403 })
|
|
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
|
})
|
|
})
|