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.
87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
/**
|
|
* T12 — billing-metering hooks (EXPLORE §6): meter PAIRED HOSTS + CONCURRENT VIEWERS (not
|
|
* bandwidth). The sample's `nodeId` is the caller's authenticated mTLS identity (INV3-analog) and
|
|
* its `accountId` is re-derived SERVER-SIDE from the host registry (INV1) — a node-supplied account
|
|
* is never trusted. Samples are append-only immutable rows (INV8); zero terminal payload (INV10).
|
|
*/
|
|
import { z } from 'zod'
|
|
import type { MeteringSampleRow } from '../model/records.js'
|
|
import type { MeteringStore } from '../store/ports.js'
|
|
import type { HostRegistry } from '../registry/hosts.js'
|
|
import type { NodeIdentity } from '../node-auth/identity.js'
|
|
|
|
export interface MeteringSample {
|
|
readonly hostId: string
|
|
readonly concurrentViewers: number
|
|
readonly sampledAt: string
|
|
// NO client/node-supplied accountId or nodeId — both are derived server-side.
|
|
}
|
|
|
|
const MeteringSampleSchema = z
|
|
.object({
|
|
hostId: z.string().uuid(),
|
|
concurrentViewers: z.number().int().nonnegative(),
|
|
sampledAt: z.string().datetime({ offset: true }),
|
|
})
|
|
.strict()
|
|
|
|
export class MeteringError extends Error {}
|
|
|
|
export interface UsageRollup {
|
|
readonly pairedHostPeak: number
|
|
readonly viewerPeak: number
|
|
readonly viewerHours: number
|
|
}
|
|
|
|
export interface MeteringCollector {
|
|
ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise<void>
|
|
pairedHostCount(accountId: string): Promise<number>
|
|
rollupUsage(accountId: string, from: string, to: string): Promise<UsageRollup>
|
|
}
|
|
|
|
export interface MeteringDeps {
|
|
readonly metering: MeteringStore
|
|
readonly hosts: HostRegistry
|
|
}
|
|
|
|
export function createMeteringCollector(deps: MeteringDeps): MeteringCollector {
|
|
return {
|
|
async ingestSample(caller, sample) {
|
|
const parsed = MeteringSampleSchema.safeParse(sample)
|
|
if (!parsed.success) throw new MeteringError(`invalid metering sample: ${parsed.error.issues[0]?.message}`)
|
|
const host = await deps.hosts.getHost(parsed.data.hostId)
|
|
// Attribution derived from ownership — a hostId the caller can't substantiate is rejected (INV1).
|
|
if (host === null) throw new MeteringError('unknown hostId — cannot attribute usage')
|
|
const row: MeteringSampleRow = {
|
|
hostId: parsed.data.hostId,
|
|
accountId: host.accountId, // server-derived, never node-asserted
|
|
nodeId: caller.nodeId, // authenticated identity, never a body field
|
|
concurrentViewers: parsed.data.concurrentViewers,
|
|
sampledAt: parsed.data.sampledAt,
|
|
}
|
|
await deps.metering.append(row) // append-only (INV8)
|
|
},
|
|
async pairedHostCount(accountId) {
|
|
const hosts = await deps.hosts.listHosts(accountId)
|
|
return hosts.filter((h) => h.status !== 'revoked').length
|
|
},
|
|
async rollupUsage(accountId, from, to) {
|
|
const samples = [...(await deps.metering.query(accountId, from, to))].sort(
|
|
(a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt),
|
|
)
|
|
const viewerPeak = samples.reduce((m, s) => Math.max(m, s.concurrentViewers), 0)
|
|
const distinctHosts = new Set(samples.map((s) => s.hostId))
|
|
// Step-function integral of concurrent viewers over the window → viewer-hours.
|
|
const toMs = Date.parse(to)
|
|
let viewerHours = 0
|
|
for (let i = 0; i < samples.length; i++) {
|
|
const cur = samples[i] as MeteringSampleRow
|
|
const nextMs = i + 1 < samples.length ? Date.parse((samples[i + 1] as MeteringSampleRow).sampledAt) : toMs
|
|
const spanHours = Math.max(0, nextMs - Date.parse(cur.sampledAt)) / 3_600_000
|
|
viewerHours += cur.concurrentViewers * spanHours
|
|
}
|
|
return { pairedHostPeak: distinctHosts.size, viewerPeak, viewerHours }
|
|
},
|
|
}
|
|
}
|