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:
5
relay-web/.gitignore
vendored
Normal file
5
relay-web/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
public/build/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
30
relay-web/build.mjs
Normal file
30
relay-web/build.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* relay-web esbuild bundler (T1). Bundles each static entry page's TS to public/build/.
|
||||
* Browser target, ESM, sourcemaps — mirrors the base app's `build:web` conventions without
|
||||
* importing public/. No inline script anywhere (strict-CSP friendly); every HTML page loads
|
||||
* its bundle via <script type="module" src="build/<name>.js">.
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** Entry map: bundle name -> source module. Add a line per new HTML page. */
|
||||
const ENTRIES = {
|
||||
index: resolve(here, 'src/entry/index-page.ts'),
|
||||
dashboard: resolve(here, 'src/entry/dashboard-page.ts'),
|
||||
pair: resolve(here, 'src/entry/pair-page.ts'),
|
||||
manage: resolve(here, 'src/entry/manage-page.ts'),
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: ENTRIES,
|
||||
outdir: resolve(here, 'public/build'),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
})
|
||||
2631
relay-web/package-lock.json
generated
Normal file
2631
relay-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
relay-web/package.json
Normal file
37
relay-web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "relay-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "P6 — browser bundle served from the tenant subdomain for the rendezvous-relay service. Password/passkey login, subdomain connect (same-origin, scheme-following wss:, M6), terminal view over an abstract TerminalTransport (passthrough in v0.8, browser-side E2E in v0.10 consuming the frozen relay-contracts/relay-e2e §4.4 surface), dashboard, add-machine onboarding, and CLIENT-SIDE preview rendering. Imports relay-contracts read-only; NEVER edits base public/ or src/. See docs/PLAN_RELAY_FRONTEND.md.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node build.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"relay-e2e": "file:../relay-e2e",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
19
relay-web/public/dashboard.html
Normal file
19
relay-web/public/dashboard.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
/>
|
||||
<title>web-terminal · dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Your machines</h1>
|
||||
<section id="dashboard"></section>
|
||||
</main>
|
||||
<script type="module" src="/build/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
relay-web/public/index.html
Normal file
22
relay-web/public/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- Strict CSP: no inline script; bundle loaded as a module. Terminal payload is untrusted
|
||||
bytes rendered by xterm, NEVER HTML. -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'"
|
||||
/>
|
||||
<title>web-terminal · connect</title>
|
||||
<link rel="stylesheet" href="/build/xterm.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section id="login"></section>
|
||||
<section id="terminal" hidden></section>
|
||||
</main>
|
||||
<script type="module" src="/build/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
relay-web/public/manage.html
Normal file
21
relay-web/public/manage.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
/>
|
||||
<title>web-terminal · manage (client-side previews)</title>
|
||||
<link rel="stylesheet" href="/build/xterm.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Sessions</h1>
|
||||
<!-- Previews are decrypted and rendered CLIENT-SIDE — the relay cannot read them (E2E). -->
|
||||
<section id="manage"></section>
|
||||
</main>
|
||||
<script type="module" src="/build/manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
relay-web/public/pair.html
Normal file
20
relay-web/public/pair.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
/>
|
||||
<title>web-terminal · add a machine</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Add a machine</h1>
|
||||
<p>Run the command below on the machine you want to reach, then leave this page open.</p>
|
||||
<section id="pair"></section>
|
||||
</main>
|
||||
<script type="module" src="/build/pair.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
116
relay-web/src/add-machine.ts
Normal file
116
relay-web/src/add-machine.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* T6 (v0.9) — add-machine onboarding (pairing) from the browser's side (§4.5 ISSUE).
|
||||
*
|
||||
* `start()` requests a single-use, short-TTL pairing code (P3), displays the copy-paste command
|
||||
* `npx web-terminal-agent pair <code>` and a countdown to `expiresAt`, then polls `listHosts` until
|
||||
* a new host flips `●online` → fires `onPaired` ONCE. KPI: first-shell-in-under-2-minutes
|
||||
* (EXPLORE §6). After `expiresAt` it shows "expired, generate a new code" and STOPS polling — the
|
||||
* client never re-displays a redeemed/expired code, and never persists it (INV5: transient state).
|
||||
*/
|
||||
import type { ApiClient } from './api-client'
|
||||
import { ApiError } from './errors'
|
||||
|
||||
const DEFAULT_POLL_MS = 3000
|
||||
const AGENT_PAIR_CMD = 'npx web-terminal-agent pair'
|
||||
|
||||
export interface AddMachine {
|
||||
start(): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface AddMachineOpts {
|
||||
readonly pollMs?: number
|
||||
readonly onPaired?: (hostId: string) => void
|
||||
/** Injectable clock for deterministic expiry tests; defaults to `Date.now`. */
|
||||
readonly now?: () => number
|
||||
}
|
||||
|
||||
export function mountAddMachine(root: HTMLElement, api: ApiClient, opts: AddMachineOpts = {}): AddMachine {
|
||||
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS
|
||||
const now = opts.now ?? (() => Date.now())
|
||||
|
||||
const cmd = document.createElement('code')
|
||||
cmd.className = 'pair-command'
|
||||
const status = document.createElement('p')
|
||||
status.className = 'pair-status'
|
||||
status.setAttribute('role', 'status')
|
||||
const error = document.createElement('p')
|
||||
error.className = 'pair-error'
|
||||
error.setAttribute('role', 'alert')
|
||||
root.append(cmd, status, error)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let disposed = false
|
||||
let paired = false
|
||||
let knownHostIds: ReadonlySet<string> = new Set()
|
||||
|
||||
function stopPolling(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(expiresAtMs: number): Promise<void> {
|
||||
if (disposed || paired) return
|
||||
if (now() >= expiresAtMs) {
|
||||
stopPolling()
|
||||
status.textContent = 'Code expired — generate a new code.'
|
||||
cmd.textContent = '' // never re-display an expired/redeemed code
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hosts = await api.listHosts()
|
||||
if (disposed || paired) return
|
||||
const fresh = hosts.find((h) => h.status === 'online' && !knownHostIds.has(h.hostId))
|
||||
if (fresh) {
|
||||
paired = true
|
||||
stopPolling()
|
||||
status.textContent = `Paired: ${fresh.subdomain} is online.`
|
||||
opts.onPaired?.(fresh.hostId)
|
||||
}
|
||||
} catch (err) {
|
||||
// A transient poll error is non-fatal; the loop retries until expiry (no infinite hang).
|
||||
error.textContent =
|
||||
err instanceof ApiError ? `Status check failed (${err.kind}).` : 'Status check failed.'
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
error.textContent = ''
|
||||
// Snapshot existing hosts so we detect the NEW one that pairs in.
|
||||
try {
|
||||
knownHostIds = new Set((await api.listHosts()).map((h) => h.hostId))
|
||||
} catch {
|
||||
knownHostIds = new Set()
|
||||
}
|
||||
|
||||
let code: string
|
||||
let expiresAt: string
|
||||
try {
|
||||
const issued = await api.requestPairingCode()
|
||||
code = issued.code
|
||||
expiresAt = issued.expiresAt
|
||||
} catch (err) {
|
||||
error.textContent =
|
||||
err instanceof ApiError ? `Could not issue a code (${err.kind}).` : 'Could not issue a code.'
|
||||
return
|
||||
}
|
||||
|
||||
cmd.textContent = `${AGENT_PAIR_CMD} ${code}` // textContent-only, no injection
|
||||
status.textContent = 'Waiting for the host to pair…'
|
||||
const expiresAtMs = Date.parse(expiresAt)
|
||||
|
||||
stopPolling()
|
||||
timer = setInterval(() => void poll(expiresAtMs), pollMs)
|
||||
void poll(expiresAtMs)
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
dispose(): void {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
},
|
||||
}
|
||||
}
|
||||
117
relay-web/src/api-client.ts
Normal file
117
relay-web/src/api-client.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* T2 — Zod-validated control-plane HTTP client.
|
||||
*
|
||||
* INV3 (owned): the request builders have NO parameter for account/tenant; every call carries only
|
||||
* the same-origin cookie/passkey session (`credentials: 'include'`). The account is derived
|
||||
* server-side. Every response is parsed through a Zod schema before return; a parse failure throws
|
||||
* a typed {@link ApiError} rather than returning a torn object.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { RelayWebConfig } from './config'
|
||||
import { ApiError } from './errors'
|
||||
import {
|
||||
CapabilityTokenResponseSchema,
|
||||
HostListWireSchema,
|
||||
HostRecordWireSchema,
|
||||
HostStatusResponseSchema,
|
||||
PairingCodeResponseSchema,
|
||||
type CapabilityRight,
|
||||
type HostRecord,
|
||||
type HostStatus,
|
||||
} from './api-schemas'
|
||||
|
||||
/** v0.8 surface — ships against the flat SQLite table + cookie session. No token issuance. */
|
||||
export interface ApiClientV08 {
|
||||
listHosts(): Promise<readonly HostRecord[]>
|
||||
getHost(hostId: string): Promise<HostRecord>
|
||||
requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }>
|
||||
hostStatus(hostId: string): Promise<HostStatus>
|
||||
}
|
||||
|
||||
/** v0.9+ surface — GROWS once P5 capability-token issuance exists (INDEX §1). */
|
||||
export interface ApiClient extends ApiClientV08 {
|
||||
issueCapabilityToken(hostId: string, rights: readonly CapabilityRight[]): Promise<string>
|
||||
}
|
||||
|
||||
const JSON_HEADERS: Readonly<Record<string, string>> = { Accept: 'application/json' }
|
||||
|
||||
/** Map a non-OK HTTP status to a typed ApiError kind (no silent swallow). */
|
||||
function errorForStatus(status: number, path: string): ApiError {
|
||||
if (status === 401) return new ApiError('unauthenticated', `unauthenticated at ${path}`, status)
|
||||
if (status === 403) return new ApiError('forbidden', `forbidden at ${path}`, status)
|
||||
return new ApiError('http', `request to ${path} failed with status ${status}`, status)
|
||||
}
|
||||
|
||||
export function createApiClient(cfg: RelayWebConfig, fetchImpl: typeof fetch = fetch): ApiClient {
|
||||
const base = cfg.apiBase
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchImpl(`${base}${path}`, {
|
||||
credentials: 'include', // cookie/passkey session — the ONLY identity (INV3)
|
||||
headers: JSON_HEADERS,
|
||||
...init,
|
||||
})
|
||||
} catch (cause) {
|
||||
throw new ApiError('network', `network error calling ${path}`, null)
|
||||
}
|
||||
if (!res.ok) throw errorForStatus(res.status, path)
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
throw new ApiError('parse', `invalid JSON body from ${path}`, res.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse `body` with `schema`, converting a ZodError into a typed ApiError('parse'). */
|
||||
function validate<S extends z.ZodTypeAny>(schema: S, body: unknown, path: string): z.infer<S> {
|
||||
const parsed = schema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
throw new ApiError('parse', `malformed response from ${path}: ${parsed.error.message}`)
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function postJson(path: string, payload: unknown): Promise<unknown> {
|
||||
return requestJson(path, {
|
||||
method: 'POST',
|
||||
headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
async listHosts(): Promise<readonly HostRecord[]> {
|
||||
const path = '/api/hosts'
|
||||
return validate(HostListWireSchema, await requestJson(path), path)
|
||||
},
|
||||
|
||||
async getHost(hostId: string): Promise<HostRecord> {
|
||||
const path = `/api/hosts/${encodeURIComponent(hostId)}`
|
||||
return validate(HostRecordWireSchema, await requestJson(path), path)
|
||||
},
|
||||
|
||||
async requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }> {
|
||||
const path = '/api/pairing-codes'
|
||||
// Body is empty: the account is derived from the session, NEVER sent (INV3).
|
||||
return validate(PairingCodeResponseSchema, await postJson(path, {}), path)
|
||||
},
|
||||
|
||||
async hostStatus(hostId: string): Promise<HostStatus> {
|
||||
const path = `/api/hosts/${encodeURIComponent(hostId)}/status`
|
||||
const parsed = validate(HostStatusResponseSchema, await requestJson(path), path)
|
||||
return parsed.status
|
||||
},
|
||||
|
||||
async issueCapabilityToken(
|
||||
hostId: string,
|
||||
rights: readonly CapabilityRight[],
|
||||
): Promise<string> {
|
||||
const path = `/api/hosts/${encodeURIComponent(hostId)}/capability-token`
|
||||
// Only the rights subset is sent; host+account scope is bound server-side from the session.
|
||||
const parsed = validate(CapabilityTokenResponseSchema, await postJson(path, { rights }), path)
|
||||
return parsed.token
|
||||
},
|
||||
}
|
||||
}
|
||||
72
relay-web/src/api-schemas.ts
Normal file
72
relay-web/src/api-schemas.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* T2 — the frozen CLIENT-contract surface. Re-exports the `relay-contracts` §4.2/§4.3 shapes
|
||||
* VERBATIM (never redefines them) and adds ONLY response envelopes needed to parse HTTP JSON.
|
||||
*
|
||||
* INV3: there is NO `account_id`/`tenant_id` REQUEST field anywhere here — identity is always the
|
||||
* cookie/passkey session the browser already holds; the server derives the account.
|
||||
*
|
||||
* Wire note: over JSON, `HostRecord.agentPubkey` arrives base64url-encoded (a Uint8Array cannot
|
||||
* ride JSON). The wire schema decodes it back to `Uint8Array` so callers receive a genuine
|
||||
* `HostRecord` — validate-at-boundary (coding-style.md "Input Validation").
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
HostStatusSchema,
|
||||
decodeBase64UrlBytes,
|
||||
type HostRecord,
|
||||
type HostStatus,
|
||||
type CapabilityRight,
|
||||
} from 'relay-contracts'
|
||||
|
||||
// Re-export the frozen types verbatim so P6 modules import them from one place (the src/types.ts
|
||||
// discipline: a new shared field is changed in relay-contracts, never redeclared locally).
|
||||
export type { HostRecord, HostStatus, CapabilityRight }
|
||||
export { HostStatusSchema }
|
||||
|
||||
/** Wire form of a HostRecord: `agentPubkey` is base64url text, decoded to bytes on parse. */
|
||||
export const HostRecordWireSchema = z
|
||||
.object({
|
||||
hostId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
subdomain: z.string().min(1),
|
||||
agentPubkey: z.string().min(1),
|
||||
enrollFpr: z.string().min(1),
|
||||
status: HostStatusSchema,
|
||||
lastSeen: z.string().min(1),
|
||||
createdAt: z.string().min(1),
|
||||
revokedAt: z.string().min(1).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.transform(
|
||||
(w): HostRecord => ({
|
||||
hostId: w.hostId,
|
||||
accountId: w.accountId,
|
||||
subdomain: w.subdomain,
|
||||
// Copy into a fresh ArrayBuffer-backed Uint8Array so the type matches HostRecord exactly.
|
||||
agentPubkey: new Uint8Array(decodeBase64UrlBytes(w.agentPubkey)),
|
||||
enrollFpr: w.enrollFpr,
|
||||
status: w.status,
|
||||
lastSeen: w.lastSeen,
|
||||
createdAt: w.createdAt,
|
||||
revokedAt: w.revokedAt,
|
||||
}),
|
||||
)
|
||||
|
||||
/** `GET /api/hosts` → array of HostRecord. */
|
||||
export const HostListWireSchema = z.array(HostRecordWireSchema).readonly()
|
||||
|
||||
/** `GET /api/hosts/:id/status` → the host's current status. */
|
||||
export const HostStatusResponseSchema = z.object({ status: HostStatusSchema }).strict()
|
||||
|
||||
/** `POST /api/pairing-codes` → single-use short-TTL code + its expiry (§4.5 ISSUE). */
|
||||
export const PairingCodeResponseSchema = z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
expiresAt: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
export type PairingCodeResponse = z.infer<typeof PairingCodeResponseSchema>
|
||||
|
||||
/** `POST /api/hosts/:id/capability-token` → opaque raw §4.3 token (v0.9+; never decoded to trust). */
|
||||
export const CapabilityTokenResponseSchema = z.object({ token: z.string().min(1) }).strict()
|
||||
53
relay-web/src/config.ts
Normal file
53
relay-web/src/config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* T1 — runtime config derived from `location` (no host/IP knob exists in the client, so the
|
||||
* browser can never be pointed at another tenant's origin — supports INV1 upstream).
|
||||
*
|
||||
* The tenant origin is `<subdomain>.term.<domain>`. `wsUrl` is ALWAYS same-origin and
|
||||
* scheme-following (`wss:` on HTTPS, else `ws:`) — the M6 guarantee that avoids mixed-content
|
||||
* blocking on TLS/Tailscale deploys.
|
||||
*/
|
||||
import { ConfigError } from './errors'
|
||||
|
||||
/** The label that marks the tenant zone: `<subdomain>.term.<domain>`. */
|
||||
const TENANT_ZONE_LABEL = 'term'
|
||||
|
||||
export interface RelayWebConfig {
|
||||
/** left-most hostname label, e.g. 'alice' in alice.term.example.com */
|
||||
readonly subdomain: string
|
||||
/** scheme-following, SAME-ORIGIN ws URL builder (M6); a path cannot inject a foreign host */
|
||||
readonly wsUrl: (path: string) => string
|
||||
/** same-origin API base ('' — every control-plane call is relative) */
|
||||
readonly apiBase: string
|
||||
}
|
||||
|
||||
/** Parse the tenant subdomain from a hostname, or throw (fail-fast, never default to a tenant). */
|
||||
function parseSubdomain(hostname: string): string {
|
||||
const labels = hostname.split('.')
|
||||
const first = labels[0]
|
||||
// Require the `<sub>.term.<...>` shape: a non-empty left label with `term` as the 2nd label.
|
||||
if (labels.length < 3 || labels[1] !== TENANT_ZONE_LABEL || first === undefined || first === '') {
|
||||
throw new ConfigError(
|
||||
`hostname '${hostname}' has no tenant subdomain (expected '<subdomain>.${TENANT_ZONE_LABEL}.<domain>')`,
|
||||
)
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the client runtime config from `location`. Throws {@link ConfigError} on a host that is
|
||||
* not a valid tenant subdomain — it never silently falls back to another tenant's subdomain.
|
||||
*/
|
||||
export function readConfig(loc: Pick<Location, 'protocol' | 'host' | 'hostname'>): RelayWebConfig {
|
||||
const subdomain = parseSubdomain(loc.hostname)
|
||||
const wsScheme = loc.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = loc.host
|
||||
|
||||
const wsUrl = (path: string): string => {
|
||||
// Always anchor to our own host; normalize to a leading '/' so a caller-supplied
|
||||
// `//evil.com` becomes a PATH on our host, never a protocol-relative foreign origin.
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
return `${wsScheme}://${host}${normalized}`
|
||||
}
|
||||
|
||||
return Object.freeze({ subdomain, wsUrl, apiBase: '' })
|
||||
}
|
||||
105
relay-web/src/dashboard.ts
Normal file
105
relay-web/src/dashboard.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* T5 (v0.9) — dashboard host list with `●online/offline/draining/revoked` status.
|
||||
*
|
||||
* Renders `api.listHosts()` as cards (subdomain, status dot, last_seen, an "open" action routing to
|
||||
* the terminal view for that host_id) and polls for live `●online` flips. The UI reflects
|
||||
* server-authorized hosts only — there is NO input path to name a foreign host_id (INV1/INV3
|
||||
* defense-in-depth; the real gate is server-side). A `revoked` host is non-interactive (INV12 UI).
|
||||
*
|
||||
* All dynamic text is set via `textContent` (never `innerHTML`) — XSS discipline.
|
||||
*/
|
||||
import type { ApiClient } from './api-client'
|
||||
import type { HostRecord, HostStatus } from './api-schemas'
|
||||
import { ApiError } from './errors'
|
||||
|
||||
const DEFAULT_POLL_MS = 5000
|
||||
|
||||
export interface Dashboard {
|
||||
refresh(): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface DashboardOpts {
|
||||
readonly pollMs?: number
|
||||
/** Routes to the terminal view for a host; injected so the module owns no navigation policy. */
|
||||
readonly onOpen?: (hostId: string) => void
|
||||
}
|
||||
|
||||
/** Statuses that must NOT expose an "open" action in the UI (INV12 defense-in-depth). */
|
||||
const NON_INTERACTIVE: ReadonlySet<HostStatus> = new Set<HostStatus>(['revoked'])
|
||||
|
||||
function buildCard(host: HostRecord, opts: DashboardOpts): HTMLElement {
|
||||
const card = document.createElement('div')
|
||||
card.className = 'host-card'
|
||||
card.dataset['hostId'] = host.hostId
|
||||
card.dataset['status'] = host.status
|
||||
|
||||
const dot = document.createElement('span')
|
||||
dot.className = `status-dot status-${host.status}`
|
||||
dot.setAttribute('aria-label', host.status)
|
||||
dot.textContent = '●'
|
||||
|
||||
const name = document.createElement('span')
|
||||
name.className = 'host-subdomain'
|
||||
name.textContent = host.subdomain
|
||||
|
||||
const seen = document.createElement('span')
|
||||
seen.className = 'host-last-seen'
|
||||
seen.textContent = host.lastSeen
|
||||
|
||||
card.append(dot, name, seen)
|
||||
|
||||
if (NON_INTERACTIVE.has(host.status)) {
|
||||
const blocked = document.createElement('span')
|
||||
blocked.className = 'host-blocked'
|
||||
blocked.textContent = 'revoked'
|
||||
card.append(blocked)
|
||||
} else {
|
||||
const open = document.createElement('button')
|
||||
open.className = 'host-open'
|
||||
open.type = 'button'
|
||||
open.textContent = 'open'
|
||||
open.addEventListener('click', () => opts.onOpen?.(host.hostId))
|
||||
card.append(open)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
export function mountDashboard(root: HTMLElement, api: ApiClient, opts: DashboardOpts = {}): Dashboard {
|
||||
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS
|
||||
|
||||
const list = document.createElement('div')
|
||||
list.className = 'host-list'
|
||||
const banner = document.createElement('p')
|
||||
banner.className = 'dashboard-error'
|
||||
banner.setAttribute('role', 'alert')
|
||||
root.append(banner, list)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let disposed = false
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const hosts = await api.listHosts()
|
||||
if (disposed) return
|
||||
banner.textContent = ''
|
||||
list.replaceChildren(...hosts.map((h) => buildCard(h, opts)))
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
// Zod drift or a rejection surfaces as a banner; polling continues (never renders undefined).
|
||||
banner.textContent =
|
||||
err instanceof ApiError ? `Could not load hosts (${err.kind}).` : 'Could not load hosts.'
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
timer = setInterval(() => void refresh(), pollMs)
|
||||
|
||||
return {
|
||||
refresh,
|
||||
dispose(): void {
|
||||
disposed = true
|
||||
if (timer) clearInterval(timer)
|
||||
},
|
||||
}
|
||||
}
|
||||
207
relay-web/src/e2e-socket.ts
Normal file
207
relay-web/src/e2e-socket.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* T8 (v0.10) — browser-side E2E transport. Implements the SAME `TerminalTransport` (T4) so
|
||||
* `terminal-view.ts` is unchanged (the drop-in swap). Consumes the FROZEN §4.4 surface
|
||||
* (`E2ESession`/`DirectionalKeys`/`sealFrame`/`openFrame`) — cited via injected `relay-e2e` impls,
|
||||
* NEVER re-implemented here, and NOT a single sessionKey via raw SubtleCrypto AES-GCM.
|
||||
*
|
||||
* Trust root (closes the first-connect TOFU gap): `expectedFpr` is the caller's
|
||||
* `api.getHost(hostId).enrollFpr` — the P5-authenticated HTTPS control-plane record, out-of-band of
|
||||
* the relay. On ANY disagreement between the relay-forwarded host fingerprint and `expectedFpr` —
|
||||
* INCLUDING THE VERY FIRST CONNECTION — `onPinMismatch(...,'api')` fires (default `'abort'`), the
|
||||
* handshake never completes, and NO session key material is derived. `HostPinStore` is only a
|
||||
* cache/audit trail of that authenticated value.
|
||||
*
|
||||
* Any `open`/`openFrame` throw (AEAD-tag / seq / direction-confusion failure, INV13) TEARS THE
|
||||
* SOCKET DOWN — a bad frame is never rendered or swallowed.
|
||||
*
|
||||
* INTEGRATION POINT: the concrete §4.4 handshake message framing over the relay (client_hello /
|
||||
* host_hello wire) and the crypto impls are injected via {@link E2ETransportDeps.runHandshake}
|
||||
* (wired to P4 `relay-e2e` + P2's agent-side counterpart in production). This module owns the WS
|
||||
* lifecycle, capability-token attachment (§4.3), the fingerprint-pin gate, and the seal/open
|
||||
* teardown — all independently testable with a loopback relay-e2e session pair.
|
||||
*/
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol, type AeadAlg } from 'relay-contracts'
|
||||
import type { AuthorizedDeviceContext, E2ESession } from 'relay-contracts'
|
||||
import type { RelayWebConfig } from './config'
|
||||
import type { HostPinStore } from './host-pin-store'
|
||||
import type { TerminalTransport, WebSocketCtor, WebSocketLike } from './ws-transport'
|
||||
|
||||
export type PinMismatchSource = 'api' | 'cache'
|
||||
export type FprDecision = 'trust' | 'abort'
|
||||
|
||||
/** Raw byte channel the handshake driver uses to exchange client_hello / host_hello opaquely. */
|
||||
export interface E2EHandshakeIo {
|
||||
sendRaw(bytes: Uint8Array): void
|
||||
onRaw(cb: (bytes: Uint8Array) => void): void
|
||||
}
|
||||
|
||||
export interface RunHandshakeArgs {
|
||||
readonly hostId: string
|
||||
readonly aeadOffer: readonly AeadAlg[]
|
||||
readonly context: AuthorizedDeviceContext
|
||||
/** API-sourced host signing pubkey (P3 registry, out-of-band of the relay). */
|
||||
readonly agentPubkey: Uint8Array
|
||||
/**
|
||||
* Gate called with the host's OFFERED fingerprint BEFORE any key material is derived. Returning
|
||||
* `'abort'` MUST make `runHandshake` reject without deriving keys (no MITM key material ever).
|
||||
*/
|
||||
verifyOfferedFpr(offeredFpr: string): Promise<FprDecision>
|
||||
}
|
||||
|
||||
export interface E2ETransportDeps {
|
||||
/**
|
||||
* Drives the §4.4 handshake through `io` and returns an established `E2ESession`, or rejects if
|
||||
* `verifyOfferedFpr` returned `'abort'`. Wired to relay-e2e (`buildClientHandshake` →
|
||||
* `ClientHandshake.start`/`.onHostHello` → `createE2ESession('client', result)`) in production.
|
||||
*/
|
||||
runHandshake(io: E2EHandshakeIo, args: RunHandshakeArgs): Promise<E2ESession>
|
||||
readonly context: AuthorizedDeviceContext
|
||||
readonly agentPubkey: Uint8Array
|
||||
/** AEAD offer; lists `aes-256-gcm` FIRST (fast native-adjacent path); host makes the final choice. */
|
||||
readonly aeadOffer?: readonly AeadAlg[]
|
||||
readonly wsCtor?: WebSocketCtor
|
||||
}
|
||||
|
||||
const DEFAULT_AEAD_OFFER: readonly AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305']
|
||||
|
||||
function toBytes(data: unknown): Uint8Array {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const v = data as ArrayBufferView
|
||||
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
|
||||
}
|
||||
if (typeof data === 'string') return new TextEncoder().encode(data)
|
||||
return new Uint8Array(0)
|
||||
}
|
||||
|
||||
export function createE2ETransport(
|
||||
cfg: RelayWebConfig,
|
||||
capabilityToken: string,
|
||||
hostId: string,
|
||||
expectedFpr: string,
|
||||
pins: HostPinStore,
|
||||
onPinMismatch: (
|
||||
expected: string,
|
||||
offered: string,
|
||||
source: PinMismatchSource,
|
||||
) => Promise<FprDecision>,
|
||||
deps: E2ETransportDeps,
|
||||
): TerminalTransport {
|
||||
const Ctor: WebSocketCtor = deps.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
|
||||
const url = cfg.wsUrl('/term') // SAME-ORIGIN; token rides the subprotocol, NEVER the URL (§4.3)
|
||||
const protocols: readonly string[] = [APP_SUBPROTOCOL, encodeTokenSubprotocol(capabilityToken)]
|
||||
|
||||
let ws: WebSocketLike | null = null
|
||||
let session: E2ESession | null = null
|
||||
let rawCb: ((bytes: Uint8Array) => void) | null = null // handshake-phase sink
|
||||
let userMessageCb: ((bytes: Uint8Array) => void) | null = null
|
||||
let closeCb: ((reason: string) => void) | null = null
|
||||
let torn = false
|
||||
|
||||
function tearDown(reason: string): void {
|
||||
if (torn) return
|
||||
torn = true
|
||||
if (ws) ws.close()
|
||||
if (closeCb) closeCb(reason)
|
||||
}
|
||||
|
||||
/** The fingerprint-pin gate — the anti-MITM trust decision (API value is authoritative). */
|
||||
async function verifyOfferedFpr(offeredFpr: string): Promise<FprDecision> {
|
||||
if (offeredFpr !== expectedFpr) {
|
||||
// Relay-forwarded fingerprint disagrees with the authenticated API value — first connect too.
|
||||
if ((await onPinMismatch(expectedFpr, offeredFpr, 'api')) === 'abort') return 'abort'
|
||||
}
|
||||
const cached = pins.get(hostId)
|
||||
if (cached !== null && cached !== expectedFpr) {
|
||||
// Rotation: cache disagrees with a fresh API value. Advisory — API remains authoritative.
|
||||
if ((await onPinMismatch(expectedFpr, cached, 'cache')) === 'abort') return 'abort'
|
||||
}
|
||||
return 'trust'
|
||||
}
|
||||
|
||||
function openWs(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new Ctor(url, protocols)
|
||||
socket.binaryType = 'arraybuffer'
|
||||
ws = socket
|
||||
socket.onopen = () => {
|
||||
// Echo rule (§4.3): accept ONLY the app subprotocol back (bearer-leak guard).
|
||||
if (socket.protocol !== APP_SUBPROTOCOL) {
|
||||
socket.close(4400, 'bad-subprotocol')
|
||||
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
socket.onmessage = (ev) => {
|
||||
const bytes = toBytes(ev.data)
|
||||
if (session) {
|
||||
// DATA phase: decrypt; ANY openFrame throw tears the socket down (INV13).
|
||||
let plain: Uint8Array
|
||||
try {
|
||||
plain = session.open(bytes)
|
||||
} catch {
|
||||
tearDown('e2e-verify-failed')
|
||||
return
|
||||
}
|
||||
if (userMessageCb) userMessageCb(plain)
|
||||
} else if (rawCb) {
|
||||
rawCb(bytes) // handshake phase
|
||||
}
|
||||
}
|
||||
socket.onclose = (ev) => {
|
||||
const reason = ev.code === 4403 ? 'forbidden' : ev.reason && ev.reason.length > 0 ? ev.reason : 'closed'
|
||||
if (!torn) {
|
||||
torn = true
|
||||
if (closeCb) closeCb(reason)
|
||||
}
|
||||
}
|
||||
socket.onerror = () => reject(new Error('websocket error'))
|
||||
})
|
||||
}
|
||||
|
||||
async function open(): Promise<void> {
|
||||
await openWs()
|
||||
const io: E2EHandshakeIo = {
|
||||
sendRaw: (bytes) => ws?.send(bytes),
|
||||
onRaw: (cb) => {
|
||||
rawCb = cb
|
||||
},
|
||||
}
|
||||
let established: E2ESession
|
||||
try {
|
||||
established = await deps.runHandshake(io, {
|
||||
hostId,
|
||||
aeadOffer: deps.aeadOffer ?? DEFAULT_AEAD_OFFER,
|
||||
context: deps.context,
|
||||
agentPubkey: deps.agentPubkey,
|
||||
verifyOfferedFpr,
|
||||
})
|
||||
} catch (err) {
|
||||
// Abort (fingerprint mismatch) or any handshake failure → no session, socket closed.
|
||||
tearDown('e2e-handshake-failed')
|
||||
throw err
|
||||
}
|
||||
session = established
|
||||
// Cache the API-VERIFIED fingerprint (audit/drift trail) — NOT a handshake-derived value.
|
||||
pins.record(hostId, expectedFpr)
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
send(bytes: Uint8Array): void {
|
||||
if (!ws || !session) throw new Error('e2e transport not open')
|
||||
ws.send(session.seal(bytes)) // plaintext never leaves un-AEAD'd (INV2)
|
||||
},
|
||||
onMessage(cb: (bytes: Uint8Array) => void): void {
|
||||
userMessageCb = cb
|
||||
},
|
||||
onClose(cb: (reason: string) => void): void {
|
||||
closeCb = cb
|
||||
},
|
||||
close(): void {
|
||||
tearDown('closed')
|
||||
},
|
||||
}
|
||||
}
|
||||
24
relay-web/src/entry/dashboard-page.ts
Normal file
24
relay-web/src/entry/dashboard-page.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* dashboard.html entry (v0.9) — host ●status list. No inline script (strict CSP).
|
||||
*/
|
||||
import { readConfig } from '../config'
|
||||
import { createApiClient } from '../api-client'
|
||||
import { mountDashboard } from '../dashboard'
|
||||
|
||||
function boot(): void {
|
||||
const cfg = readConfig(window.location)
|
||||
const root = document.getElementById('dashboard')
|
||||
if (!root) return
|
||||
const api = createApiClient(cfg)
|
||||
mountDashboard(root, api, {
|
||||
onOpen: (hostId) => {
|
||||
window.location.assign(`/term.html?host=${encodeURIComponent(hostId)}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot)
|
||||
} else {
|
||||
boot()
|
||||
}
|
||||
30
relay-web/src/entry/index-page.ts
Normal file
30
relay-web/src/entry/index-page.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* index.html entry — v0.8 password gate → terminal view over the passthrough transport.
|
||||
* No inline script (strict-CSP friendly): this bundle is loaded via <script type="module">.
|
||||
*/
|
||||
import { readConfig } from '../config'
|
||||
import { mountPasswordLogin } from '../login-password'
|
||||
import { createPassthroughTransport } from '../ws-transport'
|
||||
import { mountTerminalView } from '../terminal-view'
|
||||
|
||||
function boot(): void {
|
||||
const cfg = readConfig(window.location)
|
||||
const loginRoot = document.getElementById('login')
|
||||
const termRoot = document.getElementById('terminal')
|
||||
if (!loginRoot || !termRoot) return
|
||||
|
||||
mountPasswordLogin(loginRoot, cfg, {
|
||||
onSuccess: () => {
|
||||
loginRoot.hidden = true
|
||||
termRoot.hidden = false
|
||||
const transport = createPassthroughTransport(cfg)
|
||||
void transport.open().then(() => mountTerminalView(termRoot, transport))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot)
|
||||
} else {
|
||||
boot()
|
||||
}
|
||||
38
relay-web/src/entry/manage-page.ts
Normal file
38
relay-web/src/entry/manage-page.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* manage.html entry (v0.10) — CLIENT-SIDE preview grid. Server previews die under E2E; the
|
||||
* key-holding browser decrypts a ciphertext replay and renders read-only xterms. No inline script.
|
||||
*
|
||||
* The §4.4 replay crypto is imported from relay-e2e (P4) and injected into the grid — cited
|
||||
* verbatim, never re-implemented.
|
||||
*
|
||||
* INTEGRATION POINT: `loadReplay` must fetch the host's ciphertext ring-buffer replay + the §4.5
|
||||
* `hostContentSecret` (delivered via P5 after auth/step-up). Those endpoints are owned by P5/P1/P3;
|
||||
* this glue wires the shape and throws until they land, so a card shows "unavailable" rather than a
|
||||
* fabricated screen.
|
||||
*/
|
||||
import { deriveContentKey, openReplayCiphertext } from 'relay-e2e'
|
||||
import { readConfig } from '../config'
|
||||
import { createApiClient } from '../api-client'
|
||||
import { mountPreviewGrid } from '../preview-grid'
|
||||
import type { ReplaySource } from '../preview-client'
|
||||
|
||||
async function loadReplay(
|
||||
_hostId: string,
|
||||
): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> {
|
||||
// TODO(P5/P1): fetch ciphertext replay + hostContentSecret (post auth/step-up). Not yet available.
|
||||
throw new Error('replay source not yet wired (pending P5/P1 endpoints)')
|
||||
}
|
||||
|
||||
function boot(): void {
|
||||
const cfg = readConfig(window.location)
|
||||
const root = document.getElementById('manage')
|
||||
if (!root) return
|
||||
const api = createApiClient(cfg)
|
||||
mountPreviewGrid(root, api, loadReplay, { deriveContentKey, openReplayCiphertext })
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot)
|
||||
} else {
|
||||
boot()
|
||||
}
|
||||
23
relay-web/src/entry/pair-page.ts
Normal file
23
relay-web/src/entry/pair-page.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* pair.html entry (v0.9) — add-machine onboarding. No inline script (strict CSP).
|
||||
*/
|
||||
import { readConfig } from '../config'
|
||||
import { createApiClient } from '../api-client'
|
||||
import { mountAddMachine } from '../add-machine'
|
||||
|
||||
function boot(): void {
|
||||
const cfg = readConfig(window.location)
|
||||
const root = document.getElementById('pair')
|
||||
if (!root) return
|
||||
const api = createApiClient(cfg)
|
||||
const add = mountAddMachine(root, api, {
|
||||
onPaired: () => window.location.assign('/dashboard.html'),
|
||||
})
|
||||
void add.start()
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot)
|
||||
} else {
|
||||
boot()
|
||||
}
|
||||
41
relay-web/src/errors.ts
Normal file
41
relay-web/src/errors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* relay-web local error taxonomy. Typed, explicit errors (coding-style.md "Error Handling")
|
||||
* so callers never swallow failures or render partial objects.
|
||||
*/
|
||||
|
||||
/** Base class for every relay-web boundary error. */
|
||||
export class RelayWebError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RelayWebError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by readConfig when the origin is not a valid tenant subdomain (fail-fast, T1). */
|
||||
export class ConfigError extends RelayWebError {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ConfigError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Discriminated kinds for control-plane API failures (T2). */
|
||||
export type ApiErrorKind =
|
||||
| 'unauthenticated' // 401 — session missing/expired; surface re-login
|
||||
| 'forbidden' // 403 — INV1/INV6 upstream denial (foreign host_id etc.)
|
||||
| 'http' // other non-2xx
|
||||
| 'parse' // response failed Zod validation (never return a torn object)
|
||||
| 'network' // fetch itself rejected
|
||||
|
||||
/** Control-plane HTTP client error. `kind` drives the UI response (re-login, error banner, …). */
|
||||
export class ApiError extends RelayWebError {
|
||||
readonly kind: ApiErrorKind
|
||||
readonly status: number | null
|
||||
|
||||
constructor(kind: ApiErrorKind, message: string, status: number | null = null) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.kind = kind
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
57
relay-web/src/host-pin-store.ts
Normal file
57
relay-web/src/host-pin-store.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* T8 (v0.10) — `HostPinStore`: a CACHE / AUDIT TRAIL of the API-sourced host fingerprint per
|
||||
* `host_id`, NOT an independent trust root.
|
||||
*
|
||||
* It never establishes trust from a relay-forwarded handshake — `record` only ever stores a value
|
||||
* the caller already verified against the authenticated `HostRecord.enrollFpr` (§4.2, fetched
|
||||
* out-of-band of the relay over the P5-authenticated HTTPS API). Drift between a freshly-sourced
|
||||
* API fingerprint and the cached one is surfaced for review, but the API value is authoritative.
|
||||
*
|
||||
* Only the PUBLIC fingerprint (`enroll_fpr`) is persisted — never a key, secret, or plaintext
|
||||
* (INV5/INV9): a fingerprint is safe at rest and is exactly what enables first-connect MITM defense.
|
||||
*/
|
||||
|
||||
const KEY_PREFIX = 'relay-web:hostpin:'
|
||||
|
||||
export interface HostPinStore {
|
||||
/** last cached API-sourced enroll_fpr for this host, or null if none cached yet */
|
||||
get(hostId: string): string | null
|
||||
/** cache an API-VERIFIED fingerprint (audit/drift detection) — caller must have verified it */
|
||||
record(hostId: string, apiSourcedFpr: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fallback for non-DOM/test contexts (keeps the store usable everywhere). The store only
|
||||
* ever calls `getItem`/`setItem`, so only those are implemented (cast to the Storage interface).
|
||||
*/
|
||||
function memoryStorage(): Storage {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v)
|
||||
},
|
||||
} as unknown as Storage
|
||||
}
|
||||
|
||||
function defaultStorage(): Storage {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') return localStorage
|
||||
} catch {
|
||||
// localStorage can throw in sandboxed contexts; fall back to memory.
|
||||
}
|
||||
return memoryStorage()
|
||||
}
|
||||
|
||||
export function createHostPinStore(storage: Storage = defaultStorage()): HostPinStore {
|
||||
const keyFor = (hostId: string): string => `${KEY_PREFIX}${hostId}`
|
||||
return {
|
||||
get(hostId: string): string | null {
|
||||
return storage.getItem(keyFor(hostId))
|
||||
},
|
||||
record(hostId: string, apiSourcedFpr: string): void {
|
||||
// Store only the API-verified value; this method never derives trust from a handshake.
|
||||
storage.setItem(keyFor(hostId), apiSourcedFpr)
|
||||
},
|
||||
}
|
||||
}
|
||||
49
relay-web/src/index.ts
Normal file
49
relay-web/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* relay-web — public barrel (P6). Browser bundle served from the tenant subdomain. Imports
|
||||
* relay-contracts read-only; never touches base `public/` or `src/`. See docs/PLAN_RELAY_FRONTEND.md.
|
||||
*/
|
||||
|
||||
// Errors + runtime config (T1)
|
||||
export * from './errors'
|
||||
export { readConfig, type RelayWebConfig } from './config'
|
||||
|
||||
// Control-plane API client + schemas (T2)
|
||||
export { createApiClient, type ApiClient, type ApiClientV08 } from './api-client'
|
||||
export * from './api-schemas'
|
||||
|
||||
// v0.8 password gate (T3)
|
||||
export { mountPasswordLogin, type PasswordLogin } from './login-password'
|
||||
|
||||
// Terminal view + transport seam (T4)
|
||||
export {
|
||||
createPassthroughTransport,
|
||||
type TerminalTransport,
|
||||
type PassthroughOpts,
|
||||
type WebSocketLike,
|
||||
type WebSocketCtor,
|
||||
} from './ws-transport'
|
||||
export { mountTerminalView, type TerminalLike, type TerminalViewDeps } from './terminal-view'
|
||||
export * from './protocol'
|
||||
|
||||
// v0.9 dashboard + onboarding (T5/T6)
|
||||
export { mountDashboard, type Dashboard } from './dashboard'
|
||||
export { mountAddMachine, type AddMachine } from './add-machine'
|
||||
|
||||
// v0.10 passkey + step-up (T7)
|
||||
export { createWebAuthnClient, type WebAuthnClient } from './webauthn'
|
||||
export { mountPasskeyLogin, type PasskeyLogin } from './login-passkey'
|
||||
|
||||
// v0.10 browser-side E2E (T8)
|
||||
export {
|
||||
createHostPinStore,
|
||||
type HostPinStore,
|
||||
} from './host-pin-store'
|
||||
export {
|
||||
createE2ETransport,
|
||||
type E2ETransportDeps,
|
||||
type PinMismatchSource,
|
||||
} from './e2e-socket'
|
||||
|
||||
// v0.10 client-side preview (T9)
|
||||
export { mountPreviewClient, type PreviewClient, type ReplaySource } from './preview-client'
|
||||
export { mountPreviewGrid } from './preview-grid'
|
||||
88
relay-web/src/login-passkey.ts
Normal file
88
relay-web/src/login-passkey.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* T7 (v0.10) — Passkey/WebAuthn login + step-up-BEFORE-a-session (EXPLORE §4e / §0-5).
|
||||
*
|
||||
* `login()`: fetch a challenge from P5 → `wa.authenticate` → post the assertion (P5 sets the
|
||||
* session). `stepUp(hostId)` runs a FRESH WebAuthn assertion immediately before a session is opened
|
||||
* — a stolen cookie alone cannot open a session (INV15 step-up). Passkey is primary and
|
||||
* phishing-resistant; there is NO SMS/OTP path (INDEX/EXPLORE §0-5: never SMS).
|
||||
*
|
||||
* `rpId` is server-authoritative: it arrives inside the challenge from P5 (resolved to the stable
|
||||
* ACCOUNT-LEVEL domain per §8 Open Question #5) and is passed straight through — the client never
|
||||
* hard-wires it to `location.hostname`, so one passkey spans all of an account's host subdomains.
|
||||
*
|
||||
* INTEGRATION POINT (BLOCKED item, §8 Q#5): the challenge/verify endpoints + the resolved `rpId`
|
||||
* value are owned by P5/P3. This module consumes them through the injected {@link PasskeyAuthApi}
|
||||
* seam rather than growing the frozen T2 `ApiClient` surface or guessing the resolution.
|
||||
*/
|
||||
import type { ApiClient } from './api-client'
|
||||
import { WebAuthnError, type AuthenticationResult, type WebAuthnClient } from './webauthn'
|
||||
|
||||
/** P5-backed challenge/verify seam (NOT part of the frozen v0.8/v0.9 ApiClient — see header note). */
|
||||
export interface PasskeyAuthApi {
|
||||
getLoginChallenge(): Promise<PublicKeyCredentialRequestOptions>
|
||||
verifyLogin(assertion: AuthenticationResult): Promise<'ok' | 'rejected'>
|
||||
getStepUpChallenge(hostId: string): Promise<PublicKeyCredentialRequestOptions>
|
||||
verifyStepUp(hostId: string, assertion: AuthenticationResult): Promise<'ok' | 'rejected'>
|
||||
}
|
||||
|
||||
export interface PasskeyLogin {
|
||||
login(): Promise<'ok' | 'rejected'>
|
||||
/** BEFORE opening a session, not just at login — a fresh ceremony every call. */
|
||||
stepUp(hostId: string): Promise<'ok' | 'rejected'>
|
||||
}
|
||||
|
||||
export function mountPasskeyLogin(
|
||||
root: HTMLElement,
|
||||
_api: ApiClient,
|
||||
wa: WebAuthnClient,
|
||||
authApi: PasskeyAuthApi,
|
||||
): PasskeyLogin {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'passkey-login'
|
||||
button.textContent = 'Sign in with a passkey'
|
||||
const error = document.createElement('p')
|
||||
error.className = 'passkey-error'
|
||||
error.setAttribute('role', 'alert')
|
||||
root.append(button, error)
|
||||
|
||||
/** Run one assertion ceremony; a user-cancel (NotAllowedError) becomes a clean 'rejected'. */
|
||||
async function assert(
|
||||
getChallenge: () => Promise<PublicKeyCredentialRequestOptions>,
|
||||
verify: (a: AuthenticationResult) => Promise<'ok' | 'rejected'>,
|
||||
): Promise<'ok' | 'rejected'> {
|
||||
let challenge: PublicKeyCredentialRequestOptions
|
||||
try {
|
||||
challenge = await getChallenge()
|
||||
} catch {
|
||||
error.textContent = 'Could not start the passkey ceremony.'
|
||||
return 'rejected'
|
||||
}
|
||||
let assertion: AuthenticationResult
|
||||
try {
|
||||
assertion = await wa.authenticate(challenge) // rpId comes from the server challenge, not location
|
||||
} catch (err) {
|
||||
if (err instanceof WebAuthnError && err.kind === 'cancelled') return 'rejected'
|
||||
error.textContent = 'Passkey authentication failed.'
|
||||
return 'rejected'
|
||||
}
|
||||
return verify(assertion)
|
||||
}
|
||||
|
||||
const login = (): Promise<'ok' | 'rejected'> =>
|
||||
assert(
|
||||
() => authApi.getLoginChallenge(),
|
||||
(a) => authApi.verifyLogin(a),
|
||||
)
|
||||
|
||||
const stepUp = (hostId: string): Promise<'ok' | 'rejected'> =>
|
||||
assert(
|
||||
() => authApi.getStepUpChallenge(hostId),
|
||||
(a) => authApi.verifyStepUp(hostId, a),
|
||||
)
|
||||
|
||||
button.addEventListener('click', () => void login())
|
||||
|
||||
// Surface EXACTLY the two capabilities — no phone/SMS/OTP field exists (structural, INV: never SMS).
|
||||
return { login, stepUp }
|
||||
}
|
||||
87
relay-web/src/login-password.ts
Normal file
87
relay-web/src/login-password.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* T3 — v0.8 shared-`clientToken` password gate → signed cookie.
|
||||
*
|
||||
* The auth the base app never had (EXPLORE §5). Posts `clientToken` once over TLS to the relay
|
||||
* edge (P5/relay), which sets an `HttpOnly; Secure; SameSite` signed cookie; on `'ok'` the app
|
||||
* routes to the terminal view. Replaced by Passkey (T7) in v0.10 behind the same route.
|
||||
*
|
||||
* Security: the token is NEVER stored in `localStorage` and NEVER logged; all error text is set via
|
||||
* `textContent` (never `innerHTML`); empty input disables submit (fail-fast at the boundary).
|
||||
*/
|
||||
import type { RelayWebConfig } from './config'
|
||||
|
||||
export interface PasswordLogin {
|
||||
submit(clientToken: string): Promise<'ok' | 'rejected'>
|
||||
}
|
||||
|
||||
export interface PasswordLoginDeps {
|
||||
readonly fetchImpl?: typeof fetch
|
||||
/** Invoked once on a successful login so the shell wrapper can route to the terminal view. */
|
||||
readonly onSuccess?: () => void
|
||||
}
|
||||
|
||||
const LOGIN_PATH = '/api/login'
|
||||
|
||||
export function mountPasswordLogin(
|
||||
root: HTMLElement,
|
||||
cfg: RelayWebConfig,
|
||||
deps: PasswordLoginDeps = {},
|
||||
): PasswordLogin {
|
||||
const fetchImpl = deps.fetchImpl ?? fetch
|
||||
|
||||
const form = document.createElement('form')
|
||||
form.className = 'login-form'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'password'
|
||||
input.autocomplete = 'current-password'
|
||||
input.placeholder = 'Access token'
|
||||
input.className = 'login-input'
|
||||
|
||||
const button = document.createElement('button')
|
||||
button.type = 'submit'
|
||||
button.textContent = 'Connect'
|
||||
button.disabled = true // empty input → disabled (fail-fast)
|
||||
|
||||
const error = document.createElement('p')
|
||||
error.className = 'login-error'
|
||||
error.setAttribute('role', 'alert')
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
button.disabled = input.value.length === 0
|
||||
error.textContent = '' // clear stale error on edit
|
||||
})
|
||||
|
||||
async function submit(clientToken: string): Promise<'ok' | 'rejected'> {
|
||||
if (clientToken.length === 0) return 'rejected'
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchImpl(`${cfg.apiBase}${LOGIN_PATH}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // relay sets the signed cookie on the response
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ clientToken }), // shared password, NOT an account_id (INV3)
|
||||
})
|
||||
} catch {
|
||||
error.textContent = 'Network error — please retry.'
|
||||
return 'rejected'
|
||||
}
|
||||
if (!res.ok) {
|
||||
error.textContent = 'Invalid access token.'
|
||||
return 'rejected'
|
||||
}
|
||||
error.textContent = ''
|
||||
if (deps.onSuccess) deps.onSuccess()
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault()
|
||||
void submit(input.value)
|
||||
})
|
||||
|
||||
form.append(input, button, error)
|
||||
root.append(form)
|
||||
|
||||
return { submit }
|
||||
}
|
||||
140
relay-web/src/preview-client.ts
Normal file
140
relay-web/src/preview-client.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* T9 (v0.10) — client-side preview rendering. Under E2E the relay CANNOT render a screen it cannot
|
||||
* read (server previews die); the authorized, key-holding browser decrypts a CIPHERTEXT replay and
|
||||
* renders a READ-ONLY xterm.
|
||||
*
|
||||
* Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with
|
||||
* `sealReplayFrame` under the RECOVERABLE content key `K_content` — NOT the ephemeral live
|
||||
* `DirectionalKeys` (which are re-derived per handshake and lost on reload). The browser re-derives
|
||||
* the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg })` (§4.4 FIX 3;
|
||||
* `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with
|
||||
* `openReplayCiphertext`. A wrong/ephemeral key throws (AEAD tag) → the card shows "unavailable",
|
||||
* never a torn/garbled screen (cross-host/session isolation, INV1).
|
||||
*
|
||||
* The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and
|
||||
* injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in
|
||||
* memory: NEVER persisted or logged (INV5/INV9). The preview has NO input wiring (read-only).
|
||||
*/
|
||||
import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts'
|
||||
|
||||
/** Ciphertext ring-buffer replay for one host/session. */
|
||||
export interface ReplaySource {
|
||||
readonly sessionId: string
|
||||
readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay)
|
||||
readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope
|
||||
}
|
||||
|
||||
/** Read-only terminal surface — NO `onData`/input path exists (structural read-only guarantee). */
|
||||
export interface ReadonlyTerminalLike {
|
||||
open(container: HTMLElement): void
|
||||
write(data: string): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Injected §4.4 replay crypto (from relay-e2e) + a read-only terminal factory. */
|
||||
export interface PreviewDeps {
|
||||
deriveContentKey(p: ReplayKeyParams): AeadKey
|
||||
openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array
|
||||
createTerminal?: (dims: { cols: number; rows: number }) => ReadonlyTerminalLike
|
||||
}
|
||||
|
||||
export interface PreviewClient {
|
||||
render(): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
async function defaultReadonlyTerminal(dims: {
|
||||
cols: number
|
||||
rows: number
|
||||
}): Promise<ReadonlyTerminalLike> {
|
||||
const { Terminal } = await import('@xterm/xterm')
|
||||
// disableStdin: the preview is read-only — it can never become a covert input channel.
|
||||
const term = new Terminal({ cols: dims.cols, rows: dims.rows, disableStdin: true })
|
||||
return {
|
||||
open: (el) => term.open(el),
|
||||
write: (data) => term.write(data),
|
||||
dispose: () => term.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mountPreviewClient(
|
||||
card: HTMLElement,
|
||||
replay: ReplaySource,
|
||||
hostContentSecret: Uint8Array,
|
||||
dims: { cols: number; rows: number },
|
||||
deps: PreviewDeps,
|
||||
): PreviewClient {
|
||||
let term: ReadonlyTerminalLike | null = null
|
||||
let disposed = false
|
||||
|
||||
function showUnavailable(): void {
|
||||
const msg = document.createElement('p')
|
||||
msg.className = 'preview-unavailable'
|
||||
msg.textContent = 'unavailable'
|
||||
card.replaceChildren(msg)
|
||||
}
|
||||
|
||||
async function render(): Promise<void> {
|
||||
// Derive K_content ONCE, fresh from the host-scoped secret (no in-memory ephemeral key) —
|
||||
// this is why replay survives a reload where the forward-secret live keys cannot.
|
||||
let kContent: AeadKey
|
||||
try {
|
||||
kContent = deps.deriveContentKey({
|
||||
hostContentSecret,
|
||||
sessionId: replay.sessionId,
|
||||
alg: replay.alg,
|
||||
})
|
||||
} catch {
|
||||
showUnavailable()
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt every stored ciphertext BEFORE mounting a terminal; a wrong/ephemeral key (or a
|
||||
// host-mismatched secret) makes openReplayCiphertext throw → "unavailable", never a torn screen.
|
||||
const chunks: string[] = []
|
||||
for (const frame of replay.frames) {
|
||||
try {
|
||||
chunks.push(decoder.decode(deps.openReplayCiphertext(kContent, frame)))
|
||||
} catch {
|
||||
showUnavailable()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (disposed) return
|
||||
const factory =
|
||||
deps.createTerminal ?? ((d) => makeSyncPending(defaultReadonlyTerminal(d)))
|
||||
term = factory(dims)
|
||||
term.open(card)
|
||||
for (const chunk of chunks) term.write(chunk)
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
dispose(): void {
|
||||
disposed = true
|
||||
if (term) term.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge the async default terminal loader into the synchronous factory shape. The real path
|
||||
* (production) awaits xterm before writing; tests always inject a synchronous mock so this branch
|
||||
* is not exercised there.
|
||||
*/
|
||||
function makeSyncPending(pending: Promise<ReadonlyTerminalLike>): ReadonlyTerminalLike {
|
||||
let resolved: ReadonlyTerminalLike | null = null
|
||||
const queue: string[] = []
|
||||
void pending.then((t) => {
|
||||
resolved = t
|
||||
for (const c of queue) t.write(c)
|
||||
})
|
||||
return {
|
||||
open: (el) => void pending.then((t) => t.open(el)),
|
||||
write: (data) => (resolved ? resolved.write(data) : void queue.push(data)),
|
||||
dispose: () => void pending.then((t) => t.dispose()),
|
||||
}
|
||||
}
|
||||
87
relay-web/src/preview-grid.ts
Normal file
87
relay-web/src/preview-grid.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* T9 (v0.10) — grid of `preview-client` cards (the manage-page moved CLIENT-SIDE under E2E).
|
||||
*
|
||||
* Renders one read-only, locally-decrypted preview per authorized host. A host the account can't
|
||||
* reach → `loadReplay` rejects → the card shows "unavailable", never another tenant's screen
|
||||
* (INV1). Disposes all preview clients on unmount so no xterms/sockets leak (EXPLORE §6 preview-cost
|
||||
* discipline); `hostContentSecret` is never written to `localStorage`/`console`.
|
||||
*/
|
||||
import type { ApiClient } from './api-client'
|
||||
import type { HostRecord } from './api-schemas'
|
||||
import {
|
||||
mountPreviewClient,
|
||||
type PreviewClient,
|
||||
type PreviewDeps,
|
||||
type ReplaySource,
|
||||
} from './preview-client'
|
||||
|
||||
const DEFAULT_DIMS = { cols: 80, rows: 24 } as const
|
||||
|
||||
export interface PreviewGridOpts {
|
||||
readonly dims?: { cols: number; rows: number }
|
||||
}
|
||||
|
||||
export function mountPreviewGrid(
|
||||
root: HTMLElement,
|
||||
api: ApiClient,
|
||||
loadReplay: (
|
||||
hostId: string,
|
||||
) => Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }>,
|
||||
deps: PreviewDeps,
|
||||
opts: PreviewGridOpts = {},
|
||||
): { dispose(): void } {
|
||||
const dims = opts.dims ?? DEFAULT_DIMS
|
||||
const clients: PreviewClient[] = []
|
||||
let disposed = false
|
||||
|
||||
function unavailableCard(host: HostRecord): HTMLElement {
|
||||
const card = document.createElement('div')
|
||||
card.className = 'preview-card'
|
||||
card.dataset['hostId'] = host.hostId
|
||||
const msg = document.createElement('p')
|
||||
msg.className = 'preview-unavailable'
|
||||
msg.textContent = 'unavailable'
|
||||
card.append(msg)
|
||||
return card
|
||||
}
|
||||
|
||||
async function build(): Promise<void> {
|
||||
let hosts: readonly HostRecord[]
|
||||
try {
|
||||
hosts = await api.listHosts()
|
||||
} catch {
|
||||
const err = document.createElement('p')
|
||||
err.className = 'preview-grid-error'
|
||||
err.textContent = 'Could not load previews.'
|
||||
root.append(err)
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
|
||||
for (const host of hosts) {
|
||||
const card = document.createElement('div')
|
||||
card.className = 'preview-card'
|
||||
card.dataset['hostId'] = host.hostId
|
||||
root.append(card)
|
||||
try {
|
||||
const { replay, hostContentSecret } = await loadReplay(host.hostId)
|
||||
if (disposed) return
|
||||
const client = mountPreviewClient(card, replay, hostContentSecret, dims, deps)
|
||||
clients.push(client)
|
||||
await client.render()
|
||||
} catch {
|
||||
// A host the account can't reach (403) or a decrypt failure → "unavailable" (INV1).
|
||||
card.replaceWith(unavailableCard(host))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void build()
|
||||
|
||||
return {
|
||||
dispose(): void {
|
||||
disposed = true
|
||||
for (const c of clients) c.dispose() // no leaked xterms/sockets
|
||||
},
|
||||
}
|
||||
}
|
||||
60
relay-web/src/protocol.ts
Normal file
60
relay-web/src/protocol.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Base-app WebSocket protocol shapes, as the browser bundle SPEAKS them end-to-end.
|
||||
*
|
||||
* The agent (P2) forwards each logical stream to the UNCHANGED web-terminal at `127.0.0.1:3000`,
|
||||
* which speaks this exact JSON protocol (TECH_DOC §4 / src/protocol.ts). relay-web is a legitimate
|
||||
* consumer of that contract, so it declares the minimal shapes locally — it does NOT import the
|
||||
* base `src/` (that stays byte-for-byte untouched; verification greps `src` clean).
|
||||
*
|
||||
* These frames are the PLAINTEXT that the E2E layer (T8) later seals; in v0.8 they ride the
|
||||
* passthrough transport unencrypted.
|
||||
*/
|
||||
|
||||
/** client → server (agent → base app). `attach` MUST be the first message. */
|
||||
export type ClientMessage =
|
||||
| { readonly type: 'attach'; readonly sessionId: string | null; readonly cwd?: string }
|
||||
| { readonly type: 'input'; readonly data: string }
|
||||
| { readonly type: 'resize'; readonly cols: number; readonly rows: number }
|
||||
|
||||
/** server → client. */
|
||||
export type ServerMessage =
|
||||
| { readonly type: 'attached'; readonly sessionId: string }
|
||||
| { readonly type: 'output'; readonly data: string }
|
||||
| { readonly type: 'exit'; readonly code: number; readonly reason?: string }
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
/** Encode a client message as UTF-8 JSON bytes for `TerminalTransport.send`. */
|
||||
export function encodeClientMessage(msg: ClientMessage): Uint8Array {
|
||||
return encoder.encode(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode UTF-8 JSON bytes into a ServerMessage. NEVER throws (mirrors base invariant #3): an
|
||||
* unparseable / unknown frame yields `null` so the caller drops it rather than crashing the view.
|
||||
*/
|
||||
export function decodeServerMessage(bytes: Uint8Array): ServerMessage | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(decoder.decode(bytes))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const type = obj['type']
|
||||
if (type === 'attached' && typeof obj['sessionId'] === 'string') {
|
||||
return { type: 'attached', sessionId: obj['sessionId'] }
|
||||
}
|
||||
if (type === 'output' && typeof obj['data'] === 'string') {
|
||||
return { type: 'output', data: obj['data'] }
|
||||
}
|
||||
if (type === 'exit' && typeof obj['code'] === 'number') {
|
||||
const reason = obj['reason']
|
||||
return typeof reason === 'string'
|
||||
? { type: 'exit', code: obj['code'], reason }
|
||||
: { type: 'exit', code: obj['code'] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
124
relay-web/src/terminal-view.ts
Normal file
124
relay-web/src/terminal-view.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* T4 — mount xterm + FitAddon and drive it through an abstract `TerminalTransport`.
|
||||
*
|
||||
* Reproduces the base byte semantics against the SEAM (not `public/`): Enter → `\r` (0x0D, handled
|
||||
* by xterm's own onData), `resize` as its OWN protocol frame (triggering `TIOCSWINSZ`/SIGWINCH so
|
||||
* full-screen TUIs redraw), and `fit()` only after the container has real dimensions (calling it
|
||||
* while `display:none` yields NaN — base Gotcha).
|
||||
*
|
||||
* The terminal is injected (DI seam) so tests run in jsdom without a real canvas-backed xterm; the
|
||||
* default factory wires `@xterm/xterm` + `@xterm/addon-fit`.
|
||||
*/
|
||||
import { encodeClientMessage, decodeServerMessage } from './protocol'
|
||||
import type { TerminalTransport } from './ws-transport'
|
||||
|
||||
/** Minimal xterm surface this view needs (keeps tests mock-able, avoids canvas in jsdom). */
|
||||
export interface TerminalLike {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
open(container: HTMLElement): void
|
||||
write(data: string): void
|
||||
onData(cb: (data: string) => void): void
|
||||
fit(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface TerminalViewDeps {
|
||||
/** Factory for the terminal; default lazily loads xterm + FitAddon. */
|
||||
readonly createTerminal?: () => TerminalLike
|
||||
}
|
||||
|
||||
/** True only when the element has real, non-zero layout dimensions (guards NaN fit — Gotcha). */
|
||||
function hasRealDimensions(el: HTMLElement): boolean {
|
||||
return el.clientWidth > 0 && el.clientHeight > 0
|
||||
}
|
||||
|
||||
async function defaultTerminal(): Promise<TerminalLike> {
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
])
|
||||
const term = new Terminal({ convertEol: false })
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
return {
|
||||
get cols() {
|
||||
return term.cols
|
||||
},
|
||||
get rows() {
|
||||
return term.rows
|
||||
},
|
||||
open: (container) => term.open(container),
|
||||
write: (data) => term.write(data),
|
||||
onData: (cb) => {
|
||||
term.onData(cb)
|
||||
},
|
||||
fit: () => fit.fit(),
|
||||
dispose: () => term.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the terminal view. Returns `{ dispose }`. When `deps.createTerminal` is omitted the real
|
||||
* xterm is loaded asynchronously; tests always inject a mock for deterministic, synchronous setup.
|
||||
*/
|
||||
export function mountTerminalView(
|
||||
root: HTMLElement,
|
||||
transport: TerminalTransport,
|
||||
deps: TerminalViewDeps = {},
|
||||
): { dispose(): void } {
|
||||
let term: TerminalLike | null = null
|
||||
let disposed = false
|
||||
|
||||
const send = (bytes: Uint8Array): void => {
|
||||
try {
|
||||
transport.send(bytes)
|
||||
} catch {
|
||||
// Socket not open / torn down — drop; onClose surfaces the reason to the UI.
|
||||
}
|
||||
}
|
||||
|
||||
const sendResize = (): void => {
|
||||
if (!term || !hasRealDimensions(root)) return
|
||||
term.fit()
|
||||
send(encodeClientMessage({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
|
||||
const wire = (t: TerminalLike): void => {
|
||||
if (disposed) {
|
||||
t.dispose()
|
||||
return
|
||||
}
|
||||
term = t
|
||||
t.open(root)
|
||||
|
||||
// keypress → xterm onData → transport.send (Enter → '\r' comes from xterm itself).
|
||||
t.onData((data) => send(encodeClientMessage({ type: 'input', data })))
|
||||
|
||||
// incoming DATA → decode base ServerMessage → xterm.write (output only; attached/exit handled).
|
||||
transport.onMessage((bytes) => {
|
||||
const msg = decodeServerMessage(bytes)
|
||||
if (msg && msg.type === 'output') t.write(msg.data)
|
||||
})
|
||||
|
||||
// attach MUST be the first client message (base invariant); sessionId=null → fresh session.
|
||||
send(encodeClientMessage({ type: 'attach', sessionId: null }))
|
||||
|
||||
if (hasRealDimensions(root)) sendResize()
|
||||
}
|
||||
|
||||
const created = deps.createTerminal
|
||||
if (created) {
|
||||
wire(created())
|
||||
} else {
|
||||
void defaultTerminal().then(wire)
|
||||
}
|
||||
|
||||
return {
|
||||
dispose(): void {
|
||||
disposed = true
|
||||
if (term) term.dispose()
|
||||
transport.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
124
relay-web/src/webauthn.ts
Normal file
124
relay-web/src/webauthn.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* T7 (v0.10) — thin `navigator.credentials` wrapper: base64url ↔ ArrayBuffer plumbing + error
|
||||
* mapping, kept small and pure-ish. No credential material is ever logged (INV9).
|
||||
*
|
||||
* The server (P5) is authoritative for `rpId` — it lives in the challenge options this module
|
||||
* passes straight through to the authenticator; the client NEVER derives `rpId` from
|
||||
* `location.hostname` (see §8 Open Question #5). This module only encodes/decodes and invokes the
|
||||
* ceremony; the account is established server-side from the resulting assertion (INV3).
|
||||
*/
|
||||
import { decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
|
||||
|
||||
/** WebAuthn ceremony error, kind-tagged so callers can map cancellation → 'rejected' cleanly. */
|
||||
export class WebAuthnError extends Error {
|
||||
readonly kind: 'cancelled' | 'unsupported' | 'malformed' | 'failed'
|
||||
constructor(kind: WebAuthnError['kind'], message: string) {
|
||||
super(message)
|
||||
this.name = 'WebAuthnError'
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegistrationResult {
|
||||
readonly id: string
|
||||
readonly rawId: string // base64url
|
||||
readonly type: string
|
||||
readonly response: {
|
||||
readonly clientDataJSON: string // base64url
|
||||
readonly attestationObject: string // base64url
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthenticationResult {
|
||||
readonly id: string
|
||||
readonly rawId: string // base64url
|
||||
readonly type: string
|
||||
readonly response: {
|
||||
readonly clientDataJSON: string // base64url
|
||||
readonly authenticatorData: string // base64url
|
||||
readonly signature: string // base64url
|
||||
readonly userHandle: string | null // base64url or null
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebAuthnClient {
|
||||
register(challenge: PublicKeyCredentialCreationOptions): Promise<RegistrationResult>
|
||||
authenticate(challenge: PublicKeyCredentialRequestOptions): Promise<AuthenticationResult>
|
||||
}
|
||||
|
||||
/** ArrayBuffer → base64url (lossless). */
|
||||
export function bufferToBase64Url(buf: ArrayBuffer): string {
|
||||
return encodeBase64UrlBytes(new Uint8Array(buf))
|
||||
}
|
||||
|
||||
/** base64url → ArrayBuffer (lossless inverse of {@link bufferToBase64Url}). */
|
||||
export function base64UrlToBuffer(value: string): ArrayBuffer {
|
||||
const bytes = decodeBase64UrlBytes(value)
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/** Map a thrown ceremony error to a typed WebAuthnError (user cancel → 'cancelled'). */
|
||||
function mapCeremonyError(err: unknown): WebAuthnError {
|
||||
if (err instanceof WebAuthnError) return err
|
||||
const name = err instanceof Error ? err.name : ''
|
||||
if (name === 'NotAllowedError' || name === 'AbortError') {
|
||||
return new WebAuthnError('cancelled', 'the passkey prompt was dismissed')
|
||||
}
|
||||
return new WebAuthnError('failed', err instanceof Error ? err.message : 'webauthn ceremony failed')
|
||||
}
|
||||
|
||||
export function createWebAuthnClient(creds?: CredentialsContainer): WebAuthnClient {
|
||||
const container = creds ?? (typeof navigator !== 'undefined' ? navigator.credentials : undefined)
|
||||
if (!container) {
|
||||
// Fail fast at construction so the UI can offer a fallback (never a silent no-op).
|
||||
throw new WebAuthnError('unsupported', 'WebAuthn is not available in this environment')
|
||||
}
|
||||
|
||||
return {
|
||||
async register(challenge: PublicKeyCredentialCreationOptions): Promise<RegistrationResult> {
|
||||
let cred: Credential | null
|
||||
try {
|
||||
cred = await container.create({ publicKey: challenge })
|
||||
} catch (err) {
|
||||
throw mapCeremonyError(err)
|
||||
}
|
||||
if (!cred) throw new WebAuthnError('failed', 'no credential returned')
|
||||
const pk = cred as PublicKeyCredential
|
||||
const resp = pk.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
id: pk.id,
|
||||
rawId: bufferToBase64Url(pk.rawId),
|
||||
type: pk.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(resp.clientDataJSON),
|
||||
attestationObject: bufferToBase64Url(resp.attestationObject),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async authenticate(
|
||||
challenge: PublicKeyCredentialRequestOptions,
|
||||
): Promise<AuthenticationResult> {
|
||||
let cred: Credential | null
|
||||
try {
|
||||
cred = await container.get({ publicKey: challenge })
|
||||
} catch (err) {
|
||||
throw mapCeremonyError(err)
|
||||
}
|
||||
if (!cred) throw new WebAuthnError('failed', 'no assertion returned')
|
||||
const pk = cred as PublicKeyCredential
|
||||
const resp = pk.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: pk.id,
|
||||
rawId: bufferToBase64Url(pk.rawId),
|
||||
type: pk.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(resp.clientDataJSON),
|
||||
authenticatorData: bufferToBase64Url(resp.authenticatorData),
|
||||
signature: bufferToBase64Url(resp.signature),
|
||||
userHandle: resp.userHandle ? bufferToBase64Url(resp.userHandle) : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
129
relay-web/src/ws-transport.ts
Normal file
129
relay-web/src/ws-transport.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E).
|
||||
*
|
||||
* The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the
|
||||
* passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never
|
||||
* changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3)
|
||||
* authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained.
|
||||
*
|
||||
* Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol`
|
||||
* subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query
|
||||
* string (which would leak the bearer credential into proxy/access logs, history, and `Referer`).
|
||||
* After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the
|
||||
* socket down otherwise (echo rule).
|
||||
*/
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||
import type { RelayWebConfig } from './config'
|
||||
|
||||
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
|
||||
export interface TerminalTransport {
|
||||
open(): Promise<void>
|
||||
send(bytes: Uint8Array): void
|
||||
onMessage(cb: (bytes: Uint8Array) => void): void
|
||||
onClose(cb: (reason: string) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */
|
||||
export interface WebSocketLike {
|
||||
binaryType: string
|
||||
protocol: string
|
||||
send(data: ArrayBufferView | ArrayBufferLike | string): void
|
||||
close(code?: number, reason?: string): void
|
||||
onopen: ((ev: unknown) => void) | null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null
|
||||
onerror: ((ev: unknown) => void) | null
|
||||
}
|
||||
|
||||
export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike
|
||||
|
||||
export interface PassthroughOpts {
|
||||
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
|
||||
readonly capabilityToken?: string
|
||||
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
|
||||
readonly wsCtor?: WebSocketCtor
|
||||
}
|
||||
|
||||
/** Map a WS close event to a stable reason string the UI can render (no silent swallow). */
|
||||
function closeReason(ev: { code?: number; reason?: string }): string {
|
||||
if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror
|
||||
if (ev.code === 4401) return 'unauthenticated'
|
||||
if (ev.reason && ev.reason.length > 0) return ev.reason
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
/** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */
|
||||
function toBytes(data: unknown): Uint8Array {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
if (typeof data === 'string') return new TextEncoder().encode(data)
|
||||
return new Uint8Array(0)
|
||||
}
|
||||
|
||||
export function createPassthroughTransport(
|
||||
cfg: RelayWebConfig,
|
||||
opts: PassthroughOpts = {},
|
||||
): TerminalTransport {
|
||||
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
|
||||
const token = opts.capabilityToken
|
||||
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
|
||||
|
||||
let ws: WebSocketLike | null = null
|
||||
let messageCb: ((bytes: Uint8Array) => void) | null = null
|
||||
let closeCb: ((reason: string) => void) | null = null
|
||||
|
||||
// App subprotocol FIRST; token entry (v0.9+) second — the frozen §4.3 order.
|
||||
const protocols: readonly string[] =
|
||||
token === undefined ? [APP_SUBPROTOCOL] : [APP_SUBPROTOCOL, encodeTokenSubprotocol(token)]
|
||||
|
||||
function open(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new Ctor(url, protocols)
|
||||
socket.binaryType = 'arraybuffer'
|
||||
ws = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
// Echo rule (v0.9+): with a token attached, accept ONLY the app subprotocol back — a relay
|
||||
// echoing the token entry, an empty value, or a foreign one is rejected (bearer-leak guard).
|
||||
if (token !== undefined && socket.protocol !== APP_SUBPROTOCOL) {
|
||||
socket.close(4400, 'bad-subprotocol')
|
||||
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
socket.onmessage = (ev) => {
|
||||
if (messageCb) messageCb(toBytes(ev.data))
|
||||
}
|
||||
socket.onclose = (ev) => {
|
||||
if (closeCb) closeCb(closeReason(ev))
|
||||
}
|
||||
socket.onerror = () => {
|
||||
// Surface as a close so callers never hang; open() rejection is handled by onclose too.
|
||||
reject(new Error('websocket error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
send(bytes: Uint8Array): void {
|
||||
if (!ws) throw new Error('transport not open')
|
||||
ws.send(bytes)
|
||||
},
|
||||
onMessage(cb: (bytes: Uint8Array) => void): void {
|
||||
messageCb = cb
|
||||
},
|
||||
onClose(cb: (reason: string) => void): void {
|
||||
closeCb = cb
|
||||
},
|
||||
close(): void {
|
||||
if (ws) ws.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
106
relay-web/test/add-machine.test.ts
Normal file
106
relay-web/test/add-machine.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mountAddMachine } from '../src/add-machine'
|
||||
import { ApiError } from '../src/errors'
|
||||
import type { ApiClient } from '../src/api-client'
|
||||
import type { HostRecord } from '../src/api-schemas'
|
||||
|
||||
function host(subdomain: string, status: HostRecord['status'] = 'online'): HostRecord {
|
||||
return {
|
||||
hostId: `id-${subdomain}`,
|
||||
accountId: 'acc',
|
||||
subdomain,
|
||||
agentPubkey: new Uint8Array([1]),
|
||||
enrollFpr: `fpr-${subdomain}`,
|
||||
status,
|
||||
lastSeen: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
revokedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeApi(over: Partial<ApiClient>): ApiClient {
|
||||
return {
|
||||
listHosts: vi.fn(async () => []),
|
||||
getHost: vi.fn(),
|
||||
requestPairingCode: vi.fn(),
|
||||
hostStatus: vi.fn(),
|
||||
issueCapabilityToken: vi.fn(),
|
||||
...over,
|
||||
} as unknown as ApiClient
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0))
|
||||
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
describe('mountAddMachine (T6)', () => {
|
||||
let root: HTMLElement
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
})
|
||||
|
||||
it('happy path: shows npx command, detects a newly online host, fires onPaired once', async () => {
|
||||
let call = 0
|
||||
const listHosts = vi.fn(async () => (call++ === 0 ? [] : [host('newbox')]))
|
||||
const requestPairingCode = vi.fn(async () => ({ code: 'ABCD-1234', expiresAt: futureIso() }))
|
||||
const onPaired = vi.fn()
|
||||
const api = fakeApi({ listHosts, requestPairingCode })
|
||||
const add = mountAddMachine(root, api, { pollMs: 5, onPaired })
|
||||
|
||||
await add.start()
|
||||
await flush()
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(root.querySelector('.pair-command')?.textContent).toBe(
|
||||
'npx web-terminal-agent pair ABCD-1234',
|
||||
)
|
||||
expect(onPaired).toHaveBeenCalledExactlyOnceWith('id-newbox')
|
||||
add.dispose()
|
||||
})
|
||||
|
||||
it('expired code: after expiresAt it shows "expired", clears the code, and stops polling', async () => {
|
||||
let t = 1_000_000
|
||||
const requestPairingCode = vi.fn(async () => ({ code: 'EXP-0001', expiresAt: new Date(t + 50).toISOString() }))
|
||||
const listHosts = vi.fn(async () => [])
|
||||
const api = fakeApi({ listHosts, requestPairingCode })
|
||||
const add = mountAddMachine(root, api, { pollMs: 5, now: () => t })
|
||||
|
||||
await add.start()
|
||||
t += 100 // advance past expiry
|
||||
await tick(25) // let the poll interval fire and catch expiry
|
||||
|
||||
expect(root.querySelector('.pair-status')?.textContent).toContain('expired')
|
||||
expect(root.querySelector('.pair-command')?.textContent).toBe('')
|
||||
const callsAtExpiry = listHosts.mock.calls.length
|
||||
await tick(25)
|
||||
expect(listHosts.mock.calls.length).toBe(callsAtExpiry) // polling stopped
|
||||
add.dispose()
|
||||
})
|
||||
|
||||
it('a requestPairingCode failure surfaces an error and does not start an infinite poll', async () => {
|
||||
const requestPairingCode = vi.fn(async () => {
|
||||
throw new ApiError('http', 'nope', 500)
|
||||
})
|
||||
const listHosts = vi.fn(async () => [])
|
||||
const add = mountAddMachine(root, fakeApi({ requestPairingCode, listHosts }), { pollMs: 5 })
|
||||
await add.start()
|
||||
await flush()
|
||||
expect(root.querySelector('.pair-error')?.textContent).toContain('Could not issue a code')
|
||||
add.dispose()
|
||||
})
|
||||
|
||||
it('renders the code via textContent only (no HTML injection)', async () => {
|
||||
const requestPairingCode = vi.fn(async () => ({ code: '<img src=x>', expiresAt: futureIso() }))
|
||||
const add = mountAddMachine(root, fakeApi({ requestPairingCode }), { pollMs: 100000 })
|
||||
await add.start()
|
||||
const cmd = root.querySelector('.pair-command') as HTMLElement
|
||||
expect(cmd.querySelector('img')).toBeNull()
|
||||
expect(cmd.textContent).toContain('<img src=x>')
|
||||
add.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
function futureIso(): string {
|
||||
return new Date(Date.now() + 120_000).toISOString()
|
||||
}
|
||||
111
relay-web/test/api-client.test.ts
Normal file
111
relay-web/test/api-client.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { readConfig } from '../src/config'
|
||||
import { createApiClient, type ApiClient, type ApiClientV08 } from '../src/api-client'
|
||||
import { ApiError } from '../src/errors'
|
||||
|
||||
const cfg = readConfig({
|
||||
protocol: 'https:',
|
||||
host: 'alice.term.example.com',
|
||||
hostname: 'alice.term.example.com',
|
||||
})
|
||||
|
||||
const validHost = {
|
||||
hostId: '11111111-1111-4111-8111-111111111111',
|
||||
accountId: '22222222-2222-4222-8222-222222222222',
|
||||
subdomain: 'alice',
|
||||
agentPubkey: encodeBase64UrlBytes(new Uint8Array([1, 2, 3, 4])),
|
||||
enrollFpr: 'fpr-abc',
|
||||
status: 'online',
|
||||
lastSeen: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
revokedAt: null,
|
||||
}
|
||||
|
||||
/** Build a mock fetch returning `body` as JSON with the given status. Records every call. */
|
||||
function mockFetch(body: unknown, status = 200): { fetch: typeof fetch; calls: Request[] } {
|
||||
const calls: Request[] = []
|
||||
const fn = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push(new Request(`https://alice.term.example.com${url}`, init))
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response
|
||||
})
|
||||
return { fetch: fn as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
describe('createApiClient — Zod validation + INV3 (T2)', () => {
|
||||
it('listHosts sends NO account_id/tenant_id in url, query, or body (INV3)', async () => {
|
||||
const { fetch, calls } = mockFetch([validHost])
|
||||
const api = createApiClient(cfg, fetch)
|
||||
const hosts = await api.listHosts()
|
||||
expect(hosts).toHaveLength(1)
|
||||
expect(hosts[0]?.enrollFpr).toBe('fpr-abc')
|
||||
expect(hosts[0]?.agentPubkey).toBeInstanceOf(Uint8Array)
|
||||
|
||||
const req = calls[0]!
|
||||
expect(req.url).not.toMatch(/account_id|tenant_id/i)
|
||||
const body = await req.text()
|
||||
expect(body).not.toMatch(/account_id|tenant_id/i)
|
||||
})
|
||||
|
||||
it('requestPairingCode posts an empty body — account derived server-side (INV3)', async () => {
|
||||
const { fetch, calls } = mockFetch({ code: 'ABCD-1234', expiresAt: '2026-01-01T00:02:00Z' })
|
||||
const api = createApiClient(cfg, fetch)
|
||||
const out = await api.requestPairingCode()
|
||||
expect(out.code).toBe('ABCD-1234')
|
||||
const body = await calls[0]!.text()
|
||||
expect(body).not.toMatch(/account_id|tenant_id/i)
|
||||
expect(JSON.parse(body)).toEqual({})
|
||||
})
|
||||
|
||||
it('getHost returns a Zod-validated HostRecord with a non-empty enrollFpr (T8 trust root)', async () => {
|
||||
const { fetch } = mockFetch(validHost)
|
||||
const api = createApiClient(cfg, fetch)
|
||||
const host = await api.getHost('11111111-1111-4111-8111-111111111111')
|
||||
expect(host.enrollFpr).toBe('fpr-abc')
|
||||
})
|
||||
|
||||
it('throws ApiError(parse) on a malformed host (missing subdomain) — never a torn object', async () => {
|
||||
const bad = { ...validHost, subdomain: undefined }
|
||||
const { fetch } = mockFetch(bad)
|
||||
const api = createApiClient(cfg, fetch)
|
||||
await expect(api.getHost('h1')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(api.getHost('h1')).rejects.toMatchObject({ kind: 'parse' })
|
||||
})
|
||||
|
||||
it('throws ApiError(parse) when enrollFpr is empty (no relay-forwarded fallback for T8)', async () => {
|
||||
const { fetch } = mockFetch({ ...validHost, enrollFpr: '' })
|
||||
const api = createApiClient(cfg, fetch)
|
||||
await expect(api.getHost('h1')).rejects.toMatchObject({ kind: 'parse' })
|
||||
})
|
||||
|
||||
it('maps 401 → ApiError(unauthenticated) (surfaces re-login, no silent swallow)', async () => {
|
||||
const { fetch } = mockFetch({}, 401)
|
||||
const api = createApiClient(cfg, fetch)
|
||||
await expect(api.listHosts()).rejects.toMatchObject({ kind: 'unauthenticated' })
|
||||
})
|
||||
|
||||
it('maps 403 → ApiError(forbidden) (INV1 enforced upstream, respected here)', async () => {
|
||||
const { fetch } = mockFetch({}, 403)
|
||||
const api = createApiClient(cfg, fetch)
|
||||
await expect(api.getHost('foreign')).rejects.toMatchObject({ kind: 'forbidden' })
|
||||
})
|
||||
|
||||
it('v0.9+ issueCapabilityToken returns the opaque raw token', async () => {
|
||||
const { fetch } = mockFetch({ token: 'v2.public.rawtoken' })
|
||||
const api = createApiClient(cfg, fetch)
|
||||
const token = await api.issueCapabilityToken('h1', ['attach'])
|
||||
expect(token).toBe('v2.public.rawtoken')
|
||||
})
|
||||
|
||||
it('v0.8 phasing: issueCapabilityToken is ABSENT from ApiClientV08 (structural guard)', () => {
|
||||
const { fetch } = mockFetch([validHost])
|
||||
const client: ApiClient = createApiClient(cfg, fetch)
|
||||
const v08: ApiClientV08 = client
|
||||
// @ts-expect-error — issueCapabilityToken is v0.9+, not on the v0.8 surface a v0.8 build uses.
|
||||
void v08.issueCapabilityToken
|
||||
})
|
||||
})
|
||||
39
relay-web/test/config.test.ts
Normal file
39
relay-web/test/config.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readConfig } from '../src/config'
|
||||
import { ConfigError } from '../src/errors'
|
||||
|
||||
/** Build a Location-like object from a URL string. */
|
||||
function loc(url: string): Pick<Location, 'protocol' | 'host' | 'hostname'> {
|
||||
const u = new URL(url)
|
||||
return { protocol: u.protocol, host: u.host, hostname: u.hostname }
|
||||
}
|
||||
|
||||
describe('readConfig — subdomain + scheme-following same-origin URL (T1, M6)', () => {
|
||||
it('derives subdomain and wss: URL on https', () => {
|
||||
const cfg = readConfig(loc('https://alice.term.example.com'))
|
||||
expect(cfg.subdomain).toBe('alice')
|
||||
expect(cfg.wsUrl('/term')).toBe('wss://alice.term.example.com/term')
|
||||
expect(cfg.apiBase).toBe('')
|
||||
})
|
||||
|
||||
it('follows the page scheme to ws: on http (no mixed-content on TLS)', () => {
|
||||
const cfg = readConfig(loc('http://alice.term.localhost:3000'))
|
||||
expect(cfg.subdomain).toBe('alice')
|
||||
expect(cfg.wsUrl('/term')).toBe('ws://alice.term.localhost:3000/term')
|
||||
})
|
||||
|
||||
it('throws ConfigError on a bare host with no subdomain label (fail-fast, never defaults)', () => {
|
||||
expect(() => readConfig(loc('https://example.com'))).toThrow(ConfigError)
|
||||
expect(() => readConfig(loc('https://localhost'))).toThrow(ConfigError)
|
||||
expect(() => readConfig(loc('https://term.example.com'))).toThrow(ConfigError)
|
||||
})
|
||||
|
||||
it('keeps wsUrl same-origin — a path cannot inject a foreign host', () => {
|
||||
const cfg = readConfig(loc('https://alice.term.example.com'))
|
||||
const injected = cfg.wsUrl('//evil.com')
|
||||
expect(injected.startsWith('wss://alice.term.example.com/')).toBe(true)
|
||||
expect(injected).not.toContain('evil.com/')
|
||||
// even a scheme-looking path stays anchored to our host
|
||||
expect(new URL(cfg.wsUrl('/x')).host).toBe('alice.term.example.com')
|
||||
})
|
||||
})
|
||||
86
relay-web/test/dashboard.test.ts
Normal file
86
relay-web/test/dashboard.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mountDashboard } from '../src/dashboard'
|
||||
import { ApiError } from '../src/errors'
|
||||
import type { ApiClient } from '../src/api-client'
|
||||
import type { HostRecord, HostStatus } from '../src/api-schemas'
|
||||
|
||||
function host(subdomain: string, status: HostStatus): HostRecord {
|
||||
return {
|
||||
hostId: `id-${subdomain}`,
|
||||
accountId: 'acc',
|
||||
subdomain,
|
||||
agentPubkey: new Uint8Array([1]),
|
||||
enrollFpr: `fpr-${subdomain}`,
|
||||
status,
|
||||
lastSeen: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
revokedAt: status === 'revoked' ? '2026-01-02T00:00:00Z' : null,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeApi(listHosts: () => Promise<readonly HostRecord[]>): ApiClient {
|
||||
return {
|
||||
listHosts,
|
||||
getHost: vi.fn(),
|
||||
requestPairingCode: vi.fn(),
|
||||
hostStatus: vi.fn(),
|
||||
issueCapabilityToken: vi.fn(),
|
||||
} as unknown as ApiClient
|
||||
}
|
||||
|
||||
describe('mountDashboard (T5)', () => {
|
||||
let root: HTMLElement
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
})
|
||||
|
||||
it('renders one card per host with the correct status class', async () => {
|
||||
const api = fakeApi(async () => [host('alice', 'online'), host('bob', 'offline'), host('cy', 'draining')])
|
||||
const dash = mountDashboard(root, api, { pollMs: 100000 })
|
||||
await dash.refresh()
|
||||
const cards = root.querySelectorAll('.host-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
expect(root.querySelector('.status-online')).not.toBeNull()
|
||||
expect(root.querySelector('.status-draining')).not.toBeNull()
|
||||
dash.dispose()
|
||||
})
|
||||
|
||||
it('revoked host is non-interactive: no open action, shows blocked state (INV12 UI)', async () => {
|
||||
const api = fakeApi(async () => [host('gone', 'revoked')])
|
||||
const dash = mountDashboard(root, api, { pollMs: 100000 })
|
||||
await dash.refresh()
|
||||
const card = root.querySelector('[data-status="revoked"]') as HTMLElement
|
||||
expect(card.querySelector('.host-open')).toBeNull()
|
||||
expect(card.querySelector('.host-blocked')?.textContent).toBe('revoked')
|
||||
dash.dispose()
|
||||
})
|
||||
|
||||
it('open action routes with the host_id (no foreign-host input path exists — INV1/INV3)', async () => {
|
||||
const onOpen = vi.fn()
|
||||
const api = fakeApi(async () => [host('alice', 'online')])
|
||||
const dash = mountDashboard(root, api, { pollMs: 100000, onOpen })
|
||||
await dash.refresh()
|
||||
;(root.querySelector('.host-open') as HTMLButtonElement).click()
|
||||
expect(onOpen).toHaveBeenCalledWith('id-alice')
|
||||
dash.dispose()
|
||||
})
|
||||
|
||||
it('a listHosts rejection shows an error banner and keeps polling', async () => {
|
||||
const api = fakeApi(async () => {
|
||||
throw new ApiError('http', 'boom', 500)
|
||||
})
|
||||
const dash = mountDashboard(root, api, { pollMs: 100000 })
|
||||
await dash.refresh()
|
||||
expect(root.querySelector('.dashboard-error')?.textContent).toContain('Could not load hosts')
|
||||
dash.dispose()
|
||||
})
|
||||
|
||||
it('never issues a request carrying an account_id (INV3) — listHosts is argument-free', async () => {
|
||||
const listHosts = vi.fn(async () => [] as HostRecord[])
|
||||
const dash = mountDashboard(root, fakeApi(listHosts), { pollMs: 100000 })
|
||||
await dash.refresh()
|
||||
expect(listHosts).toHaveBeenCalledWith() // no args ⇒ no client-supplied tenant identity
|
||||
dash.dispose()
|
||||
})
|
||||
})
|
||||
72
relay-web/test/default-terminal.test.ts
Normal file
72
relay-web/test/default-terminal.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deriveContentKey, encodeEnvelope, openReplayCiphertext, randomBytes, sealReplayFrame } from 'relay-e2e'
|
||||
|
||||
// Mock the xterm modules so the DEFAULT (non-injected) terminal loaders run without a real canvas.
|
||||
const writes: string[] = []
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: class {
|
||||
cols = 80
|
||||
rows = 24
|
||||
constructor(_opts?: unknown) {}
|
||||
loadAddon(_a: unknown): void {}
|
||||
open(_el: unknown): void {}
|
||||
write(data: string): void {
|
||||
writes.push(data)
|
||||
}
|
||||
onData(_cb: (d: string) => void): void {}
|
||||
dispose(): void {}
|
||||
},
|
||||
}))
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: class {
|
||||
fit(): void {}
|
||||
},
|
||||
}))
|
||||
|
||||
import { mountTerminalView } from '../src/terminal-view'
|
||||
import { mountPreviewClient, type ReplaySource } from '../src/preview-client'
|
||||
import type { TerminalTransport } from '../src/ws-transport'
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
describe('default (real-xterm) loaders behind the DI seam', () => {
|
||||
it('mountTerminalView loads the default xterm + FitAddon and sends attach', async () => {
|
||||
const root = document.createElement('div')
|
||||
Object.defineProperty(root, 'clientWidth', { value: 100 })
|
||||
Object.defineProperty(root, 'clientHeight', { value: 100 })
|
||||
const sent: Uint8Array[] = []
|
||||
const transport: TerminalTransport = {
|
||||
open: async () => {},
|
||||
send: (b) => sent.push(b),
|
||||
onMessage: () => {},
|
||||
onClose: () => {},
|
||||
close: () => {},
|
||||
}
|
||||
const view = mountTerminalView(root, transport) // NO createTerminal → default loader path
|
||||
await tick()
|
||||
await tick()
|
||||
const first = JSON.parse(new TextDecoder().decode(sent[0]!))
|
||||
expect(first.type).toBe('attach')
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
it('mountPreviewClient renders via the default read-only xterm loader', async () => {
|
||||
const secret = randomBytes(32)
|
||||
const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' })
|
||||
const replay: ReplaySource = {
|
||||
sessionId: 's',
|
||||
alg: 'aes-256-gcm',
|
||||
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))],
|
||||
}
|
||||
const card = document.createElement('div')
|
||||
const client = mountPreviewClient(card, replay, secret, { cols: 80, rows: 24 }, {
|
||||
deriveContentKey,
|
||||
openReplayCiphertext,
|
||||
})
|
||||
await client.render()
|
||||
await tick()
|
||||
await tick()
|
||||
expect(writes).toContain('DEFAULT-XTERM')
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
340
relay-web/test/e2e-socket.test.ts
Normal file
340
relay-web/test/e2e-socket.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { importAeadKey, createE2ESession } from 'relay-e2e'
|
||||
import { randomBytes } from 'relay-e2e'
|
||||
import type { AeadKey, E2ESession, HandshakeResult } from 'relay-contracts'
|
||||
import { readConfig } from '../src/config'
|
||||
import { createHostPinStore } from '../src/host-pin-store'
|
||||
import {
|
||||
createE2ETransport,
|
||||
type E2EHandshakeIo,
|
||||
type E2ETransportDeps,
|
||||
type FprDecision,
|
||||
type RunHandshakeArgs,
|
||||
} from '../src/e2e-socket'
|
||||
import type { WebSocketLike } from '../src/ws-transport'
|
||||
import { APP_SUBPROTOCOL } from 'relay-contracts'
|
||||
|
||||
const cfg = readConfig({
|
||||
protocol: 'https:',
|
||||
host: 'alice.term.example.com',
|
||||
hostname: 'alice.term.example.com',
|
||||
})
|
||||
|
||||
/** A real relay-e2e loopback: client + host sessions sharing DirectionalKeys (genuine crypto). */
|
||||
function loopbackPair(): { client: E2ESession; host: E2ESession } {
|
||||
const c2h: AeadKey = importAeadKey(randomBytes(32), 'aes-256-gcm', 'c2h')
|
||||
const h2c: AeadKey = importAeadKey(randomBytes(32), 'aes-256-gcm', 'h2c')
|
||||
const result: HandshakeResult = { keys: { c2h, h2c }, aead: 'aes-256-gcm', transcript: new Uint8Array() }
|
||||
return { client: createE2ESession('client', result), host: createE2ESession('host', result) }
|
||||
}
|
||||
|
||||
class MockWs implements WebSocketLike {
|
||||
static last: MockWs | null = null
|
||||
binaryType = ''
|
||||
protocol = ''
|
||||
readonly sent: Array<ArrayBufferView | ArrayBufferLike | string> = []
|
||||
closed = false
|
||||
onopen: ((ev: unknown) => void) | null = null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly protocols?: string | readonly string[],
|
||||
) {
|
||||
MockWs.last = this
|
||||
}
|
||||
send(data: ArrayBufferView | ArrayBufferLike | string): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.onclose?.({ code: 1000 })
|
||||
}
|
||||
fireOpen(): void {
|
||||
this.protocol = APP_SUBPROTOCOL
|
||||
this.onopen?.({})
|
||||
}
|
||||
fireMessage(data: unknown): void {
|
||||
this.onmessage?.({ data })
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
deviceAuthProofProvider: { proofFor: async () => 'proof' },
|
||||
pinStore: { get: async () => null, pin: async () => {} },
|
||||
hostContentSecret: new Uint8Array(32),
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a transport whose injected `runHandshake` honours the fingerprint gate (calling
|
||||
* verifyOfferedFpr with `offeredFpr`) and, on 'trust', returns the real relay-e2e client session.
|
||||
*/
|
||||
function makeTransport(opts: {
|
||||
expectedFpr: string
|
||||
offeredFpr: string
|
||||
onPinMismatch?: (e: string, o: string, s: 'api' | 'cache') => Promise<FprDecision>
|
||||
pins?: ReturnType<typeof createHostPinStore>
|
||||
}) {
|
||||
const { client, host } = loopbackPair()
|
||||
const pins = opts.pins ?? createHostPinStore(localStorage)
|
||||
const runHandshake = vi.fn(
|
||||
async (_io: E2EHandshakeIo, args: RunHandshakeArgs): Promise<E2ESession> => {
|
||||
const decision = await args.verifyOfferedFpr(opts.offeredFpr)
|
||||
if (decision === 'abort') throw new Error('handshake aborted: fingerprint mismatch')
|
||||
return client
|
||||
},
|
||||
)
|
||||
const deps: E2ETransportDeps = {
|
||||
runHandshake,
|
||||
context: ctx,
|
||||
agentPubkey: new Uint8Array([1, 2, 3]),
|
||||
}
|
||||
const onPinMismatch = opts.onPinMismatch ?? (async () => 'abort' as FprDecision)
|
||||
const transport = createE2ETransport(
|
||||
cfg,
|
||||
'RAWTOK',
|
||||
'h1',
|
||||
opts.expectedFpr,
|
||||
pins,
|
||||
onPinMismatch,
|
||||
deps,
|
||||
)
|
||||
return { transport, host, pins, runHandshake }
|
||||
}
|
||||
|
||||
async function openTransport(t: ReturnType<typeof makeTransport>['transport']): Promise<void> {
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen()
|
||||
await opened
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
MockWs.last = null
|
||||
})
|
||||
|
||||
// Patch the ctor into deps via globalThis for these tests.
|
||||
function withMockWs<T>(fn: () => T): T {
|
||||
const original = (globalThis as { WebSocket?: unknown }).WebSocket
|
||||
;(globalThis as { WebSocket?: unknown }).WebSocket = MockWs as unknown as typeof WebSocket
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
;(globalThis as { WebSocket?: unknown }).WebSocket = original
|
||||
}
|
||||
}
|
||||
|
||||
describe('createE2ETransport (T8) — INV2 / INV13 / TOFU trust root', () => {
|
||||
it('first-connect MITM: relay fpr ≠ API expectedFpr → abort, no session, nothing recorded', async () => {
|
||||
await withMockWs(async () => {
|
||||
const onPinMismatch = vi.fn(async () => 'abort' as FprDecision)
|
||||
const { transport, pins } = makeTransport({
|
||||
expectedFpr: 'fpr-API',
|
||||
offeredFpr: 'fpr-RELAY-FORGED',
|
||||
onPinMismatch,
|
||||
})
|
||||
await expect(openTransport(transport)).rejects.toThrow()
|
||||
expect(onPinMismatch).toHaveBeenCalledWith('fpr-API', 'fpr-RELAY-FORGED', 'api')
|
||||
expect(pins.get('h1')).toBeNull() // never recorded a relay-forwarded value
|
||||
expect(MockWs.last!.closed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('happy path: fpr == expectedFpr → session established, API value cached (audit trail)', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport, pins } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-API' })
|
||||
await openTransport(transport)
|
||||
expect(pins.get('h1')).toBe('fpr-API') // the API-sourced value, not a handshake-derived one
|
||||
})
|
||||
})
|
||||
|
||||
it('INV2: a known plaintext marker never appears un-AEAD\'d on the wire', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
await openTransport(transport)
|
||||
const marker = new TextEncoder().encode('SECRET-MARKER-12345')
|
||||
transport.send(marker)
|
||||
const framed = MockWs.last!.sent.at(-1) as Uint8Array
|
||||
expect(new TextDecoder().decode(framed)).not.toContain('SECRET-MARKER-12345')
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips: a host-sealed frame decrypts to the original plaintext via onMessage', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
const received: Uint8Array[] = []
|
||||
transport.onMessage((b) => received.push(b))
|
||||
await openTransport(transport)
|
||||
MockWs.last!.fireMessage(host.seal(new TextEncoder().encode('hello')))
|
||||
expect(new TextDecoder().decode(received[0]!)).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
it('INV13 tamper: flipping a ciphertext byte → AEAD tag fails → socket torn down', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let closeReason = ''
|
||||
transport.onClose((r) => (closeReason = r))
|
||||
await openTransport(transport)
|
||||
const frame = Uint8Array.from(host.seal(new TextEncoder().encode('data')))
|
||||
const last = frame.length - 1
|
||||
frame[last] = (frame[last] ?? 0) ^ 0xff // flip a tag/ciphertext byte
|
||||
MockWs.last!.fireMessage(frame)
|
||||
expect(closeReason).toBe('e2e-verify-failed')
|
||||
expect(MockWs.last!.closed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('INV13 replay: re-injecting a captured frame → rejected → tear-down', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let closeReason = ''
|
||||
transport.onClose((r) => (closeReason = r))
|
||||
await openTransport(transport)
|
||||
const frame = host.seal(new TextEncoder().encode('data'))
|
||||
MockWs.last!.fireMessage(frame) // seq 0 accepted
|
||||
MockWs.last!.fireMessage(frame) // replay of seq 0 → strict-successor guard rejects
|
||||
expect(closeReason).toBe('e2e-verify-failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('INV13 reorder: seq=1 before seq=0 → rejected', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let closeReason = ''
|
||||
transport.onClose((r) => (closeReason = r))
|
||||
await openTransport(transport)
|
||||
host.seal(new TextEncoder().encode('zero')) // consume seq 0 on the host sender
|
||||
const frameOne = host.seal(new TextEncoder().encode('one')) // seq 1
|
||||
MockWs.last!.fireMessage(frameOne) // client expects seq 0 first
|
||||
expect(closeReason).toBe('e2e-verify-failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('INV13 reflection: a client→host frame re-injected as host→client → rejected (direction-bound)', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let closeReason = ''
|
||||
transport.onClose((r) => (closeReason = r))
|
||||
await openTransport(transport)
|
||||
// Capture a genuine outbound (client→host) frame and reflect it into the inbound path.
|
||||
transport.send(new TextEncoder().encode('outbound'))
|
||||
const reflected = MockWs.last!.sent.at(-1) as Uint8Array
|
||||
MockWs.last!.fireMessage(reflected)
|
||||
expect(closeReason).toBe('e2e-verify-failed') // c2h frame cannot verify under h2c
|
||||
})
|
||||
})
|
||||
|
||||
it('key non-exposure: no key bytes reach localStorage; only the public fpr is cached', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-API' })
|
||||
await openTransport(transport)
|
||||
transport.send(new TextEncoder().encode('x'))
|
||||
const dump = JSON.stringify(localStorage)
|
||||
expect(dump).toContain('fpr-API')
|
||||
expect(dump).not.toMatch(/__aeadKey|CryptoKey/)
|
||||
})
|
||||
})
|
||||
|
||||
it('a socket error during the E2E open rejects (no partial session)', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
const opened = transport.open()
|
||||
MockWs.last!.onerror?.({})
|
||||
await expect(opened).rejects.toThrow('websocket error')
|
||||
})
|
||||
})
|
||||
|
||||
it('a plain server close during DATA phase surfaces its reason string', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let reason = ''
|
||||
transport.onClose((r) => (reason = r))
|
||||
await openTransport(transport)
|
||||
MockWs.last!.onclose?.({ code: 1000, reason: 'agent shutdown' })
|
||||
expect(reason).toBe('agent shutdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('a bare close (no code/reason) surfaces the default "closed" reason', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let reason = ''
|
||||
transport.onClose((r) => (reason = r))
|
||||
await openTransport(transport)
|
||||
MockWs.last!.onclose?.({})
|
||||
expect(reason).toBe('closed')
|
||||
})
|
||||
})
|
||||
|
||||
it('operator override: onPinMismatch("api") → "trust" lets the handshake proceed (branch)', async () => {
|
||||
await withMockWs(async () => {
|
||||
const onPinMismatch = vi.fn(async () => 'trust' as FprDecision)
|
||||
const { transport, pins } = makeTransport({
|
||||
expectedFpr: 'fpr-API',
|
||||
offeredFpr: 'fpr-DIFFERENT',
|
||||
onPinMismatch,
|
||||
})
|
||||
await openTransport(transport)
|
||||
expect(onPinMismatch).toHaveBeenCalledWith('fpr-API', 'fpr-DIFFERENT', 'api')
|
||||
expect(pins.get('h1')).toBe('fpr-API') // still records the authoritative API value
|
||||
})
|
||||
})
|
||||
|
||||
it('send() before the session is established throws (never sends plaintext)', () => {
|
||||
withMockWs(() => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
expect(() => transport.send(new Uint8Array([1]))).toThrow('e2e transport not open')
|
||||
})
|
||||
})
|
||||
|
||||
it('a non-abort handshake failure tears the socket down and rejects open()', async () => {
|
||||
await withMockWs(async () => {
|
||||
const pins = createHostPinStore(localStorage)
|
||||
const deps: E2ETransportDeps = {
|
||||
runHandshake: vi.fn(async () => {
|
||||
throw new Error('agent unreachable')
|
||||
}),
|
||||
context: ctx,
|
||||
agentPubkey: new Uint8Array([1]),
|
||||
}
|
||||
const transport = createE2ETransport(cfg, 'RAWTOK', 'h1', 'f', pins, async () => 'trust', deps)
|
||||
let closeReason = ''
|
||||
transport.onClose((r) => (closeReason = r))
|
||||
const opened = transport.open()
|
||||
MockWs.last!.fireOpen()
|
||||
await expect(opened).rejects.toThrow('agent unreachable')
|
||||
expect(closeReason).toBe('e2e-handshake-failed')
|
||||
expect(pins.get('h1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('a 4403 server close during the DATA phase surfaces onClose("forbidden")', async () => {
|
||||
await withMockWs(async () => {
|
||||
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
|
||||
let closeReason = ''
|
||||
transport.onClose((r) => (closeReason = r))
|
||||
await openTransport(transport)
|
||||
MockWs.last!.onclose?.({ code: 4403 })
|
||||
expect(closeReason).toBe('forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
it('cache drift: a later expectedFpr differing from the cache surfaces onPinMismatch(...,"cache")', async () => {
|
||||
await withMockWs(async () => {
|
||||
const pins = createHostPinStore(localStorage)
|
||||
pins.record('h1', 'fpr-OLD')
|
||||
const onPinMismatch = vi.fn(async () => 'trust' as FprDecision)
|
||||
const { transport } = makeTransport({
|
||||
expectedFpr: 'fpr-NEW',
|
||||
offeredFpr: 'fpr-NEW',
|
||||
onPinMismatch,
|
||||
pins,
|
||||
})
|
||||
await openTransport(transport)
|
||||
expect(onPinMismatch).toHaveBeenCalledWith('fpr-NEW', 'fpr-OLD', 'cache')
|
||||
expect(pins.get('h1')).toBe('fpr-NEW') // API value is authoritative — cache updated
|
||||
})
|
||||
})
|
||||
})
|
||||
277
relay-web/test/extra-coverage.test.ts
Normal file
277
relay-web/test/extra-coverage.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readConfig } from '../src/config'
|
||||
import { createHostPinStore } from '../src/host-pin-store'
|
||||
import { createPassthroughTransport, type WebSocketLike } from '../src/ws-transport'
|
||||
import { mountTerminalView, type TerminalLike } from '../src/terminal-view'
|
||||
import { decodeServerMessage } from '../src/protocol'
|
||||
import { createWebAuthnClient } from '../src/webauthn'
|
||||
import { mountPreviewClient, type ReadonlyTerminalLike } from '../src/preview-client'
|
||||
import { createApiClient } from '../src/api-client'
|
||||
import { mountPasswordLogin } from '../src/login-password'
|
||||
import type { TerminalTransport } from '../src/ws-transport'
|
||||
|
||||
const cfg = readConfig({ protocol: 'http:', host: 'a.term.localhost', hostname: 'a.term.localhost' })
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('host-pin-store fallbacks', () => {
|
||||
it('uses an in-memory Storage when localStorage is unavailable', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
const pins = createHostPinStore()
|
||||
expect(pins.get('h')).toBeNull()
|
||||
pins.record('h', 'fpr-mem')
|
||||
expect(pins.get('h')).toBe('fpr-mem')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ws-transport defensive branches', () => {
|
||||
it('send() before open() throws (never silently drops bytes)', () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: FakeWs })
|
||||
expect(() => t.send(new Uint8Array([1]))).toThrow('transport not open')
|
||||
})
|
||||
|
||||
it('decodes a string WS payload to bytes', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: FakeWs })
|
||||
const got: Uint8Array[] = []
|
||||
t.onMessage((b) => got.push(b))
|
||||
const opened = t.open()
|
||||
FakeWs.last!.fire()
|
||||
await opened
|
||||
FakeWs.last!.msg('hi')
|
||||
expect(new TextDecoder().decode(got[0]!)).toBe('hi')
|
||||
t.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-view defensive branches', () => {
|
||||
it('drops a send when the transport is not open (caught), and disposes cleanly', () => {
|
||||
const root = document.createElement('div')
|
||||
Object.defineProperty(root, 'clientWidth', { value: 10 })
|
||||
Object.defineProperty(root, 'clientHeight', { value: 10 })
|
||||
const throwing: TerminalTransport = {
|
||||
open: async () => {},
|
||||
send: () => {
|
||||
throw new Error('not open')
|
||||
},
|
||||
onMessage: () => {},
|
||||
onClose: () => {},
|
||||
close: () => {},
|
||||
}
|
||||
const term: TerminalLike = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
open: () => {},
|
||||
write: () => {},
|
||||
onData: () => {},
|
||||
fit: () => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
const view = mountTerminalView(root, throwing, { createTerminal: () => term })
|
||||
view.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('protocol edge cases', () => {
|
||||
it('decodes exit with a reason and rejects unknown/array frames', () => {
|
||||
expect(decodeServerMessage(enc({ type: 'exit', code: 1, reason: 'bye' }))).toEqual({
|
||||
type: 'exit',
|
||||
code: 1,
|
||||
reason: 'bye',
|
||||
})
|
||||
expect(decodeServerMessage(enc({ type: 'nope' }))).toBeNull()
|
||||
expect(decodeServerMessage(enc([1, 2]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('webauthn register mapping', () => {
|
||||
it('maps an attestation credential to base64url fields', async () => {
|
||||
const attestation = {
|
||||
id: 'reg',
|
||||
type: 'public-key',
|
||||
rawId: new Uint8Array([1]).buffer,
|
||||
response: {
|
||||
clientDataJSON: new Uint8Array([2]).buffer,
|
||||
attestationObject: new Uint8Array([3]).buffer,
|
||||
},
|
||||
}
|
||||
const container = {
|
||||
create: vi.fn(async () => attestation),
|
||||
get: vi.fn(),
|
||||
} as unknown as CredentialsContainer
|
||||
const res = await createWebAuthnClient(container).register(
|
||||
{} as PublicKeyCredentialCreationOptions,
|
||||
)
|
||||
expect(res.response.attestationObject.length).toBeGreaterThan(0)
|
||||
expect(res.id).toBe('reg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('webauthn error branches', () => {
|
||||
it('register cancel → cancelled; register/authenticate null → failed; AbortError → cancelled', async () => {
|
||||
const cancel = {
|
||||
create: vi.fn(async () => {
|
||||
throw Object.assign(new Error('abort'), { name: 'AbortError' })
|
||||
}),
|
||||
get: vi.fn(async () => null),
|
||||
} as unknown as CredentialsContainer
|
||||
const wa = createWebAuthnClient(cancel)
|
||||
await expect(wa.register({} as PublicKeyCredentialCreationOptions)).rejects.toMatchObject({
|
||||
kind: 'cancelled',
|
||||
})
|
||||
await expect(
|
||||
wa.authenticate({} as PublicKeyCredentialRequestOptions),
|
||||
).rejects.toMatchObject({ kind: 'failed' }) // null assertion → failed
|
||||
|
||||
const nullReg = {
|
||||
create: vi.fn(async () => null),
|
||||
get: vi.fn(),
|
||||
} as unknown as CredentialsContainer
|
||||
await expect(
|
||||
createWebAuthnClient(nullReg).register({} as PublicKeyCredentialCreationOptions),
|
||||
).rejects.toMatchObject({ kind: 'failed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('webauthn non-Error rejection', () => {
|
||||
it('maps a thrown non-Error value to WebAuthnError("failed")', async () => {
|
||||
const container = {
|
||||
get: vi.fn(async () => {
|
||||
throw 'string failure' // eslint-disable-line no-throw-literal
|
||||
}),
|
||||
create: vi.fn(),
|
||||
} as unknown as CredentialsContainer
|
||||
await expect(
|
||||
createWebAuthnClient(container).authenticate({} as PublicKeyCredentialRequestOptions),
|
||||
).rejects.toMatchObject({ kind: 'failed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboard optional-branch coverage', () => {
|
||||
it('renders with default pollMs and an open action that is a no-op when onOpen is absent', async () => {
|
||||
const { mountDashboard } = await import('../src/dashboard')
|
||||
const root = document.createElement('div')
|
||||
const api = {
|
||||
listHosts: vi.fn(async () => [
|
||||
{
|
||||
hostId: 'id-a',
|
||||
accountId: 'acc',
|
||||
subdomain: 'a',
|
||||
agentPubkey: new Uint8Array([1]),
|
||||
enrollFpr: 'f',
|
||||
status: 'online',
|
||||
lastSeen: 't',
|
||||
createdAt: 't',
|
||||
revokedAt: null,
|
||||
},
|
||||
]),
|
||||
} as unknown as import('../src/api-client').ApiClient
|
||||
const dash = mountDashboard(root, api) // no opts → default pollMs, onOpen undefined
|
||||
await dash.refresh()
|
||||
expect(() => (root.querySelector('.host-open') as HTMLButtonElement).click()).not.toThrow()
|
||||
dash.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboard generic-error branch', () => {
|
||||
it('a non-ApiError rejection still shows the generic banner and keeps polling', async () => {
|
||||
const { mountDashboard } = await import('../src/dashboard')
|
||||
const root = document.createElement('div')
|
||||
const api = {
|
||||
listHosts: vi.fn(async () => {
|
||||
throw new Error('kaboom')
|
||||
}),
|
||||
} as unknown as import('../src/api-client').ApiClient
|
||||
const dash = mountDashboard(root, api, { pollMs: 100000 })
|
||||
await dash.refresh()
|
||||
expect(root.querySelector('.dashboard-error')?.textContent).toBe('Could not load hosts.')
|
||||
dash.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ws-transport raw Uint8Array message branch', () => {
|
||||
it('delivers a raw Uint8Array payload unchanged', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: FakeWs })
|
||||
const got: Uint8Array[] = []
|
||||
t.onMessage((b) => got.push(b))
|
||||
const opened = t.open()
|
||||
FakeWs.last!.fire()
|
||||
await opened
|
||||
FakeWs.last!.msg(new Uint8Array([7, 7]))
|
||||
expect(got[0]).toEqual(new Uint8Array([7, 7]))
|
||||
t.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview-client unavailable path', () => {
|
||||
it('shows "unavailable" when key derivation throws', async () => {
|
||||
const card = document.createElement('div')
|
||||
const client = mountPreviewClient(
|
||||
card,
|
||||
{ sessionId: 's', alg: 'aes-256-gcm', frames: [new Uint8Array([1])] },
|
||||
new Uint8Array(32),
|
||||
{ cols: 80, rows: 24 },
|
||||
{
|
||||
deriveContentKey: () => {
|
||||
throw new Error('bad key')
|
||||
},
|
||||
openReplayCiphertext: () => new Uint8Array(),
|
||||
createTerminal: () => ({ open: () => {}, write: () => {}, dispose: () => {} }) as ReadonlyTerminalLike,
|
||||
},
|
||||
)
|
||||
await client.render()
|
||||
expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('api-client + login-password error branches', () => {
|
||||
it('hostStatus returns the parsed status; a network error maps to ApiError(network)', async () => {
|
||||
const ok = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ status: 'online' }) }) as Response)
|
||||
const api = createApiClient(cfg, ok as unknown as typeof fetch)
|
||||
expect(await api.hostStatus('h1')).toBe('online')
|
||||
|
||||
const boom = vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
await expect(
|
||||
createApiClient(cfg, boom as unknown as typeof fetch).listHosts(),
|
||||
).rejects.toMatchObject({ kind: 'network' })
|
||||
})
|
||||
|
||||
it('password login surfaces a network error via textContent', async () => {
|
||||
const root = document.createElement('div')
|
||||
const boom = vi.fn(async () => {
|
||||
throw new Error('down')
|
||||
}) as unknown as typeof fetch
|
||||
const login = mountPasswordLogin(root, cfg, { fetchImpl: boom })
|
||||
await expect(login.submit('tok')).resolves.toBe('rejected')
|
||||
expect(root.querySelector('.login-error')?.textContent).toContain('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
// ── helpers ──
|
||||
function enc(v: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(v))
|
||||
}
|
||||
|
||||
class FakeWs implements WebSocketLike {
|
||||
static last: FakeWs | null = null
|
||||
binaryType = ''
|
||||
protocol = ''
|
||||
onopen: ((ev: unknown) => void) | null = null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
constructor() {
|
||||
FakeWs.last = this
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
fire(): void {
|
||||
this.protocol = 'term.relay.v1'
|
||||
this.onopen?.({})
|
||||
}
|
||||
msg(data: unknown): void {
|
||||
this.onmessage?.({ data })
|
||||
}
|
||||
}
|
||||
36
relay-web/test/host-pin-store.test.ts
Normal file
36
relay-web/test/host-pin-store.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createHostPinStore } from '../src/host-pin-store'
|
||||
|
||||
describe('createHostPinStore (T8) — cache/audit of the API-sourced fingerprint', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('record/get round-trips the API-verified fingerprint', () => {
|
||||
const pins = createHostPinStore(localStorage)
|
||||
expect(pins.get('h1')).toBeNull()
|
||||
pins.record('h1', 'fpr-api')
|
||||
expect(pins.get('h1')).toBe('fpr-api')
|
||||
})
|
||||
|
||||
it('detects drift: a caller comparing get() vs a fresh API value sees the rotation', () => {
|
||||
const pins = createHostPinStore(localStorage)
|
||||
pins.record('h1', 'fpr-old')
|
||||
const cached = pins.get('h1')
|
||||
const freshApi = 'fpr-new'
|
||||
expect(cached).not.toBe(freshApi) // caller surfaces this as a 'cache' mismatch for review
|
||||
})
|
||||
|
||||
it('keeps hosts isolated — one host_id never returns another host\'s pin', () => {
|
||||
const pins = createHostPinStore(localStorage)
|
||||
pins.record('h1', 'fpr-1')
|
||||
pins.record('h2', 'fpr-2')
|
||||
expect(pins.get('h1')).toBe('fpr-1')
|
||||
expect(pins.get('h2')).toBe('fpr-2')
|
||||
})
|
||||
|
||||
it('persists only the public fingerprint (no key/secret material at rest)', () => {
|
||||
const pins = createHostPinStore(localStorage)
|
||||
pins.record('h1', 'fpr-abc')
|
||||
expect(JSON.stringify(localStorage)).toContain('fpr-abc')
|
||||
expect(JSON.stringify(localStorage)).not.toMatch(/BEGIN|PRIVATE|secret/i)
|
||||
})
|
||||
})
|
||||
133
relay-web/test/login-passkey.test.ts
Normal file
133
relay-web/test/login-passkey.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mountPasskeyLogin, type PasskeyAuthApi } from '../src/login-passkey'
|
||||
import { WebAuthnError, type AuthenticationResult, type WebAuthnClient } from '../src/webauthn'
|
||||
import type { ApiClient } from '../src/api-client'
|
||||
|
||||
const fakeAssertion: AuthenticationResult = {
|
||||
id: 'x',
|
||||
rawId: 'cmF3',
|
||||
type: 'public-key',
|
||||
response: { clientDataJSON: 'Y2Q', authenticatorData: 'YWQ', signature: 'c2ln', userHandle: null },
|
||||
}
|
||||
|
||||
function reqOptions(rpId: string): PublicKeyCredentialRequestOptions {
|
||||
return { challenge: new Uint8Array([1]).buffer, rpId } as PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
const stubApi = {} as ApiClient
|
||||
|
||||
describe('mountPasskeyLogin (T7)', () => {
|
||||
let root: HTMLElement
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
})
|
||||
|
||||
it('successful authenticate → assertion verified → "ok"', async () => {
|
||||
const wa: WebAuthnClient = { register: vi.fn(), authenticate: vi.fn(async () => fakeAssertion) }
|
||||
const authApi: PasskeyAuthApi = {
|
||||
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
|
||||
verifyLogin: vi.fn(async () => 'ok' as const),
|
||||
getStepUpChallenge: vi.fn(),
|
||||
verifyStepUp: vi.fn(),
|
||||
}
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
await expect(login.login()).resolves.toBe('ok')
|
||||
expect(authApi.verifyLogin).toHaveBeenCalledWith(fakeAssertion)
|
||||
})
|
||||
|
||||
it('user cancels the passkey prompt → "rejected", no session, no throw leak', async () => {
|
||||
const wa: WebAuthnClient = {
|
||||
register: vi.fn(),
|
||||
authenticate: vi.fn(async () => {
|
||||
throw new WebAuthnError('cancelled', 'dismissed')
|
||||
}),
|
||||
}
|
||||
const authApi: PasskeyAuthApi = {
|
||||
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
|
||||
verifyLogin: vi.fn(async () => 'ok' as const),
|
||||
getStepUpChallenge: vi.fn(),
|
||||
verifyStepUp: vi.fn(),
|
||||
}
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
await expect(login.login()).resolves.toBe('rejected')
|
||||
expect(authApi.verifyLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stepUp runs a FRESH ceremony each call (a stolen cookie alone cannot open a session)', async () => {
|
||||
const authenticate = vi.fn(async () => fakeAssertion)
|
||||
const wa: WebAuthnClient = { register: vi.fn(), authenticate }
|
||||
const authApi: PasskeyAuthApi = {
|
||||
getLoginChallenge: vi.fn(),
|
||||
verifyLogin: vi.fn(),
|
||||
getStepUpChallenge: vi.fn(async () => reqOptions('app.example.com')),
|
||||
verifyStepUp: vi.fn(async () => 'ok' as const),
|
||||
}
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
await login.stepUp('h1')
|
||||
await login.stepUp('h1')
|
||||
expect(authenticate).toHaveBeenCalledTimes(2) // not cached — a new assertion every time
|
||||
})
|
||||
|
||||
it('rpId comes from the SERVER challenge, never location.hostname (§8 Q#5)', async () => {
|
||||
const authenticate = vi.fn((_c: PublicKeyCredentialRequestOptions) => Promise.resolve(fakeAssertion))
|
||||
const wa: WebAuthnClient = { register: vi.fn(), authenticate }
|
||||
const authApi: PasskeyAuthApi = {
|
||||
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
|
||||
verifyLogin: vi.fn(async () => 'ok' as const),
|
||||
getStepUpChallenge: vi.fn(),
|
||||
verifyStepUp: vi.fn(),
|
||||
}
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
await login.login()
|
||||
const passed = authenticate.mock.calls[0]![0] as PublicKeyCredentialRequestOptions
|
||||
expect(passed.rpId).toBe('app.example.com')
|
||||
expect(passed.rpId).not.toBe(window.location.hostname)
|
||||
})
|
||||
|
||||
it('a failed challenge fetch → "rejected" with an error message (no throw leak)', async () => {
|
||||
const wa: WebAuthnClient = { register: vi.fn(), authenticate: vi.fn(async () => fakeAssertion) }
|
||||
const authApi: PasskeyAuthApi = {
|
||||
getLoginChallenge: vi.fn(async () => {
|
||||
throw new Error('challenge 500')
|
||||
}),
|
||||
verifyLogin: vi.fn(),
|
||||
getStepUpChallenge: vi.fn(),
|
||||
verifyStepUp: vi.fn(),
|
||||
}
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
await expect(login.login()).resolves.toBe('rejected')
|
||||
expect(root.querySelector('.passkey-error')?.textContent).toContain('Could not start')
|
||||
})
|
||||
|
||||
it('a non-cancel authenticate failure → "rejected" and a failure message', async () => {
|
||||
const wa: WebAuthnClient = {
|
||||
register: vi.fn(),
|
||||
authenticate: vi.fn(async () => {
|
||||
throw new Error('authenticator exploded')
|
||||
}),
|
||||
}
|
||||
const authApi: PasskeyAuthApi = {
|
||||
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
|
||||
verifyLogin: vi.fn(),
|
||||
getStepUpChallenge: vi.fn(),
|
||||
verifyStepUp: vi.fn(),
|
||||
}
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
await expect(login.login()).resolves.toBe('rejected')
|
||||
expect(root.querySelector('.passkey-error')?.textContent).toContain('failed')
|
||||
})
|
||||
|
||||
it('exposes EXACTLY login + stepUp — no phone/SMS/OTP field (never SMS)', () => {
|
||||
const wa: WebAuthnClient = { register: vi.fn(), authenticate: vi.fn() }
|
||||
const authApi = {
|
||||
getLoginChallenge: vi.fn(),
|
||||
verifyLogin: vi.fn(),
|
||||
getStepUpChallenge: vi.fn(),
|
||||
verifyStepUp: vi.fn(),
|
||||
} as unknown as PasskeyAuthApi
|
||||
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
|
||||
expect(Object.keys(login).sort()).toEqual(['login', 'stepUp'])
|
||||
expect(JSON.stringify(Object.keys(login))).not.toMatch(/sms|phone|otp/i)
|
||||
})
|
||||
})
|
||||
59
relay-web/test/login-password.test.ts
Normal file
59
relay-web/test/login-password.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readConfig } from '../src/config'
|
||||
import { mountPasswordLogin } from '../src/login-password'
|
||||
|
||||
const cfg = readConfig({
|
||||
protocol: 'https:',
|
||||
host: 'alice.term.example.com',
|
||||
hostname: 'alice.term.example.com',
|
||||
})
|
||||
|
||||
function okFetch(): typeof fetch {
|
||||
return vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }) as Response) as unknown as typeof fetch
|
||||
}
|
||||
function rejectFetch(): typeof fetch {
|
||||
return vi.fn(async () => ({ ok: false, status: 401, json: async () => ({}) }) as Response) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('mountPasswordLogin — v0.8 gate (T3)', () => {
|
||||
let root: HTMLElement
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
})
|
||||
|
||||
it('correct token → "ok" and issues a credentialed cookie login request', async () => {
|
||||
const fetchImpl = okFetch()
|
||||
const onSuccess = vi.fn()
|
||||
const login = mountPasswordLogin(root, cfg, { fetchImpl, onSuccess })
|
||||
await expect(login.submit('s3cret')).resolves.toBe('ok')
|
||||
expect(onSuccess).toHaveBeenCalledOnce()
|
||||
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!
|
||||
const init = call[1] as RequestInit
|
||||
expect(init.credentials).toBe('include')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ clientToken: 's3cret' })
|
||||
})
|
||||
|
||||
it('wrong token → "rejected", no onSuccess, error shown via textContent (no innerHTML)', async () => {
|
||||
const login = mountPasswordLogin(root, cfg, { fetchImpl: rejectFetch() })
|
||||
await expect(login.submit('nope')).resolves.toBe('rejected')
|
||||
const err = root.querySelector('.login-error') as HTMLElement
|
||||
expect(err.textContent).toBe('Invalid access token.')
|
||||
expect(err.innerHTML).toBe('Invalid access token.') // textContent-set, no markup injected
|
||||
})
|
||||
|
||||
it('empty input keeps submit disabled (fail-fast) and submit("") is rejected without a fetch', async () => {
|
||||
const fetchImpl = okFetch()
|
||||
const login = mountPasswordLogin(root, cfg, { fetchImpl })
|
||||
const button = root.querySelector('button') as HTMLButtonElement
|
||||
expect(button.disabled).toBe(true)
|
||||
await expect(login.submit('')).resolves.toBe('rejected')
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not persist the token to localStorage', async () => {
|
||||
const login = mountPasswordLogin(root, cfg, { fetchImpl: okFetch() })
|
||||
await login.submit('s3cret')
|
||||
expect(JSON.stringify(localStorage)).not.toContain('s3cret')
|
||||
})
|
||||
})
|
||||
100
relay-web/test/preview-client.test.ts
Normal file
100
relay-web/test/preview-client.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from 'relay-e2e'
|
||||
import { randomBytes } from 'relay-e2e'
|
||||
import {
|
||||
mountPreviewClient,
|
||||
type PreviewDeps,
|
||||
type ReadonlyTerminalLike,
|
||||
type ReplaySource,
|
||||
} from '../src/preview-client'
|
||||
|
||||
const HOST_SECRET = randomBytes(32)
|
||||
const SESSION_ID = 'sess-1'
|
||||
const ALG = 'aes-256-gcm' as const
|
||||
|
||||
/** Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. */
|
||||
function sealedReplay(lines: readonly string[], secret = HOST_SECRET): ReplaySource {
|
||||
const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG })
|
||||
const frames = lines.map((line, i) =>
|
||||
// sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens.
|
||||
// openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec.
|
||||
encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))),
|
||||
)
|
||||
return { sessionId: SESSION_ID, alg: ALG, frames }
|
||||
}
|
||||
|
||||
// The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes.
|
||||
import { encodeEnvelope } from 'relay-e2e'
|
||||
function encodeReplayFrame(env: ReturnType<typeof sealReplayFrame>): Uint8Array {
|
||||
return encodeEnvelope(env)
|
||||
}
|
||||
|
||||
const realDeps: PreviewDeps = { deriveContentKey, openReplayCiphertext }
|
||||
|
||||
function mockTerminal(): ReadonlyTerminalLike & { written: string[] } {
|
||||
const written: string[] = []
|
||||
return { written, open: () => {}, write: (d) => written.push(d), dispose: () => {} }
|
||||
}
|
||||
|
||||
describe('mountPreviewClient (T9)', () => {
|
||||
it('recoverable-key decrypt: sealReplayFrame frames render into the read-only xterm (FIX 3)', async () => {
|
||||
const card = document.createElement('div')
|
||||
const term = mockTerminal()
|
||||
const client = mountPreviewClient(
|
||||
card,
|
||||
sealedReplay(['line-A', 'line-B']),
|
||||
HOST_SECRET,
|
||||
{ cols: 80, rows: 24 },
|
||||
{ ...realDeps, createTerminal: () => term },
|
||||
)
|
||||
await client.render()
|
||||
expect(term.written).toEqual(['line-A', 'line-B'])
|
||||
})
|
||||
|
||||
it('wrong/host-mismatched secret → openReplayCiphertext throws → "unavailable", never garbled', async () => {
|
||||
const card = document.createElement('div')
|
||||
const term = mockTerminal()
|
||||
const replay = sealedReplay(['secret-screen']) // sealed under HOST_SECRET
|
||||
const client = mountPreviewClient(
|
||||
card,
|
||||
replay,
|
||||
randomBytes(32), // a DIFFERENT host secret → derives a different K_content
|
||||
{ cols: 80, rows: 24 },
|
||||
{ ...realDeps, createTerminal: () => term },
|
||||
)
|
||||
await client.render()
|
||||
expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
|
||||
expect(term.written).toEqual([]) // never wrote a torn screen
|
||||
})
|
||||
|
||||
it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => {
|
||||
const card = document.createElement('div')
|
||||
const term = mockTerminal()
|
||||
mountPreviewClient(
|
||||
card,
|
||||
sealedReplay(['x']),
|
||||
HOST_SECRET,
|
||||
{ cols: 80, rows: 24 },
|
||||
{ ...realDeps, createTerminal: () => term },
|
||||
).render()
|
||||
expect('onData' in term).toBe(false)
|
||||
expect('send' in term).toBe(false)
|
||||
})
|
||||
|
||||
it('never writes hostContentSecret to localStorage/console', async () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const card = document.createElement('div')
|
||||
await mountPreviewClient(
|
||||
card,
|
||||
sealedReplay(['x']),
|
||||
HOST_SECRET,
|
||||
{ cols: 80, rows: 24 },
|
||||
{ ...realDeps, createTerminal: () => mockTerminal() },
|
||||
).render()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(JSON.stringify(localStorage)).not.toContain(
|
||||
Array.from(HOST_SECRET.slice(0, 4)).join(','),
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
112
relay-web/test/preview-grid.test.ts
Normal file
112
relay-web/test/preview-grid.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deriveContentKey,
|
||||
encodeEnvelope,
|
||||
openReplayCiphertext,
|
||||
randomBytes,
|
||||
sealReplayFrame,
|
||||
} from 'relay-e2e'
|
||||
import { mountPreviewGrid } from '../src/preview-grid'
|
||||
import type { PreviewDeps, ReplaySource } from '../src/preview-client'
|
||||
import type { ApiClient } from '../src/api-client'
|
||||
import type { HostRecord } from '../src/api-schemas'
|
||||
|
||||
const ALG = 'aes-256-gcm' as const
|
||||
|
||||
function host(sub: string): HostRecord {
|
||||
return {
|
||||
hostId: `id-${sub}`,
|
||||
accountId: 'acc',
|
||||
subdomain: sub,
|
||||
agentPubkey: new Uint8Array([1]),
|
||||
enrollFpr: `fpr-${sub}`,
|
||||
status: 'online',
|
||||
lastSeen: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
revokedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource {
|
||||
const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG })
|
||||
return { sessionId, alg: ALG, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))] }
|
||||
}
|
||||
|
||||
function fakeApi(hosts: readonly HostRecord[]): ApiClient {
|
||||
return {
|
||||
listHosts: vi.fn(async () => hosts),
|
||||
getHost: vi.fn(),
|
||||
requestPairingCode: vi.fn(),
|
||||
hostStatus: vi.fn(),
|
||||
issueCapabilityToken: vi.fn(),
|
||||
} as unknown as ApiClient
|
||||
}
|
||||
|
||||
const realDeps: PreviewDeps = {
|
||||
deriveContentKey,
|
||||
openReplayCiphertext,
|
||||
createTerminal: () => ({ open: () => {}, write: () => {}, dispose: () => {} }),
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
describe('mountPreviewGrid (T9)', () => {
|
||||
it('renders one preview card per authorized host', async () => {
|
||||
const root = document.createElement('div')
|
||||
const secret = randomBytes(32)
|
||||
const loadReplay = vi.fn(async (hostId: string) => ({
|
||||
replay: sealed(hostId, secret, 'screen'),
|
||||
hostContentSecret: secret,
|
||||
}))
|
||||
const grid = mountPreviewGrid(root, fakeApi([host('a'), host('b')]), loadReplay, realDeps)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(root.querySelectorAll('.preview-card')).toHaveLength(2)
|
||||
grid.dispose()
|
||||
})
|
||||
|
||||
it('a host the account can\'t reach (loadReplay rejects) → "unavailable", never a foreign screen (INV1)', async () => {
|
||||
const root = document.createElement('div')
|
||||
const loadReplay = vi.fn(async () => {
|
||||
throw new Error('forbidden')
|
||||
})
|
||||
const grid = mountPreviewGrid(root, fakeApi([host('a')]), loadReplay, realDeps)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(root.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
|
||||
grid.dispose()
|
||||
})
|
||||
|
||||
it('a listHosts failure renders a grid-level error, not a fabricated screen', async () => {
|
||||
const root = document.createElement('div')
|
||||
const api = {
|
||||
listHosts: vi.fn(async () => {
|
||||
throw new Error('unauth')
|
||||
}),
|
||||
} as unknown as ApiClient
|
||||
const grid = mountPreviewGrid(root, api, vi.fn(), realDeps)
|
||||
await flush()
|
||||
expect(root.querySelector('.preview-grid-error')?.textContent).toBe('Could not load previews.')
|
||||
grid.dispose()
|
||||
})
|
||||
|
||||
it('dispose() tears down every preview client (no leaked xterms)', async () => {
|
||||
const root = document.createElement('div')
|
||||
const secret = randomBytes(32)
|
||||
const disposed: string[] = []
|
||||
const deps: PreviewDeps = {
|
||||
deriveContentKey,
|
||||
openReplayCiphertext,
|
||||
createTerminal: () => ({ open: () => {}, write: () => {}, dispose: () => disposed.push('x') }),
|
||||
}
|
||||
const loadReplay = vi.fn(async (hostId: string) => ({
|
||||
replay: sealed(hostId, secret, 'screen'),
|
||||
hostContentSecret: secret,
|
||||
}))
|
||||
const grid = mountPreviewGrid(root, fakeApi([host('a')]), loadReplay, deps)
|
||||
await flush()
|
||||
await flush()
|
||||
grid.dispose()
|
||||
expect(disposed.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
129
relay-web/test/terminal-view.test.ts
Normal file
129
relay-web/test/terminal-view.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mountTerminalView, type TerminalLike } from '../src/terminal-view'
|
||||
import { decodeServerMessage, type ClientMessage } from '../src/protocol'
|
||||
import type { TerminalTransport } from '../src/ws-transport'
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
function decodeClient(bytes: Uint8Array): ClientMessage {
|
||||
return JSON.parse(decoder.decode(bytes)) as ClientMessage
|
||||
}
|
||||
|
||||
/** In-memory transport capturing sent frames and exposing a way to push incoming bytes. */
|
||||
function fakeTransport(): TerminalTransport & { sent: ClientMessage[]; push(m: object): void; closed: boolean } {
|
||||
let msgCb: ((b: Uint8Array) => void) | null = null
|
||||
const sent: ClientMessage[] = []
|
||||
return {
|
||||
sent,
|
||||
closed: false,
|
||||
open: async () => {},
|
||||
send: (bytes) => sent.push(decodeClient(bytes)),
|
||||
onMessage: (cb) => {
|
||||
msgCb = cb
|
||||
},
|
||||
onClose: () => {},
|
||||
close() {
|
||||
this.closed = true
|
||||
},
|
||||
push(m: object) {
|
||||
msgCb?.(new TextEncoder().encode(JSON.stringify(m)))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock xterm; `fitCalls` records fit() invocations, `dataCb` fires simulated keystrokes. */
|
||||
function fakeTerminal(): TerminalLike & {
|
||||
written: string[]
|
||||
fitCalls: number
|
||||
typeInto(data: string): void
|
||||
} {
|
||||
let onDataCb: ((d: string) => void) | null = null
|
||||
return {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
written: [],
|
||||
fitCalls: 0,
|
||||
open: () => {},
|
||||
write(data: string) {
|
||||
this.written.push(data)
|
||||
},
|
||||
onData: (cb) => {
|
||||
onDataCb = cb
|
||||
},
|
||||
fit() {
|
||||
this.fitCalls++
|
||||
},
|
||||
dispose: () => {},
|
||||
typeInto(data: string) {
|
||||
onDataCb?.(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function realDims(el: HTMLElement): void {
|
||||
Object.defineProperty(el, 'clientWidth', { value: 640, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 480, configurable: true })
|
||||
}
|
||||
|
||||
describe('mountTerminalView (T4)', () => {
|
||||
it('sends attach FIRST, then a resize frame once the container has real dimensions', () => {
|
||||
const root = document.createElement('div')
|
||||
realDims(root)
|
||||
const transport = fakeTransport()
|
||||
const term = fakeTerminal()
|
||||
mountTerminalView(root, transport, { createTerminal: () => term })
|
||||
|
||||
expect(transport.sent[0]).toEqual({ type: 'attach', sessionId: null })
|
||||
const resize = transport.sent.find((m) => m.type === 'resize')
|
||||
expect(resize).toEqual({ type: 'resize', cols: 80, rows: 24 })
|
||||
expect(term.fitCalls).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keypress → input frame; Enter carries "\\r" verbatim (not "\\n")', () => {
|
||||
const root = document.createElement('div')
|
||||
realDims(root)
|
||||
const transport = fakeTransport()
|
||||
const term = fakeTerminal()
|
||||
mountTerminalView(root, transport, { createTerminal: () => term })
|
||||
|
||||
term.typeInto('\r')
|
||||
const input = transport.sent.find((m) => m.type === 'input')
|
||||
expect(input).toEqual({ type: 'input', data: '\r' })
|
||||
expect(JSON.stringify(input)).not.toContain('\\n')
|
||||
})
|
||||
|
||||
it('incoming output frame → xterm.write; attached/exit are not written as output', () => {
|
||||
const root = document.createElement('div')
|
||||
realDims(root)
|
||||
const transport = fakeTransport()
|
||||
const term = fakeTerminal()
|
||||
mountTerminalView(root, transport, { createTerminal: () => term })
|
||||
|
||||
transport.push({ type: 'output', data: 'hello' })
|
||||
transport.push({ type: 'attached', sessionId: 'x' })
|
||||
expect(term.written).toEqual(['hello'])
|
||||
})
|
||||
|
||||
it('does NOT call fit() while the container is display:none (guards NaN dims)', () => {
|
||||
const root = document.createElement('div') // clientWidth/Height default to 0 in jsdom
|
||||
const transport = fakeTransport()
|
||||
const term = fakeTerminal()
|
||||
mountTerminalView(root, transport, { createTerminal: () => term })
|
||||
expect(term.fitCalls).toBe(0)
|
||||
expect(transport.sent.some((m) => m.type === 'resize')).toBe(false)
|
||||
})
|
||||
|
||||
it('the same seam mounts identically over any TerminalTransport (drop-in proof)', () => {
|
||||
const root = document.createElement('div')
|
||||
realDims(root)
|
||||
const transport = fakeTransport()
|
||||
const view = mountTerminalView(root, transport, { createTerminal: fakeTerminal })
|
||||
view.dispose()
|
||||
expect(transport.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('server messages decode via the shared codec (round-trip sanity)', () => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify({ type: 'output', data: 'z' }))
|
||||
expect(decodeServerMessage(bytes)).toEqual({ type: 'output', data: 'z' })
|
||||
expect(decodeServerMessage(new TextEncoder().encode('not json'))).toBeNull()
|
||||
})
|
||||
})
|
||||
74
relay-web/test/webauthn.test.ts
Normal file
74
relay-web/test/webauthn.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import {
|
||||
base64UrlToBuffer,
|
||||
bufferToBase64Url,
|
||||
createWebAuthnClient,
|
||||
WebAuthnError,
|
||||
} from '../src/webauthn'
|
||||
|
||||
function ab(bytes: number[]): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer
|
||||
}
|
||||
|
||||
const assertionCred = {
|
||||
id: 'cred-1',
|
||||
type: 'public-key',
|
||||
rawId: ab([1, 2, 3]),
|
||||
response: {
|
||||
clientDataJSON: ab([4, 5]),
|
||||
authenticatorData: ab([6]),
|
||||
signature: ab([7, 8]),
|
||||
userHandle: null,
|
||||
},
|
||||
}
|
||||
|
||||
describe('webauthn wrapper (T7)', () => {
|
||||
it('base64url ↔ ArrayBuffer round-trip is lossless', () => {
|
||||
const bytes = [0, 1, 250, 255, 128, 64]
|
||||
const b64u = bufferToBase64Url(ab(bytes))
|
||||
expect(new Uint8Array(base64UrlToBuffer(b64u))).toEqual(new Uint8Array(bytes))
|
||||
})
|
||||
|
||||
it('authenticate maps the assertion to base64url fields', async () => {
|
||||
const container = {
|
||||
get: vi.fn(async () => assertionCred),
|
||||
create: vi.fn(),
|
||||
} as unknown as CredentialsContainer
|
||||
const wa = createWebAuthnClient(container)
|
||||
const res = await wa.authenticate({ challenge: ab([9]) } as PublicKeyCredentialRequestOptions)
|
||||
expect(res.rawId).toBe(encodeBase64UrlBytes(new Uint8Array([1, 2, 3])))
|
||||
expect(res.response.signature).toBe(encodeBase64UrlBytes(new Uint8Array([7, 8])))
|
||||
expect(res.response.userHandle).toBeNull()
|
||||
})
|
||||
|
||||
it('a user-cancelled prompt (NotAllowedError) maps to WebAuthnError("cancelled")', async () => {
|
||||
const container = {
|
||||
get: vi.fn(async () => {
|
||||
throw Object.assign(new Error('dismissed'), { name: 'NotAllowedError' })
|
||||
}),
|
||||
create: vi.fn(),
|
||||
} as unknown as CredentialsContainer
|
||||
const wa = createWebAuthnClient(container)
|
||||
await expect(
|
||||
wa.authenticate({ challenge: ab([1]) } as PublicKeyCredentialRequestOptions),
|
||||
).rejects.toMatchObject({ name: 'WebAuthnError', kind: 'cancelled' })
|
||||
})
|
||||
|
||||
it('throws WebAuthnError("unsupported") when no credentials container is available', () => {
|
||||
expect(() => createWebAuthnClient(undefined)).toThrow(WebAuthnError)
|
||||
})
|
||||
|
||||
it('does not leak credential material to console', async () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const container = {
|
||||
get: vi.fn(async () => assertionCred),
|
||||
create: vi.fn(),
|
||||
} as unknown as CredentialsContainer
|
||||
await createWebAuthnClient(container).authenticate({
|
||||
challenge: ab([1]),
|
||||
} as PublicKeyCredentialRequestOptions)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
128
relay-web/test/ws-transport.test.ts
Normal file
128
relay-web/test/ws-transport.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||
import { readConfig } from '../src/config'
|
||||
import { createPassthroughTransport, type WebSocketLike } from '../src/ws-transport'
|
||||
|
||||
const cfg = readConfig({
|
||||
protocol: 'https:',
|
||||
host: 'alice.term.example.com',
|
||||
hostname: 'alice.term.example.com',
|
||||
})
|
||||
|
||||
/** Controllable mock WebSocket capturing constructor args and driving lifecycle events. */
|
||||
class MockWs implements WebSocketLike {
|
||||
static last: MockWs | null = null
|
||||
binaryType = ''
|
||||
protocol = ''
|
||||
readonly url: string
|
||||
readonly protocols: readonly string[]
|
||||
readonly sent: Array<ArrayBufferView | ArrayBufferLike | string> = []
|
||||
closed: { code?: number; reason?: string } | null = null
|
||||
onopen: ((ev: unknown) => void) | null = null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
|
||||
constructor(url: string, protocols?: string | readonly string[]) {
|
||||
this.url = url
|
||||
this.protocols = protocols === undefined ? [] : typeof protocols === 'string' ? [protocols] : protocols
|
||||
MockWs.last = this
|
||||
}
|
||||
send(data: ArrayBufferView | ArrayBufferLike | string): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
close(code?: number, reason?: string): void {
|
||||
const ev: { code?: number; reason?: string } = {}
|
||||
if (code !== undefined) ev.code = code
|
||||
if (reason !== undefined) ev.reason = reason
|
||||
this.closed = ev
|
||||
this.onclose?.(ev)
|
||||
}
|
||||
fireOpen(echoedProtocol: string): void {
|
||||
this.protocol = echoedProtocol
|
||||
this.onopen?.({})
|
||||
}
|
||||
fireMessage(data: unknown): void {
|
||||
this.onmessage?.({ data })
|
||||
}
|
||||
}
|
||||
|
||||
describe('createPassthroughTransport (T4)', () => {
|
||||
it('v0.8: opens same-origin wss with NO query-string token (cookie-only auth)', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
expect(MockWs.last!.url).toBe('wss://alice.term.example.com/term')
|
||||
expect(MockWs.last!.url).not.toMatch(/token|\?/)
|
||||
expect(MockWs.last!.protocols).toEqual([APP_SUBPROTOCOL]) // no token entry in v0.8
|
||||
expect(MockWs.last!.binaryType).toBe('arraybuffer')
|
||||
})
|
||||
|
||||
it('round-trips bytes: send() reaches the socket, onMessage() delivers incoming bytes', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
|
||||
const received: Uint8Array[] = []
|
||||
t.onMessage((b) => received.push(b))
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
|
||||
t.send(new Uint8Array([9, 8, 7]))
|
||||
expect(MockWs.last!.sent[0]).toEqual(new Uint8Array([9, 8, 7]))
|
||||
|
||||
MockWs.last!.fireMessage(new Uint8Array([1, 2]).buffer)
|
||||
expect(received[0]).toEqual(new Uint8Array([1, 2]))
|
||||
})
|
||||
|
||||
it('v0.9+ token attachment: token rides Sec-WebSocket-Protocol, NEVER the URL (§4.3)', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, capabilityToken: 'RAWTOK' })
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
expect(MockWs.last!.protocols).toEqual([APP_SUBPROTOCOL, encodeTokenSubprotocol('RAWTOK')])
|
||||
expect(MockWs.last!.url).not.toContain('RAWTOK') // never in the URL/query
|
||||
expect(MockWs.last!.url).not.toContain(encodeTokenSubprotocol('RAWTOK'))
|
||||
})
|
||||
|
||||
it('v0.9+ echo rule: a token echo (or foreign subprotocol) tears the socket down', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, capabilityToken: 'RAWTOK' })
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(encodeTokenSubprotocol('RAWTOK')) // relay wrongly echoes the token entry
|
||||
await expect(opened).rejects.toThrow()
|
||||
expect(MockWs.last!.closed).not.toBeNull()
|
||||
})
|
||||
|
||||
it('server close reason surfaces via onClose (no silent swallow)', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
|
||||
let reason = ''
|
||||
t.onClose((r) => (reason = r))
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
MockWs.last!.close(1000, 'shell exited')
|
||||
expect(reason).toBe('shell exited')
|
||||
})
|
||||
|
||||
it('a socket error during open rejects the open() promise', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
|
||||
const opened = t.open()
|
||||
MockWs.last!.onerror?.({})
|
||||
await expect(opened).rejects.toThrow('websocket error')
|
||||
})
|
||||
|
||||
it('close() before open() is a safe no-op (no throw)', () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
|
||||
expect(() => t.close()).not.toThrow()
|
||||
})
|
||||
|
||||
it('a 4403 upgrade denial maps to onClose("forbidden") (INV1/INV6 client mirror)', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
|
||||
let reason = ''
|
||||
t.onClose((r) => (reason = r))
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
MockWs.last!.close(4403, '')
|
||||
expect(reason).toBe('forbidden')
|
||||
})
|
||||
})
|
||||
23
relay-web/tsconfig.json
Normal file
23
relay-web/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "public/build"]
|
||||
}
|
||||
15
relay-web/vitest.config.ts
Normal file
15
relay-web/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
// Exclude the public barrel and the thin DOM-bootstrap entry pages (integration glue with no
|
||||
// branching logic; exercised by the esbuild build + manual/E2E gates, not unit tests).
|
||||
exclude: ['src/index.ts', 'src/entry/**', 'src/**/*.d.ts'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user