@volter/twin-tunnel 0.1.37 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +13 -121
- package/dist/generated/pack-facts.json +54 -0
- package/dist/journeys/coverage.json +109 -0
- package/dist/journeys/customer-life.json +588 -0
- package/dist/journeys/customer-life.outline.md +34 -0
- package/dist/journeys/customer-life.outline.verdict.json +42 -0
- package/dist/journeys/customer-life.verdict.json +51 -0
- package/dist/journeys/decisions.json +23 -0
- package/dist/journeys/demand.json +24 -0
- package/dist/journeys/score.json +134 -0
- package/dist/journeys/unreachable.json +12 -0
- package/dist/journeys/vendor-examples.json +7 -0
- package/dist/journeys/vendor-examples.verdict.json +18 -0
- package/dist/spec/SOURCE.md +30 -0
- package/dist/spec/openapi.json +50 -0
- package/dist/spec/sources.json +1 -0
- package/dist/src/cli.js +15 -53
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +19 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +5 -7
- package/dist/src/index.js +9 -42
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +49 -0
- package/dist/src/semantics/around.d.ts +2 -0
- package/dist/src/semantics/around.js +72 -0
- package/dist/src/semantics/doors.d.ts +3 -0
- package/dist/src/semantics/doors.js +18 -0
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +1 -0
- package/dist/src/semantics/shared.d.ts +22 -0
- package/dist/src/semantics/shared.js +73 -0
- package/dist/src/semantics/sockets.d.ts +2 -0
- package/dist/src/semantics/sockets.js +161 -0
- package/dist/src/semantics/states.d.ts +1 -0
- package/dist/src/semantics/states.js +3 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +9 -0
- package/generated/pack-facts.json +54 -0
- package/journeys/coverage.json +109 -0
- package/journeys/customer-life.json +588 -0
- package/journeys/customer-life.outline.md +34 -0
- package/journeys/customer-life.outline.verdict.json +42 -0
- package/journeys/customer-life.verdict.json +51 -0
- package/journeys/decisions.json +23 -0
- package/journeys/demand.json +24 -0
- package/journeys/score.json +134 -0
- package/journeys/unreachable.json +12 -0
- package/journeys/vendor-examples.json +7 -0
- package/journeys/vendor-examples.verdict.json +18 -0
- package/package.json +17 -32
- package/spec/SOURCE.md +30 -0
- package/spec/openapi.json +50 -0
- package/spec/sources.json +1 -0
- package/src/cli.ts +15 -41
- package/src/fetch.ts +20 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +10 -52
- package/src/manifest.ts +54 -0
- package/src/semantics/around.ts +68 -0
- package/src/semantics/doors.ts +21 -0
- package/src/semantics/index.ts +6 -0
- package/src/semantics/shared.ts +78 -0
- package/src/semantics/sockets.ts +127 -0
- package/src/semantics/states.ts +3 -0
- package/src/server.ts +10 -0
- package/dist/src/tunnel-budget.d.ts +0 -21
- package/dist/src/tunnel-budget.js +0 -26
- package/dist/src/tunnel-capabilities.d.ts +0 -4
- package/dist/src/tunnel-capabilities.js +0 -2091
- package/dist/src/tunnel-conformance.d.ts +0 -10
- package/dist/src/tunnel-conformance.js +0 -63
- package/dist/src/tunnel-connector.d.ts +0 -21
- package/dist/src/tunnel-connector.js +0 -68
- package/dist/src/tunnel-server.d.ts +0 -109
- package/dist/src/tunnel-server.js +0 -1197
- package/dist/src/tunnel-twin.d.ts +0 -175
- package/dist/src/tunnel-twin.js +0 -236
- package/src/tunnel-budget.ts +0 -34
- package/src/tunnel-capabilities.ts +0 -1770
- package/src/tunnel-conformance.ts +0 -62
- package/src/tunnel-connector.ts +0 -64
- package/src/tunnel-server.ts +0 -1194
- package/src/tunnel-twin.ts +0 -339
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.0.2",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "volter-tunnel relay",
|
|
5
|
+
"version": "2",
|
|
6
|
+
"description": "The volter-tunnel relay's HTTP surface at its apex: its status. A client's control channel (a WebSocket at /ws?id=<tunnelId>) and the public requests it answers (every path of <tunnelId>.<domain>) are the relay's protocol, recorded in spec/SOURCE.md. Written from volter-ai/volter-tunnel (spec/SOURCE.md)."
|
|
7
|
+
},
|
|
8
|
+
"servers": [
|
|
9
|
+
{
|
|
10
|
+
"url": "https://voltertest.xyz"
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"paths": {
|
|
14
|
+
"/api/status": {
|
|
15
|
+
"get": {
|
|
16
|
+
"operationId": "getStatus",
|
|
17
|
+
"summary": "The relay's status.",
|
|
18
|
+
"responses": {
|
|
19
|
+
"200": {
|
|
20
|
+
"description": "The relay is up.",
|
|
21
|
+
"content": {
|
|
22
|
+
"application/json": {
|
|
23
|
+
"schema": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"ok": {
|
|
27
|
+
"type": "boolean"
|
|
28
|
+
},
|
|
29
|
+
"relay": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"example": "cloudflare-do"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"x-reference": {
|
|
43
|
+
"protocol": "/**\n * The volter-tunnel control-channel wire protocol \u2014 the single source of truth\n * for the messages exchanged over the control WebSocket between a client and the\n * relay. Both the client and the relay import these types; a change here is a\n * change to the contract, type-checked on both sides.\n */\nimport type { AccountSnapshot, CorrelationId, HeaderMap, RateWindow, UsageLevel } from './dto';\n\n// \u2500\u2500 client \u2192 relay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** First frame the client sends; reserves a tunnel id and sets auth options. */\nexport interface RegisterMsg {\n type: 'register';\n /** Requested tunnel id; the relay generates one if omitted. */\n tunnelId?: string;\n /** Shared secret or api/login token authenticating the client. */\n secret?: string;\n /** Replace an existing live registration for this id (reconnect). */\n replace?: boolean;\n /** Whether inbound requests must pass the relay's JWT/cookie auth layer. */\n authRequired: boolean;\n /** Optional HTTP basic-auth gate applied to every inbound request. */\n basicAuth?: { user: string; pass: string };\n}\n\n/** A complete (buffered) response \u2014 used for relay-visible errors like 502. */\nexport interface ResponseMsg {\n type: 'response';\n reqId: CorrelationId;\n status: number;\n headers: HeaderMap;\n /** base64-encoded body. */\n body: string;\n}\n\n/** Start of a streamed response (status + headers, body follows in chunks). */\nexport interface ResponseStartMsg {\n type: 'response-start';\n reqId: CorrelationId;\n status: number;\n headers: HeaderMap;\n}\n\n/** One streamed response body chunk. */\nexport interface ResponseChunkMsg {\n type: 'response-chunk';\n reqId: CorrelationId;\n /** base64-encoded chunk. */\n data: string;\n}\n\n/** End of a streamed response. */\nexport interface ResponseEndMsg {\n type: 'response-end';\n reqId: CorrelationId;\n}\n\n/** The client connected to the local WebSocket and is ready to relay frames. */\nexport interface WsReadyMsg {\n type: 'ws-ready';\n connId: CorrelationId;\n}\n\n/** The client failed to establish/maintain the local WebSocket. */\nexport interface WsErrorMsg {\n type: 'ws-error';\n connId: CorrelationId;\n error: string;\n}\n\n// \u2500\u2500 relay \u2192 client \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Acknowledges a successful registration; carries the public URL + usage. */\nexport interface RegisteredMsg {\n type: 'registered';\n tunnelId: string;\n url: string;\n /** Present when the relay meters usage for this account. */\n account?: AccountSnapshot;\n}\n\n/** An inbound HTTP request to forward to the local server. */\nexport interface RequestMsg {\n type: 'request';\n reqId: CorrelationId;\n method: string;\n path: string;\n headers: HeaderMap;\n /** base64-encoded body, or null for bodyless methods. */\n body: string | null;\n}\n\n/** The relay asks the client to abort an in-flight request. */\nexport interface RequestAbortMsg {\n type: 'request-abort';\n reqId: CorrelationId;\n}\n\n/** An inbound browser WebSocket upgrade to bridge to the local server. */\nexport interface WsUpgradeMsg {\n type: 'ws-upgrade';\n connId: CorrelationId;\n path: string;\n headers: HeaderMap;\n}\n\n/** Account usage level changed (ok \u2194 warn \u2194 exceeded). */\nexport interface QuotaMsg {\n type: 'quota';\n level: UsageLevel;\n day: RateWindow;\n month: RateWindow;\n}\n\n/** A terminal protocol/registration error from the relay. */\nexport interface ErrorMsg {\n type: 'error';\n message: string;\n /** The client must not reconnect this control session without operator action. */\n fatal?: boolean;\n}\n\n// \u2500\u2500 bidirectional (same shape both ways) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** A WebSocket data frame relayed in either direction. */\nexport interface WsMessageMsg {\n type: 'ws-message';\n connId: CorrelationId;\n /** base64-encoded frame payload. */\n data: string;\n binary: boolean;\n}\n\n/** A WebSocket close relayed in either direction. */\nexport interface WsCloseMsg {\n type: 'ws-close';\n connId: CorrelationId;\n code?: number;\n reason?: string;\n}\n\n// \u2500\u2500 unions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Frames a client sends to the relay. */\nexport type ClientToRelay =\n | RegisterMsg\n | ResponseMsg\n | ResponseStartMsg\n | ResponseChunkMsg\n | ResponseEndMsg\n | WsReadyMsg\n | WsErrorMsg\n | WsMessageMsg\n | WsCloseMsg;\n\n/** Frames the relay sends to a client. */\nexport type RelayToClient =\n | RegisteredMsg\n | RequestMsg\n | RequestAbortMsg\n | WsUpgradeMsg\n | QuotaMsg\n | ErrorMsg\n | WsMessageMsg\n | WsCloseMsg;\n\n/** Any control-channel message. */\nexport type ControlMessage = ClientToRelay | RelayToClient;\n\n/** Every valid `type` discriminator, the authoritative registry. */\nexport const MESSAGE_TYPES = [\n 'register',\n 'response',\n 'response-start',\n 'response-chunk',\n 'response-end',\n 'ws-ready',\n 'ws-error',\n 'ws-message',\n 'ws-close',\n 'registered',\n 'request',\n 'request-abort',\n 'ws-upgrade',\n 'quota',\n 'error',\n] as const;\n\nexport type MessageType = (typeof MESSAGE_TYPES)[number];\n\nconst MESSAGE_TYPE_SET: ReadonlySet<string> = new Set(MESSAGE_TYPES);\n\n/** True if `x` is an object with a known control-message `type` discriminator. */\nexport function isControlMessage(x: unknown): x is ControlMessage {\n return (\n typeof x === 'object' &&\n x !== null &&\n !Array.isArray(x) &&\n typeof (x as { type?: unknown }).type === 'string' &&\n MESSAGE_TYPE_SET.has((x as { type: string }).type)\n );\n}\n",
|
|
44
|
+
"relay": "/**\n * TunnelDO \u2014 one Durable Object per tunnelId.\n *\n * Holds the tunnel client's control WebSocket (hibernatable) and relays inbound\n * HTTP requests and browser WebSocket connections over it, using the same wire\n * protocol defined in @volter/tunnel-core. Because there is exactly one DO per tunnelId\n * (Worker routes via idFromName(tunnelId)), the multi-tenant `clients` map a single-process relay needs collapses to \"the one control socket this DO holds\".\n *\n * Hibernation model: WebSockets + their serialized attachments survive eviction\n * (re-fetched via ctx.getWebSockets(tag)). In-memory correlation maps only need\n * to live for the duration of a single awaited fetch (the DO stays resident while\n * a request is in flight), so request/upgrade correlation uses instance maps.\n */\nimport { DurableObject } from 'cloudflare:workers';\nimport {\n type Env,\n type HeaderRules,\n buildResponseHeaders,\n cookieFor,\n corsHeaders,\n parseHeaderRules,\n stripAuthCookie,\n stripTokenParam,\n validateAuth,\n validateWsAuth,\n} from './auth';\nimport { CREDIT_WEIGHTS, hashToken, parseToken, type UsageDelta } from './credits';\nimport { burstStep, envNum, reservationDecision } from './metering-types';\nimport type {\n AuthorizeResult,\n BurstState,\n DeviceCredentialResult,\n LeaseResult,\n RateSnapshot,\n Reservation,\n} from './metering-types';\nimport { sendFrame } from './protocol';\n\ninterface CtlAttach {\n role: 'ctl';\n registered: boolean;\n /** A connector replaced after the successor was authorized. Its eventual\n * close must not settle or tear down state now owned by that successor. */\n superseded?: boolean;\n authRequired: boolean;\n tunnelId: string;\n /** Account this tunnel meters against \u2014 set at register, survives hibernation. */\n slug?: string;\n /** Credits granted per lease top-up (from the account config). */\n leaseChunk?: number;\n /** Date.now() at register \u2014 tunnel-seconds are billed from here on close. */\n openedAt?: number;\n /** Registration nonce \u2014 disambiguates this socket's account entry from a\n * replacement's, so a superseded socket's close can't clobber the new owner. */\n regId?: string;\n /** Re-authorize inputs (so a reaped-but-live tunnel can re-register its entry). */\n legacy?: boolean;\n tokenHash?: string;\n /** Active device credential that won this reserved-id lease. Newer\n * credentials may replace it; older credentials may not evict it. */\n credentialCreatedAt?: string;\n credentialId?: string;\n /** SHA-256 of \"user:pass\" when the tunnel is gated by HTTP Basic Auth (#6). */\n basicAuthHash?: string;\n}\ninterface BrowserAttach {\n role: 'browser';\n connId: string;\n}\n\ninterface PendingHttp {\n resolve: (r: Response) => void;\n timer: ReturnType<typeof setTimeout>;\n request: Request;\n bootstrapCookie: string | null;\n /** RateLimit-* headers captured at request time, applied to the response. */\n rate: Record<string, string>;\n}\ninterface Streaming {\n controller: ReadableStreamDefaultController<Uint8Array>;\n idle: ReturnType<typeof setTimeout>;\n}\ninterface PendingUpgrade {\n resolve: () => void;\n reject: (e: Error) => void;\n timer: ReturnType<typeof setTimeout>;\n}\n\n/** One captured request for the live inspector (#5). Metadata only \u2014 no bodies\n * or headers; persistence/replay is the paid #10. In-memory, so the buffer is\n * lost on hibernation (fine for a live view of an actively-used tunnel). */\ninterface InspectEntry {\n id: string;\n t: number;\n method: string;\n path: string;\n status: number | null;\n ms: number | null;\n bytes: number | null;\n}\n\n/** A captured request stored for replay (#10). Body is base64, size-capped. */\ninterface Capture {\n method: string;\n path: string;\n headers: Record<string, string>;\n body: string | null;\n at: number;\n}\n\n/** Charge relayed messages (stream chunks / WS frames) in batches of this many,\n * keeping the per-frame path cheap. Caps unmetered overshoot at one batch. */\nconst MSG_FLUSH_EVERY = 32;\n\n/** Max requests retained in the live inspector ring (#5). */\nconst INSPECT_CAP = 50;\n\nfunction b64encode(bytes: Uint8Array): string {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!);\n return btoa(s);\n}\nfunction b64decode(b64: string): Uint8Array {\n const s = atob(b64);\n const out = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);\n return out;\n}\nfunction headersToObject(headers: Headers): Record<string, string> {\n const out: Record<string, string> = {};\n headers.forEach((v, k) => {\n out[k] = v;\n });\n return out;\n}\n\n/** Prepare visitor headers for the local origin. Never trust a visitor-supplied\n * X-Forwarded-For value: Cloudflare's edge-authenticated CF-Connecting-IP is the\n * authority. A fail-closed non-loopback sentinel prevents downstream apps from\n * mistaking a proxied request for a localhost caller when that header is absent\n * in local/self-hosted relay environments. */\nfunction forwardedRequestHeaders(request: Request): Record<string, string> {\n const out = stripAuthCookie(headersToObject(request.headers));\n delete out['x-forwarded-for'];\n out['x-forwarded-for'] = request.headers.get('cf-connecting-ip') || '0.0.0.0';\n return out;\n}\n\n/**\n * Clamp a WebSocket close reason to 123 UTF-8 bytes (a control frame's reason is\n * capped at 125 bytes minus the 2-byte code). close() throws RangeError otherwise\n * \u2014 this guard was added after one oversized relayed reason crash-looped a relay. Drops a trailing replacement char from a severed sequence.\n */\nfunction truncateReason(value: unknown): string {\n const s = String(value ?? '');\n const enc = new TextEncoder().encode(s);\n if (enc.length <= 123) return s;\n return new TextDecoder().decode(enc.subarray(0, 123)).replace(/\ufffd+$/, '');\n}\n\n/** Constant-time string compare for the shared secret (avoids early-exit timing). */\nfunction safeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let r = 0;\n for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return r === 0;\n}\n\nexport class TunnelDO extends DurableObject<Env> {\n private pendingHttp = new Map<string, PendingHttp>();\n private streaming = new Map<string, Streaming>();\n private pendingUpgrades = new Map<string, PendingUpgrade>();\n\n /** Operator response-header rewrite rules (parsed once from env). */\n private _headerRules: HeaderRules | null = null;\n private get headerRules(): HeaderRules {\n return (this._headerRules ??= parseHeaderRules(this.env.RESPONSE_HEADER_RULES));\n }\n\n /** Per-tunnel request-rate burst limiter state (#4; in-memory, resets on\n * hibernation \u2014 lenient, it only bounds bursts while the DO is resident). */\n private burst: BurstState = { tokens: 0, last: 0, init: false };\n\n /** Live request inspector ring (#5; in-memory, persisted to storage when\n * INSPECT_REPLAY is on so it survives hibernation \u2014 #10). */\n private inspect: InspectEntry[] = [];\n private inspectByReq = new Map<string, InspectEntry>();\n /** Captured requests for replay (#10), keyed by reqId. Persisted when enabled. */\n private captures = new Map<string, Capture>();\n\n constructor(ctx: DurableObjectState, env: Env) {\n super(ctx, env);\n // Only touch storage when replay/persistence is on \u2014 otherwise the inspector\n // is purely in-memory (#5) and a cold DO does no extra reads.\n if (env.INSPECT_REPLAY === 'true' || env.INSPECT_REPLAY === '1') {\n ctx.blockConcurrencyWhile(async () => {\n this.inspect = (await ctx.storage.get<InspectEntry[]>('inspect')) ?? [];\n const caps = (await ctx.storage.get<Record<string, Capture>>('captures')) ?? {};\n this.captures = new Map(Object.entries(caps));\n });\n }\n }\n\n private get replayEnabled(): boolean {\n return this.env.INSPECT_REPLAY === 'true' || this.env.INSPECT_REPLAY === '1';\n }\n\n /** Record a request as it starts; trims the ring to its cap. */\n private pushInspect(reqId: string, method: string, path: string): void {\n const e: InspectEntry = { id: reqId, t: Date.now(), method, path, status: null, ms: null, bytes: null };\n this.inspect.push(e);\n const cap = envNum(this.env.INSPECT_MAX, INSPECT_CAP);\n while (this.inspect.length > cap) this.inspect.shift();\n this.inspectByReq.set(reqId, e);\n }\n /** Fill in a request's outcome (status, latency, response size) on completion. */\n private finishInspect(reqId: string, status: number, bytes: number | null): void {\n const e = this.inspectByReq.get(reqId);\n if (!e) return;\n e.status = status;\n e.ms = Date.now() - e.t;\n e.bytes = bytes;\n this.inspectByReq.delete(reqId);\n if (this.replayEnabled) void this.ctx.storage.put('inspect', this.inspect).catch(() => {});\n }\n\n /** Persist a captured request for later replay (#10), trimming to INSPECT_MAX. */\n private async captureForReplay(\n id: string,\n method: string,\n path: string,\n headers: Record<string, string>,\n bodyBytes: Uint8Array | null\n ): Promise<void> {\n const maxBody = envNum(this.env.INSPECT_BODY_MAX, 65536);\n const body = bodyBytes?.length && bodyBytes.length <= maxBody ? b64encode(bodyBytes) : null;\n this.captures.set(id, { method, path, headers, body, at: Date.now() });\n const cap = envNum(this.env.INSPECT_MAX, INSPECT_CAP);\n while (this.captures.size > cap) {\n const oldest = this.captures.keys().next().value;\n if (oldest === undefined) break;\n this.captures.delete(oldest);\n }\n await this.ctx.storage.put('captures', Object.fromEntries(this.captures));\n }\n\n // \u2500\u2500 metering state (ephemeral; safe to lose on hibernation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** Credits leased from the account but not yet spent. */\n private budget = 0;\n /** Credits spent since the last lease/close flush to the account. */\n private consumedSinceCommit = 0;\n /** Raw usage (for dashboards) accumulated since the last flush. */\n private rawSinceFlush: UsageDelta = { requests: 0, wsUpgrades: 0, bytes: 0, seconds: 0 };\n /** Serializes budget mutations so concurrent requests don't double-lease. */\n private budgetChain: Promise<unknown> = Promise.resolve();\n /** Latest limit snapshot from the account (for RateLimit headers + quota push). */\n private rate: RateSnapshot | null = null;\n /** Last quota level pushed to the client \u2014 only re-push on change. */\n private lastQuotaLevel: string | null = null;\n /** Relayed DO messages (response chunks + WS frames) not yet charged. Flushed\n * in batches so the per-frame path stays cheap; bounds unmetered overshoot. */\n private msgsSinceFlush = 0;\n\n private accountStub(slug?: string): DurableObjectStub | null {\n const s = slug ?? this.ctlAttach()?.slug;\n if (!s) return null;\n return this.env.ACCOUNTS.get(this.env.ACCOUNTS.idFromName(s));\n }\n\n /** RPC to the account DO. `slug` is required at register (before the slug is in\n * the attachment); afterwards it's read from the attachment. */\n private async accountRpc<T>(path: string, payload: Record<string, unknown>, slug?: string): Promise<T | null> {\n const stub = this.accountStub(slug ?? (payload.slug as string | undefined));\n if (!stub) return null;\n try {\n const res = await stub.fetch(`https://account${path}`, {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n return (await res.json()) as T;\n } catch {\n return null;\n }\n }\n\n private async registryRpc<T>(path: string, payload: Record<string, unknown>): Promise<T | null> {\n try {\n const stub = this.env.REGISTRY.get(this.env.REGISTRY.idFromName('registry'));\n const res = await stub.fetch(`https://registry${path}`, {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n return (await res.json()) as T;\n } catch {\n return null;\n }\n }\n\n private takeRaw(): UsageDelta {\n const r = this.rawSinceFlush;\n this.rawSinceFlush = { requests: 0, wsUpgrades: 0, bytes: 0, seconds: 0 };\n return r;\n }\n\n /** Record the latest account snapshot and, on a level change, push a `quota`\n * frame to the client so the CLI/gateway can warn before the hard cutoff. */\n private applyRate(rate: RateSnapshot | undefined): void {\n if (!rate) return;\n this.rate = rate;\n if (rate.level !== this.lastQuotaLevel) {\n this.lastQuotaLevel = rate.level;\n const ctl = this.ctl();\n if (ctl) {\n try {\n sendFrame(ctl, { type: 'quota', level: rate.level, day: rate.day, month: rate.month });\n } catch {\n /* control gone */\n }\n }\n }\n }\n\n /** IETF RateLimit-* headers (draft-ietf-httpapi-ratelimit-headers) for the\n * binding daily window. `reset` is seconds until refill. */\n private rateHeaders(): Record<string, string> {\n if (!this.rate) return {};\n const nowSec = Math.floor(Date.now() / 1000);\n return {\n 'ratelimit-limit': String(this.rate.day.limit),\n 'ratelimit-remaining': String(Math.max(0, this.rate.day.remaining)),\n 'ratelimit-reset': String(Math.max(1, this.rate.day.reset - nowSec)),\n };\n }\n\n /** Seconds until the binding window refills \u2014 for Retry-After on a 429. */\n private retryAfterSeconds(): number {\n if (!this.rate) return 60;\n const nowSec = Math.floor(Date.now() / 1000);\n const dayOut = this.rate.day.remaining <= 0;\n const reset = dayOut ? this.rate.day.reset : this.rate.month.reset;\n return Math.max(1, reset - nowSec);\n }\n\n /**\n * Pre-authorize `cost` credits before relaying. Spends from the local lease;\n * tops the lease up from the account when short. Returns false (\u2192 429) when the\n * account has no budget left \u2014 the hard cutoff that bounds spend. Fail-closed:\n * an unreachable account yields no grant, so traffic stops rather than runs free.\n */\n private ensureBudget(cost: number): Promise<boolean> {\n const run = this.budgetChain.then(async () => {\n if (this.budget >= cost) {\n this.budget -= cost;\n this.consumedSinceCommit += cost;\n return true;\n }\n const attach = this.ctlAttach();\n const tunnelId = attach?.tunnelId ?? '';\n const want = Math.max(attach?.leaseChunk ?? 50, cost);\n let res = await this.accountRpc<LeaseResult>('/lease', {\n tunnelId,\n regId: attach?.regId,\n want,\n commit: this.consumedSinceCommit,\n raw: this.takeRaw(),\n });\n this.consumedSinceCommit = 0;\n // The account reaped our (idle) entry \u2192 re-register it and retry once so a\n // still-connected tunnel self-heals instead of getting wedged at 0 budget.\n if (res?.notOpen && (await this.reauthorize())) {\n res = await this.accountRpc<LeaseResult>('/lease', {\n tunnelId,\n regId: this.ctlAttach()?.regId,\n want,\n commit: 0,\n raw: this.takeRaw(),\n });\n }\n this.applyRate(res?.rate);\n this.budget += res?.grant ?? 0;\n if (this.budget >= cost) {\n this.budget -= cost;\n this.consumedSinceCommit += cost;\n return true;\n }\n return false;\n });\n this.budgetChain = run.catch(() => {});\n return run;\n }\n\n /** Re-authorize this tunnel's account entry after a reap. Runs inside the budget\n * chain (callers already hold it). True if the account re-accepted the tunnel. */\n private async reauthorize(): Promise<boolean> {\n const a = this.ctlAttach();\n if (!a?.slug || !a.regId) return false;\n const auth = await this.accountRpc<AuthorizeResult>('/authorize', {\n slug: a.slug,\n legacy: a.legacy,\n tokenHash: a.tokenHash,\n tunnelId: a.tunnelId,\n regId: a.regId,\n });\n return !!auth?.ok;\n }\n\n /** Charge one relayed-message op. Fast path: spend from the locally-held lease\n * synchronously (no await, atomic in the single-threaded DO). Slow path (local\n * budget exhausted): await a top-up from the account. Returns false when the\n * account is out of budget \u2014 the caller must then cut the traffic off. This is\n * the synchronous gate the floodable browser-frame path needs so it can never\n * relay unmetered while an async charge is in flight. */\n private async chargeMessage(): Promise<boolean> {\n const cost = CREDIT_WEIGHTS.message;\n if (this.budget >= cost) {\n this.budget -= cost;\n this.consumedSinceCommit += cost;\n return true;\n }\n return this.ensureBudget(cost);\n }\n\n /** Charge the batch of relayed CONTROL messages accrued since the last flush. On\n * exhaustion, cut the chatty traffic off by closing the browser sockets\n * (the control socket stays so the client can re-auth after the reset). */\n private async flushMessages(): Promise<void> {\n const n = this.msgsSinceFlush;\n if (n <= 0) return;\n this.msgsSinceFlush = 0;\n const ok = await this.ensureBudget(n * CREDIT_WEIGHTS.message);\n if (!ok) {\n this.applyRate(this.rate ?? undefined); // ensure an 'exceeded' quota push\n for (const b of this.ctx.getWebSockets('browser')) {\n try {\n (b as WebSocket).close(1011, 'Account quota exceeded');\n } catch {\n /* already gone */\n }\n }\n }\n }\n\n async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const upgrade = request.headers.get('Upgrade');\n\n // Internal admin: revoke this tunnelId's reservation (#3). Reached only via a\n // DO-to-DO call from the RegistryDO after it has authenticated root; gated by\n // the root token so a browser hitting this path on the tunnel host can't.\n if (url.pathname === '/__internal/revoke-reservation') {\n const bearer = (request.headers.get('authorization') || '').replace(/^Bearer /, '');\n if (!this.env.ROOT_TOKEN || !safeEqual(bearer, this.env.ROOT_TOKEN)) {\n return new Response('forbidden', { status: 403 });\n }\n return this.revokeReservation(request.headers.get('x-volter-expected-owner'));\n }\n\n // Control channel: the tunnel client connects to /ws?id=<tunnelId>.\n if (url.pathname === '/ws') {\n if (upgrade !== 'websocket') return new Response('expected websocket', { status: 426 });\n return this.acceptControl(url);\n }\n\n // Browser-side WebSocket upgrade on a tunnel subdomain.\n if (upgrade === 'websocket') return this.handleBrowserWsUpgrade(request, url);\n\n // Plain HTTP data request.\n return this.handleHttpRequest(request, url);\n }\n\n /** Revoke this tunnelId's reservation (#3): release the slot on the owner's\n * account, clear the persistent record, and disconnect any live client. */\n private async revokeReservation(expectedOwner: string | null): Promise<Response> {\n const res = await this.ctx.storage.get<Reservation>('reservation');\n if (expectedOwner && res?.ownerSlug !== expectedOwner) {\n return Response.json({ ok: false, error: 'reservation owner changed' }, { status: 409 });\n }\n if (res?.tunnelId) {\n await this.accountRpc('/release-id', { tunnelId: res.tunnelId }, res.ownerSlug);\n }\n await this.ctx.storage.delete('reservation');\n for (const w of this.ctx.getWebSockets('ctl')) {\n try {\n (w as WebSocket).close(4010, 'Reservation revoked');\n } catch {\n /* already gone */\n }\n }\n return Response.json({ ok: true, revoked: !!res, slug: res?.ownerSlug ?? null });\n }\n\n // \u2500\u2500 control socket helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n private ctl(): WebSocket | null {\n const arr = this.ctx.getWebSockets('ctl');\n const registered = arr.find((ws) => (ws.deserializeAttachment() as CtlAttach | null)?.registered);\n return (registered ?? arr[0] ?? null) as WebSocket | null;\n }\n private ctlAttach(): CtlAttach | null {\n const ws = this.ctl();\n return ws ? (ws.deserializeAttachment() as CtlAttach) : null;\n }\n\n private acceptControl(url: URL): Response {\n // The Worker routed us via idFromName(?id=), so ?id= is the authoritative\n // tunnelId for this DO \u2014 capture it so the registered URL always matches the\n // subdomain the browser actually reaches (don't trust msg.tunnelId blindly).\n const id = url.searchParams.get('id') || '';\n const { 0: client, 1: server } = new WebSocketPair();\n const attach: CtlAttach = { role: 'ctl', registered: false, authRequired: true, tunnelId: id };\n server.serializeAttachment(attach);\n this.ctx.acceptWebSocket(server, ['ctl']);\n return new Response(null, { status: 101, webSocket: client });\n }\n\n /** Validate an Authorization: Basic header against the stored \"user:pass\" hash\n * (#6). Constant-time on the hash; returns false on any missing/malformed input. */\n private async checkBasicAuth(request: Request, expectedHash: string): Promise<boolean> {\n const h = request.headers.get('authorization');\n if (!h || !h.startsWith('Basic ')) return false;\n let decoded: string;\n try {\n decoded = atob(h.slice(6).trim());\n } catch {\n return false;\n }\n return safeEqual(await hashToken(decoded), expectedHash);\n }\n\n /** Owner-only gate for the inspector/replay endpoints (#sec): the caller must\n * present the tunnel's own secret (api token or, for legacy, TUNNEL_SECRET).\n * This keeps request metadata private even on a public (authRequired=false)\n * tunnel \u2014 without forcing JWT auth on visitors, which would break sharing. */\n private async inspectorAuthorized(request: Request, attach: CtlAttach): Promise<boolean> {\n const bearer = (request.headers.get('authorization') || '').replace(/^Bearer /, '');\n if (!bearer) return false;\n if (attach.tokenHash) return safeEqual(await hashToken(bearer), attach.tokenHash);\n if (attach.legacy && this.env.TUNNEL_SECRET) return safeEqual(bearer, this.env.TUNNEL_SECRET);\n return false;\n }\n\n // \u2500\u2500 inbound HTTP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // `isReplay` (#10) marks an internally re-issued capture: it has already been\n // authorized via the auth-gated /__volter_replay endpoint, so it skips the\n // burst/auth/endpoint preamble and goes straight to meter + forward.\n private async handleHttpRequest(request: Request, url: URL, isReplay = false): Promise<Response> {\n // Answer CORS preflight BEFORE the connectivity check (answer it early) so\n // a momentarily-down tunnel doesn't surface a CORS error to the browser.\n if (request.method === 'OPTIONS') {\n return new Response(null, { status: 204, headers: corsHeaders(request) });\n }\n const ctl = this.ctl();\n const attach = this.ctlAttach();\n if (!ctl || !attach?.registered) {\n return new Response('Tunnel not connected', { status: 502, headers: corsHeaders(request) });\n }\n\n // Per-tunnel request-rate burst limit (#4) \u2014 cheap flood guard ahead of auth\n // and forwarding. The daily/monthly credit caps remain the primary limit.\n const rps = envNum(this.env.BURST_RPS, 0);\n if (!isReplay && rps > 0) {\n const retry = burstStep(this.burst, Date.now(), rps, envNum(this.env.BURST_SIZE, rps * 2));\n if (retry > 0) {\n return new Response('Too Many Requests', {\n status: 429,\n headers: { ...corsHeaders(request), 'retry-after': String(retry) },\n });\n }\n }\n\n // Basic-auth gate (#6): if the tunnel was registered with credentials, every\n // inbound request must present a matching Authorization: Basic header before\n // anything is served or forwarded (independent of the JWT layer below).\n if (!isReplay && attach.basicAuthHash && !(await this.checkBasicAuth(request, attach.basicAuthHash))) {\n return new Response('Authentication required', {\n status: 401,\n headers: {\n ...corsHeaders(request),\n 'www-authenticate': 'Basic realm=\"volter-tunnel\"',\n },\n });\n }\n\n let bootstrapCookie: string | null = null;\n if (!isReplay && attach.authRequired && this.env.JWT_SECRET) {\n const auth = await validateAuth(request, url, this.env.JWT_SECRET, {\n tunnelId: attach.tunnelId,\n requireTid: this.env.REQUIRE_TID === 'true',\n });\n if (!auth) {\n return Response.json({ error: 'Authentication required' }, { status: 401, headers: corsHeaders(request) });\n }\n if (auth.source === 'query') {\n // Scope the cookie to THIS tunnel so a token bootstrapped on one tunnel's\n // subdomain isn't sent to (and rejected by) another open tunnel.\n bootstrapCookie = cookieFor(auth.token, `${attach.tunnelId}.${this.env.TUNNEL_DOMAIN}`);\n }\n }\n\n // Live request inspector (#5): recent request metadata, served on a reserved\n // path (never forwarded) and gated by the same auth as the tunnel above.\n if (!isReplay && url.pathname === '/__volter_inspect') {\n if (!(await this.inspectorAuthorized(request, attach))) {\n return new Response('Unauthorized', { status: 401, headers: corsHeaders(request) });\n }\n return Response.json(\n { tunnelId: attach.tunnelId, entries: this.inspect, replay: this.replayEnabled },\n { headers: corsHeaders(request) }\n );\n }\n\n // Replay a captured request (#10): re-issue it through the tunnel. Owner-only\n // (tunnel secret); only available when INSPECT_REPLAY is enabled.\n if (!isReplay && url.pathname === '/__volter_replay' && request.method === 'POST') {\n if (!this.replayEnabled) {\n return Response.json({ error: 'replay not enabled' }, { status: 404, headers: corsHeaders(request) });\n }\n if (!(await this.inspectorAuthorized(request, attach))) {\n return new Response('Unauthorized', { status: 401, headers: corsHeaders(request) });\n }\n const b = (await request.json().catch(() => ({}))) as { id?: string };\n const cap = b.id ? this.captures.get(b.id) : undefined;\n if (!cap) {\n return Response.json({ error: 'no such captured request' }, { status: 404, headers: corsHeaders(request) });\n }\n const replayUrl = new URL(`https://${attach.tunnelId}.${this.env.TUNNEL_DOMAIN}${cap.path}`);\n const synth = new Request(replayUrl.toString(), {\n method: cap.method,\n headers: cap.headers,\n body: cap.body ? b64decode(cap.body) : undefined,\n });\n return this.handleHttpRequest(synth, replayUrl, true);\n }\n\n const forwardPath = stripTokenParam(url.pathname + url.search);\n const forwardHeaders = forwardedRequestHeaders(request);\n const hasBody = request.method !== 'GET' && request.method !== 'HEAD';\n const bodyBytes = hasBody ? new Uint8Array(await request.arrayBuffer()) : null;\n\n // A DO WebSocket frame is capped at 1 MiB and base64 inflates ~33%; the whole\n // body goes in one `request` frame. Reject oversized bodies fast with 413\n // instead of letting ctl.send throw and the request hang to the 30s timeout.\n if (bodyBytes && bodyBytes.length > 750_000) {\n return new Response('Request body too large to tunnel (limit ~750KB)', {\n status: 413,\n headers: corsHeaders(request),\n });\n }\n\n // Meter + hard-cutoff: a request only relays if the account has budget.\n this.rawSinceFlush.requests = (this.rawSinceFlush.requests ?? 0) + 1;\n this.rawSinceFlush.bytes = (this.rawSinceFlush.bytes ?? 0) + (bodyBytes?.length ?? 0);\n if (!(await this.ensureBudget(CREDIT_WEIGHTS.request))) {\n const retry = this.retryAfterSeconds();\n const scope = this.rate && this.rate.day.remaining <= 0 ? 'day' : 'month';\n return Response.json(\n { error: 'quota_exceeded', scope, retryAfter: retry },\n {\n status: 429,\n headers: {\n ...corsHeaders(request),\n ...this.rateHeaders(),\n 'retry-after': String(retry),\n },\n }\n );\n }\n const rateHeaders = this.rateHeaders();\n\n const reqId = crypto.randomUUID();\n this.pushInspect(reqId, request.method, url.pathname);\n if (!isReplay && this.replayEnabled) {\n await this.captureForReplay(reqId, request.method, forwardPath, forwardHeaders, bodyBytes);\n }\n return await new Promise<Response>((resolve) => {\n const timer = setTimeout(() => {\n this.pendingHttp.delete(reqId);\n this.streaming.delete(reqId);\n this.finishInspect(reqId, 504, null);\n resolve(new Response('Tunnel timeout', { status: 504, headers: corsHeaders(request) }));\n }, 30000);\n this.pendingHttp.set(reqId, { resolve, timer, request, bootstrapCookie, rate: rateHeaders });\n try {\n sendFrame(ctl, {\n type: 'request',\n reqId,\n method: request.method,\n path: forwardPath,\n headers: forwardHeaders,\n body: bodyBytes?.length ? b64encode(bodyBytes) : null,\n });\n } catch {\n clearTimeout(timer);\n this.pendingHttp.delete(reqId);\n resolve(new Response('Tunnel send failed', { status: 502, headers: corsHeaders(request) }));\n }\n });\n }\n\n // \u2500\u2500 inbound browser WebSocket \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n private async handleBrowserWsUpgrade(request: Request, url: URL): Promise<Response> {\n const ctl = this.ctl();\n const attach = this.ctlAttach();\n if (!ctl || !attach?.registered) return new Response('Tunnel not connected', { status: 502 });\n\n if (attach.authRequired && this.env.JWT_SECRET) {\n const okWs = await validateWsAuth(request, url, this.env.JWT_SECRET, {\n tunnelId: attach.tunnelId,\n requireTid: this.env.REQUIRE_TID === 'true',\n });\n if (!okWs) {\n return new Response('Unauthorized', { status: 401 });\n }\n }\n\n // Meter + hard-cutoff (one charge per upgrade; relayed frames are not priced).\n this.rawSinceFlush.wsUpgrades = (this.rawSinceFlush.wsUpgrades ?? 0) + 1;\n if (!(await this.ensureBudget(CREDIT_WEIGHTS.wsUpgrade))) {\n return new Response('Account quota exceeded', {\n status: 429,\n headers: { ...this.rateHeaders(), 'retry-after': String(this.retryAfterSeconds()) },\n });\n }\n\n const connId = crypto.randomUUID();\n const { 0: client, 1: server } = new WebSocketPair();\n server.serializeAttachment({ role: 'browser', connId } satisfies BrowserAttach);\n this.ctx.acceptWebSocket(server, ['browser', `c:${connId}`]);\n\n const cleanPath = stripTokenParam(url.pathname + url.search);\n const forwardHeaders = forwardedRequestHeaders(request);\n\n // Await ws-ready BEFORE returning 101 \u2014 so the browser's socket only opens\n // once the local end is connected. This removes the message-buffering dance a single-process relay needs (it can't delay its upgrade).\n const ready = new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pendingUpgrades.delete(connId);\n reject(new Error('ws-upgrade timeout'));\n }, 15000);\n this.pendingUpgrades.set(connId, { resolve, reject, timer });\n });\n sendFrame(ctl, { type: 'ws-upgrade', connId, path: cleanPath, headers: forwardHeaders });\n try {\n await ready;\n } catch {\n try {\n server.close(1011, 'tunnel timeout');\n } catch {\n /* already gone */\n }\n return new Response('tunnel timeout', { status: 504 });\n }\n return new Response(null, { status: 101, webSocket: client });\n }\n\n // \u2500\u2500 hibernation handlers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> {\n const attach = ws.deserializeAttachment() as CtlAttach | BrowserAttach | null;\n\n // Raw browser frame \u2192 the untrusted, floodable direction. Meter it\n // SYNCHRONOUSLY (gate per frame on locally-held budget; only await a top-up\n // when exhausted) so an over-quota flood is cut off (1011) within a bounded\n // overshoot instead of being relayed unmetered while an async charge lands.\n if (attach?.role === 'browser') {\n const ctl = this.ctl();\n if (!ctl) return;\n if (!(await this.chargeMessage())) {\n try {\n ws.close(1011, 'Account quota exceeded');\n } catch {\n /* already gone */\n }\n return;\n }\n const binary = typeof data !== 'string';\n const bytes = binary ? new Uint8Array(data as ArrayBuffer) : new TextEncoder().encode(data as string);\n // A DO WS frame is capped at 1 MiB and base64 inflates ~33%; an oversized\n // browser frame would throw on ctl.send and escape the hibernation handler.\n if (bytes.length > 750_000) {\n try {\n ws.close(1009, 'frame too large to tunnel');\n } catch {\n /* already gone */\n }\n return;\n }\n try {\n sendFrame(ctl, { type: 'ws-message', connId: attach.connId, data: b64encode(bytes), binary });\n } catch {\n try {\n ws.close(1011, 'relay error');\n } catch {\n /* already gone */\n }\n }\n return;\n }\n\n let msg: Record<string, unknown>;\n try {\n msg = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data));\n } catch {\n return;\n }\n // An accepted socket may send only register until it becomes the owner. A\n // superseded connector can have already-queued frames when its close lands;\n // ignore them instead of billing or dispatching them as successor traffic.\n if ((!attach?.registered || attach.superseded) && msg.type !== 'register') return;\n\n // Control-channel message (responses, chunks, ws relays from the tunnel\n // client) \u2192 count for batch metering (fire-and-forget, so the lease round-trip\n // never convoys response delivery under concurrency), then dispatch.\n this.msgsSinceFlush++;\n if (this.msgsSinceFlush >= MSG_FLUSH_EVERY) void this.flushMessages().catch(() => {});\n await this.onControlMessage(ws, msg);\n }\n\n async webSocketClose(ws: WebSocket): Promise<void> {\n const attach = ws.deserializeAttachment() as CtlAttach | BrowserAttach | null;\n if (attach?.role === 'browser') {\n const ctl = this.ctl();\n if (ctl) {\n try {\n sendFrame(ctl, { type: 'ws-close', connId: attach.connId });\n } catch {\n /* control gone */\n }\n }\n return;\n }\n // Replacement transfers the account lease and the TunnelDO's local metering\n // state to the successor. The superseded socket's delayed close is therefore\n // deliberately inert: settling or disconnecting here would clobber traffic\n // already owned by the new registration.\n if (attach?.role === 'ctl' && (!attach.registered || attach.superseded)) return;\n\n // Settle metering: commit unflushed usage + tunnel-seconds and return any\n // unconsumed lease to the account (recovers budget stranded by hibernation).\n if (attach?.slug) {\n // Fold any unflushed relayed-message ops into the final settlement.\n this.consumedSinceCommit += this.msgsSinceFlush * CREDIT_WEIGHTS.message;\n this.msgsSinceFlush = 0;\n const seconds = attach.openedAt ? Math.max(0, (Date.now() - attach.openedAt) / 1000) : 0;\n await this.accountRpc(\n '/close',\n {\n slug: attach.slug,\n tunnelId: attach.tunnelId,\n regId: attach.regId,\n consumed: this.consumedSinceCommit,\n seconds,\n raw: this.takeRaw(),\n },\n attach.slug\n );\n this.consumedSinceCommit = 0;\n this.budget = 0;\n\n // Idle clock starts now: stamp lastSeenAt at disconnect so the reclaim TTL\n // counts from when the owner went away, not from when it first connected\n // (DECISIONS D5). Only the current owner may move its own clock.\n const res = await this.ctx.storage.get<Reservation>('reservation');\n if (res && res.ownerSlug === attach.slug) {\n await this.ctx.storage.put('reservation', {\n ...res,\n lastSeenAt: Date.now(),\n } satisfies Reservation);\n }\n }\n\n this.disconnectTraffic('Tunnel disconnected');\n }\n\n /** Tear down traffic bound to the current connector. Replacement cannot keep\n * in-flight correlations because their responses belong to the old socket. */\n private disconnectTraffic(reason: string): void {\n for (const b of this.ctx.getWebSockets('browser')) {\n try {\n (b as WebSocket).close(1001, reason);\n } catch {\n /* ignore */\n }\n }\n for (const [rid, p] of this.pendingHttp) {\n clearTimeout(p.timer);\n this.finishInspect(rid, 502, null);\n try {\n p.resolve(new Response(reason, { status: 502 }));\n } catch {\n /* already resolved */\n }\n }\n this.pendingHttp.clear();\n for (const [, s] of this.streaming) {\n clearTimeout(s.idle);\n try {\n s.controller.error(new Error(reason));\n } catch {\n /* already closed */\n }\n }\n this.streaming.clear();\n for (const [, p] of this.pendingUpgrades) {\n clearTimeout(p.timer);\n p.reject(new Error(reason));\n }\n this.pendingUpgrades.clear();\n }\n\n /** Idle watchdog for a streaming response \u2014 if the client stalls without sending\n * response-end, error the stream and tell the client to abort. Reset per chunk. */\n private armStreamIdle(reqId: string): ReturnType<typeof setTimeout> {\n return setTimeout(() => {\n const s = this.streaming.get(reqId);\n if (!s) return;\n this.streaming.delete(reqId);\n try {\n s.controller.error(new Error('stream idle timeout'));\n } catch {\n /* already closed */\n }\n const ctl = this.ctl();\n if (ctl) {\n try {\n sendFrame(ctl, { type: 'request-abort', reqId });\n } catch {\n /* control gone */\n }\n }\n }, 120000);\n }\n\n async webSocketError(ws: WebSocket): Promise<void> {\n await this.webSocketClose(ws);\n }\n\n // \u2500\u2500 control protocol \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n private async onControlMessage(ws: WebSocket, msg: Record<string, unknown>): Promise<void> {\n const type = msg.type as string;\n\n if (type === 'register') {\n // Resolve the metering account from the presented secret. An api token\n // (`vta_<slug>_\u2026`) names + proves its account; the legacy shared\n // TUNNEL_SECRET maps to the built-in internal account for back-compat.\n // FAIL CLOSED: when TUNNEL_SECRET is unset there is no legacy path at all\n // (an api token is required) \u2014 an unset secret must never become an open\n // relay that bills the internal account for anyone.\n const secret = String(msg.secret ?? '');\n const internal = this.env.INTERNAL_ACCOUNT || 'volter-internal';\n const parsed = parseToken(secret);\n let slug: string;\n let legacy = false;\n let tokenHash: string | undefined;\n if (parsed?.kind === 'api') {\n slug = parsed.slug!;\n tokenHash = await hashToken(secret);\n } else if (this.env.TUNNEL_SECRET && safeEqual(secret, this.env.TUNNEL_SECRET)) {\n slug = internal;\n legacy = true;\n } else {\n sendFrame(ws, { type: 'error', message: 'Invalid tunnel secret' });\n ws.close(4003, 'Invalid tunnel secret');\n return;\n }\n\n // \u2500\u2500 reserved-id ownership: lazy reclaim-on-contention (DECISIONS D5) \u2500\u2500\u2500\u2500\u2500\u2500\n // This DO *is* the tunnelId, so a single 'reservation' record is its owner.\n // A different account may take a reserved id only once the current owner has\n // been idle past RESERVATION_IDLE_TTL_DAYS; while the owner is active a\n // foreign claim is refused so the id stays stable. lastSeenAt is refreshed\n // on (re)register (below, after authorize) and on disconnect.\n const ttlMs = envNum(this.env.RESERVATION_IDLE_TTL_DAYS, 60) * 86_400_000;\n const reservation = await this.ctx.storage.get<Reservation>('reservation');\n const verdict = reservationDecision(reservation, slug, Date.now(), ttlMs);\n if (verdict === 'reject') {\n sendFrame(ws, {\n type: 'error',\n message: `Tunnel ID '${String(msg.tunnelId ?? '')}' is reserved by another account.`,\n });\n ws.close(4002, 'Tunnel ID reserved');\n return;\n }\n\n const credential = legacy\n ? undefined\n : await this.registryRpc<DeviceCredentialResult>('/resolve-device-credential', { slug, tokenHash });\n if (!legacy && credential?.ok !== true) {\n sendFrame(ws, { type: 'error', fatal: true, message: 'Tunnel rejected: credential is invalid or revoked.' });\n ws.close(4003, 'Invalid tunnel secret');\n return;\n }\n\n const others = this.ctx\n .getWebSockets('ctl')\n .filter((w) => w !== ws && (w.deserializeAttachment() as CtlAttach | null)?.registered);\n if (others.length) {\n if (msg.replace) {\n const incomingRank = credential?.ok === true ? `${credential.createdAt}\\u0000${credential.id}` : undefined;\n const incomingCredentialId = credential?.ok === true ? credential.id : undefined;\n let newerOwner: WebSocket | undefined;\n if (incomingRank !== undefined) {\n for (const candidate of others) {\n const attached = candidate.deserializeAttachment() as CtlAttach | null;\n if (\n attached?.credentialId === undefined ||\n attached.credentialCreatedAt === undefined ||\n attached.credentialId === incomingCredentialId ||\n `${attached.credentialCreatedAt}\\u0000${attached.credentialId}` <= incomingRank\n )\n continue;\n const ownerCredential = await this.registryRpc<DeviceCredentialResult>('/resolve-device-credential', {\n slug,\n credentialId: attached.credentialId,\n });\n if (ownerCredential?.ok === true) {\n newerOwner = candidate as WebSocket;\n break;\n }\n }\n }\n if (newerOwner !== undefined) {\n sendFrame(ws, {\n type: 'error',\n fatal: true,\n message: `Tunnel ID '${String(msg.tunnelId ?? '')}' is active on a newer authenticated device. Stop that connector or revoke its device token before taking over.`,\n });\n ws.close(4002, 'Newer device owns tunnel');\n return;\n }\n } else {\n sendFrame(ws, {\n type: 'error',\n message: `Tunnel ID '${msg.tunnelId}' is already in use by another client. Pass { replace: true } to take over.`,\n });\n ws.close(4002, 'Tunnel ID already in use');\n return;\n }\n }\n\n // Prefer the ?id= captured at accept (authoritative \u2014 it's how the Worker\n // routed to this DO); fall back to msg.tunnelId, then a random id.\n const existing = ws.deserializeAttachment() as CtlAttach | null;\n const tunnelId = existing?.tunnelId || (msg.tunnelId as string) || crypto.randomUUID().slice(0, 8);\n\n // Unique registration nonce \u2014 lets the account tell this socket's ledger\n // entry apart from a later replacement's (fixes the replace race).\n const regId = crypto.randomUUID();\n const authRequired = msg.authRequired !== false;\n const ba = msg.basicAuth as { user?: string; pass?: string } | undefined;\n const basicAuthHash = ba?.user && ba.pass ? await hashToken(`${ba.user}:${ba.pass}`) : undefined;\n let openedAt = Date.now();\n for (const old of others) {\n const oldAttach = old.deserializeAttachment() as CtlAttach;\n if (oldAttach.openedAt !== undefined) openedAt = Math.min(openedAt, oldAttach.openedAt);\n }\n\n // Authorize against the account: valid token, active, under the concurrent\n // cap, and with budget remaining. A rejection closes the control socket.\n const auth = await this.accountRpc<AuthorizeResult>('/authorize', {\n slug,\n legacy,\n tokenHash,\n tunnelId,\n regId,\n });\n if (!auth || !auth.ok) {\n const reason = auth?.reason ?? 'account unavailable';\n const code = reason === 'badToken' ? 4003 : 4029;\n const message =\n reason === 'reservationCap'\n ? `Tunnel rejected: reservation capacity ${auth?.reservedTunnels?.length ?? 0}/${auth?.reservedMax ?? 0}. Reserved ids: ${auth?.reservedTunnels?.join(', ') || 'none'}. Run \"volter-tunnel reservations\" to inspect or \"volter-tunnel release <id>\" to free one.`\n : reason === 'badToken'\n ? 'Tunnel rejected: credential is invalid or revoked. On an owner-authenticated device, run \"volter-tunnel tokens\" and restore this host token, or sign in again on the host.'\n : `Tunnel rejected: ${reason}`;\n sendFrame(ws, { type: 'error', message });\n ws.close(code, reason);\n return;\n }\n\n // The account now recognizes regId as the owner. Transfer the live relay\n // state before the next await: mark predecessors inert first, fail their\n // in-flight traffic, then publish the successor as registered. Visitors\n // therefore cannot observe the account's new regId while ctl() still\n // selects an old registration.\n if (others.length) {\n for (const old of others) {\n const oldAttach = old.deserializeAttachment() as CtlAttach;\n old.serializeAttachment({ ...oldAttach, registered: false, superseded: true } satisfies CtlAttach);\n }\n this.disconnectTraffic('Tunnel replaced');\n for (const old of others) (old as WebSocket).close(4001, 'Replaced by new client');\n }\n ws.serializeAttachment({\n role: 'ctl',\n registered: true,\n authRequired,\n tunnelId,\n slug,\n leaseChunk: auth.leaseChunk ?? 50,\n openedAt,\n regId,\n legacy,\n tokenHash,\n ...(credential?.ok === true ? { credentialCreatedAt: credential.createdAt, credentialId: credential.id } : {}),\n basicAuthHash,\n } satisfies CtlAttach);\n\n // Account is authorized \u2192 claim/refresh/reclaim this tunnelId for it and\n // (re)start the idle clock. Same write for all three verdicts (the only\n // rejected case already returned above).\n await this.ctx.storage.put('reservation', {\n ownerSlug: slug,\n lastSeenAt: Date.now(),\n tunnelId,\n } satisfies Reservation);\n\n // Reclaimed from another account past its idle TTL \u2192 free the reserved-id\n // slot on the previous owner so its count reflects the loss (#1/#3).\n if (verdict === 'reclaim' && reservation && reservation.ownerSlug !== slug) {\n await this.accountRpc('/release-id', { tunnelId }, reservation.ownerSlug);\n }\n\n // Seed the rate snapshot + quota level so headers work from the first\n // request and we don't re-announce the level the client already sees.\n this.rate = auth.rate ?? null;\n this.lastQuotaLevel = auth.rate?.level ?? null;\n const url = `https://${tunnelId}.${this.env.TUNNEL_DOMAIN}`;\n sendFrame(ws, {\n type: 'registered',\n tunnelId,\n url,\n account: auth.rate ? { slug, day: auth.rate.day, month: auth.rate.month, level: auth.rate.level } : undefined,\n });\n return;\n }\n\n if (type === 'response') {\n const pending = this.pendingHttp.get(msg.reqId as string);\n if (!pending) return;\n clearTimeout(pending.timer);\n this.pendingHttp.delete(msg.reqId as string);\n const headers = buildResponseHeaders(\n (msg.headers as Record<string, string>) || {},\n pending.request,\n pending.bootstrapCookie,\n this.headerRules\n );\n for (const [k, v] of Object.entries(pending.rate)) headers.set(k, v);\n // A malformed body must resolve the request (502), not throw out of the\n // hibernation handler and leave it hung (timer already cleared above).\n let body: Uint8Array | null;\n try {\n body = msg.body ? b64decode(msg.body as string) : null;\n } catch {\n this.finishInspect(msg.reqId as string, 502, null);\n pending.resolve(new Response('Malformed tunnel response', { status: 502, headers }));\n return;\n }\n this.finishInspect(msg.reqId as string, (msg.status as number) || 200, body ? body.length : 0);\n // Workers strip content-encoding from a constructed Response and assume the\n // body is decoded; a compressed upstream body then ships headerless and the\n // consumer reads gzip bytes as text. encodeBody manual passes both through.\n pending.resolve(new Response(body, { status: (msg.status as number) || 200, headers, ...(headers.has('content-encoding') ? { encodeBody: 'manual' as const } : {}) } as ResponseInit));\n return;\n }\n\n if (type === 'response-start') {\n const pending = this.pendingHttp.get(msg.reqId as string);\n if (!pending) return;\n clearTimeout(pending.timer);\n this.pendingHttp.delete(msg.reqId as string);\n const reqId = msg.reqId as string;\n // Streamed: record status + latency-to-first-byte now; body size is unknown\n // (chunks stream out), so leave bytes null.\n this.finishInspect(reqId, (msg.status as number) || 200, null);\n const headers = buildResponseHeaders(\n (msg.headers as Record<string, string>) || {},\n pending.request,\n pending.bootstrapCookie,\n this.headerRules\n );\n for (const [k, v] of Object.entries(pending.rate)) headers.set(k, v);\n const status = (msg.status as number) || 200;\n // Fetch forbids a body for these statuses. Constructing a Response with a\n // ReadableStream throws after the pending entry has been removed, leaving\n // the visitor request unresolved forever. HEAD is bodyless by contract too.\n if (pending.request.method === 'HEAD' || status === 204 || status === 205 || status === 304) {\n pending.resolve(new Response(null, { status, headers }));\n return;\n }\n const self = this;\n const readable = new ReadableStream<Uint8Array>({\n start(controller) {\n self.streaming.set(reqId, { controller, idle: self.armStreamIdle(reqId) });\n },\n cancel() {\n const s = self.streaming.get(reqId);\n if (s) clearTimeout(s.idle);\n self.streaming.delete(reqId);\n const ctl = self.ctl();\n if (ctl) {\n try {\n sendFrame(ctl, { type: 'request-abort', reqId });\n } catch {\n /* control gone */\n }\n }\n },\n });\n pending.resolve(new Response(readable, { status, headers, ...(headers.has('content-encoding') ? { encodeBody: 'manual' as const } : {}) } as ResponseInit));\n return;\n }\n\n if (type === 'response-chunk') {\n const s = this.streaming.get(msg.reqId as string);\n if (s) {\n try {\n s.controller.enqueue(b64decode(msg.data as string));\n clearTimeout(s.idle);\n s.idle = this.armStreamIdle(msg.reqId as string);\n } catch {\n clearTimeout(s.idle);\n this.streaming.delete(msg.reqId as string);\n }\n }\n return;\n }\n\n if (type === 'response-end') {\n const s = this.streaming.get(msg.reqId as string);\n if (s) {\n clearTimeout(s.idle);\n this.streaming.delete(msg.reqId as string);\n try {\n s.controller.close();\n } catch {\n /* already closed */\n }\n }\n return;\n }\n\n if (type === 'ws-ready') {\n const p = this.pendingUpgrades.get(msg.connId as string);\n if (p) {\n clearTimeout(p.timer);\n this.pendingUpgrades.delete(msg.connId as string);\n p.resolve();\n }\n return;\n }\n\n if (type === 'ws-error') {\n const p = this.pendingUpgrades.get(msg.connId as string);\n if (p) {\n clearTimeout(p.timer);\n this.pendingUpgrades.delete(msg.connId as string);\n p.reject(new Error((msg.error as string) || 'Tunnel error'));\n }\n return;\n }\n\n if (type === 'ws-message') {\n // control \u2192 browser\n const arr = this.ctx.getWebSockets(`c:${msg.connId}`);\n const browser = arr.length ? (arr[0] as WebSocket) : null;\n if (browser) {\n try {\n const bytes = b64decode(msg.data as string);\n browser.send(msg.binary ? bytes : new TextDecoder().decode(bytes));\n } catch {\n /* malformed frame \u2014 drop it, keep the relay alive */\n }\n }\n return;\n }\n\n if (type === 'ws-close') {\n const arr = this.ctx.getWebSockets(`c:${msg.connId}`);\n const browser = arr.length ? (arr[0] as WebSocket) : null;\n if (browser) {\n const raw = msg.code as number | undefined;\n const code = raw && raw >= 1000 && raw <= 4999 && raw !== 1005 && raw !== 1006 ? raw : 1000;\n try {\n browser.close(code, truncateReason(msg.reason));\n } catch {\n /* ignore */\n }\n }\n return;\n }\n }\n}\n",
|
|
45
|
+
"routing": "/**\n * Worker entry \u2014 routes every request to the Durable Object for its tunnelId.\n *\n * - Control channel: `\u2026/ws?id=<tunnelId>` (WS upgrade) \u2192 DO(idFromName(id)).\n * - Data (HTTP or browser WS): `Host: <tunnelId>.<domain>` \u2192 DO(idFromName(tunnelId)).\n * - Apex (no subdomain): /api/status and the /__volter_auth cookie bootstrap.\n *\n * State is sharded per tunnel by construction: both the client's control socket\n * and inbound requests for a tunnel deterministically map to the same DO.\n */\nimport { type Env, getTunnelIdFromHost, handleCookieBootstrap } from './auth';\nimport { TunnelDO } from './tunnel-do';\nimport { AccountDO } from './account-do';\nimport { RegistryDO } from './registry-do';\nimport { docsPage, fontResponse, htmlResponse, landingPage } from './pages';\n\nexport { TunnelDO, AccountDO, RegistryDO };\n\nfunction routeToDO(env: Env, name: string, request: Request): Promise<Response> {\n const id = env.TUNNEL.idFromName(name);\n return env.TUNNEL.get(id).fetch(request);\n}\n\n/** Subdomain labels that are NOT tunnels \u2014 they serve the apex front door /\n * management plane instead (so `www.<domain>` shows the landing page rather\n * than being a tunnel named \"www\", and nobody can reserve a tunnel that shadows\n * them). Override via RESERVED_HOSTS (comma-separated); default `www`. */\nfunction isReservedHost(env: Env, label: string): boolean {\n const raw = (env.RESERVED_HOSTS ?? 'www').toLowerCase();\n return raw\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n .includes(label.toLowerCase());\n}\n\nexport default {\n async fetch(request: Request, env: Env): Promise<Response> {\n const url = new URL(request.url);\n\n // Control channel \u2014 host-independent; the client names its DO via ?id=.\n if (url.pathname === '/ws') {\n const id = url.searchParams.get('id');\n if (!id) return new Response('missing tunnel id', { status: 400 });\n return routeToDO(env, id, request);\n }\n\n // Direct tunnel addressing for hosts without a per-tunnel subdomain \u2014 e.g.\n // *.workers.dev (Cloudflare rejects a foreign Host) or local testing.\n // `?__tunnel=<id>` is stripped before the request is forwarded downstream.\n // Gated to direct hosts only: on the production wildcard domain, tunnels must\n // be reached by their own subdomain so per-tunnel cookie/host isolation holds.\n const host = request.headers.get('host') || '';\n const isDirectHost =\n host.endsWith('.workers.dev') ||\n host.startsWith('127.0.0.1') ||\n host.startsWith('localhost') ||\n host.startsWith('0.0.0.0');\n const override = url.searchParams.get('__tunnel');\n if (override && isDirectHost) return routeToDO(env, override, request);\n\n const tunnelId = getTunnelIdFromHost(request.headers.get('host'), env.TUNNEL_DOMAIN);\n\n // On a tunnel subdomain EVERYTHING forwards to the tunnel \u2014 paths like\n // /admin, /signup, /report are management routes only on the apex, never on a\n // tunnel host (otherwise a tunneled app's own /signup page would be hijacked).\n // Reserved labels (e.g. `www`) are NOT tunnels \u2014 they fall through to the apex\n // front door below so `www.<domain>` serves the landing page.\n if (tunnelId && !isReservedHost(env, tunnelId)) return routeToDO(env, tunnelId, request);\n\n // Apex (no subdomain) or a reserved host: management plane (admin), self-serve\n // signup (#2,\n // unauthenticated \u2014 identity via GitHub), and abuse reports (#3). All share\n // the single RegistryDO.\n if (\n url.pathname === '/admin' ||\n url.pathname.startsWith('/admin/') ||\n url.pathname === '/signup' ||\n url.pathname.startsWith('/signup/') ||\n url.pathname === '/waitlist' ||\n url.pathname === '/report' ||\n url.pathname === '/me' ||\n url.pathname.startsWith('/me/')\n ) {\n const id = env.REGISTRY.idFromName('registry');\n return env.REGISTRY.get(id).fetch(request);\n }\n if (url.pathname.startsWith('/__volter_auth')) {\n return handleCookieBootstrap(request, url, env);\n }\n if (url.pathname === '/api/status') {\n return Response.json({ ok: true, relay: 'cloudflare-do' });\n }\n // Public front door (apex only): marketing landing + waitlist form, and docs.\n if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {\n return htmlResponse(landingPage(env.TUNNEL_DOMAIN));\n }\n if (request.method === 'GET' && (url.pathname === '/docs' || url.pathname === '/docs/')) {\n return htmlResponse(docsPage(env.TUNNEL_DOMAIN));\n }\n if (request.method === 'GET' && url.pathname.startsWith('/fonts/')) {\n const font = fontResponse(url.pathname.slice('/fonts/'.length));\n if (font) return font;\n }\n return new Response('volter-tunnel (cloudflare)', { status: 200 });\n },\n};\n",
|
|
46
|
+
"credentials": "/**\n * Metering primitives shared across the Worker, RegistryDO, AccountDO and TunnelDO.\n *\n * Tokens are opaque, prefixed, and carry the account slug so the data plane can\n * route straight to the strongly-consistent AccountDO without a global index \u2014\n * the same trick Cloudflare Tunnel's own connector token uses (it embeds the\n * account + tunnel id). Only the SHA-256 of a token is ever persisted; the\n * plaintext is shown once at creation.\n *\n * vtr_<rand> root token (global; manages accounts + limits)\n * vts_<slug>_<rand> service token (per account; mints/revokes api tokens)\n * vta_<slug>_<rand> api token (per account; the \"tunnel secret\")\n *\n * \"Credits\" are the single blended spend unit. requests / ws-upgrades / bytes /\n * tunnel-seconds each convert to credits via CREDIT_WEIGHTS, and daily+monthly\n * limits are enforced on credits. Defaults charge per request/upgrade only \u2014\n * bytes are free egress on Workers, and tunnel-seconds are gated by the\n * concurrent-tunnel cap rather than priced \u2014 but every weight is tunable.\n */\n\nexport type TokenKind = 'root' | 'service' | 'api';\n\nexport interface ParsedToken {\n kind: TokenKind;\n /** Account slug \u2014 present for service/api tokens, undefined for root. */\n slug?: string;\n}\n\nconst SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;\n\n/** Whether a string is a valid account slug (DNS-label-ish, no underscores). */\nexport function isValidSlug(slug: string): boolean {\n return SLUG_RE.test(slug);\n}\n\n/**\n * Parse a token string into its kind + slug. Returns null for anything that\n * doesn't match the `vt*` grammar (e.g. a legacy shared secret). Underscore is\n * the field separator and never appears inside a slug or random part, so the\n * split is unambiguous.\n */\nexport function parseToken(token: string): ParsedToken | null {\n if (typeof token !== 'string' || token.length < 8) return null;\n const parts = token.split('_');\n if (parts[0] === 'vtr') {\n return parts.length === 2 && parts[1] ? { kind: 'root' } : null;\n }\n if (parts[0] === 'vts' || parts[0] === 'vta') {\n if (parts.length !== 3) return null;\n const slug = parts[1]!;\n if (!isValidSlug(slug) || !parts[2]) return null;\n return { kind: parts[0] === 'vts' ? 'service' : 'api', slug };\n }\n return null;\n}\n\nconst BASE62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n/** Cryptographically-random base62 string of `len` chars. */\nexport function randomBase62(len = 40): string {\n const bytes = new Uint8Array(len);\n crypto.getRandomValues(bytes);\n let s = '';\n for (let i = 0; i < len; i++) s += BASE62[bytes[i]! % 62];\n return s;\n}\n\n/** Mint a fresh token of the given kind (root tokens carry no slug). */\nexport function mintToken(kind: TokenKind, slug?: string): string {\n if (kind === 'root') return `vtr_${randomBase62()}`;\n const prefix = kind === 'service' ? 'vts' : 'vta';\n return `${prefix}_${slug}_${randomBase62()}`;\n}\n\n/** SHA-256 hex digest \u2014 the only form of a token we persist. */\nexport async function hashToken(token: string): Promise<string> {\n const data = new TextEncoder().encode(token);\n const digest = await crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(digest);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) hex += bytes[i]!.toString(16).padStart(2, '0');\n return hex;\n}\n\n/** Constant-time hex-string compare (both are fixed-length SHA-256 hex). */\nexport function safeEqualHex(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let r = 0;\n for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return r === 0;\n}\n\n/** Last 4 chars of a token \u2014 stored for display (\"\u2026aB3x\") without the secret. */\nexport function last4(token: string): string {\n return token.slice(-4);\n}\n\n// \u2500\u2500 credit accounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreditWeights {\n request: number;\n wsUpgrade: number;\n /** Per relayed DO message (response chunk or WebSocket frame). */\n message: number;\n byte: number;\n second: number;\n}\n\n/**\n * Credits are denominated in **ops** \u2014 the universal Cloudflare billable unit.\n * Every event that wakes/charges the relay's Durable Object counts as 1 op:\n * an HTTP request, a WS upgrade, and each relayed message (a streamed response\n * chunk or a WebSocket frame). Metering messages \u2014 not just the opening request\n * \u2014 is what makes a dollar cap actually hold for streaming/WS-heavy tunnels.\n *\n * Dollars = credits \u00d7 COST_PER_OP_USD. Bytes/seconds are 0 (egress is free on\n * Workers; idle duration is ~0 under hibernation and bounded by the concurrency\n * cap), but the message weight captures the per-frame DO cost that does bill.\n */\nexport const CREDIT_WEIGHTS: CreditWeights = {\n request: 1,\n wsUpgrade: 1,\n message: 1,\n byte: 0,\n second: 0,\n};\n\n/**\n * Conservative estimate of the Cloudflare cost of one op, in USD. An op bills\n * roughly: 1 Worker request + 1\u20132 DO requests + a slice of DO active duration,\n * plus amortized unmetered overhead (the AccountDO lease/authorize/close RPCs,\n * and billed-but-free paths like OPTIONS/502/429). Real Worker+DO request prices\n * are ~$0.15\u20130.45/M, so $1/M carries ~2\u20133\u00d7 headroom over the direct request cost\n * to absorb that overhead. This is the ONLY money\u2194ops knob \u2014 RAISE it (and the\n * dollar caps stay honest, op-limits auto-shrink) once a real CF invoice lets you\n * calibrate; lower it only if you're confident it over-charges.\n */\nexport const COST_PER_OP_USD = 0.000001;\n\n/** Dollar amount \u2192 integer op-credits (for setting limits in money). */\nexport function usdToCredits(usd: number): number {\n return Math.round(usd / COST_PER_OP_USD);\n}\n/** Op-credits \u2192 dollars, rounded to cents (for display). */\nexport function creditsToUsd(credits: number): number {\n return Math.round(credits * COST_PER_OP_USD * 100) / 100;\n}\n\nexport interface UsageDelta {\n requests?: number;\n wsUpgrades?: number;\n messages?: number;\n bytes?: number;\n seconds?: number;\n}\n\n/** Convert a raw usage delta into integer credits using the given weights. */\nexport function toCredits(d: UsageDelta, w: CreditWeights = CREDIT_WEIGHTS): number {\n const raw =\n (d.requests ?? 0) * w.request +\n (d.wsUpgrades ?? 0) * w.wsUpgrade +\n (d.messages ?? 0) * w.message +\n (d.bytes ?? 0) * w.byte +\n (d.seconds ?? 0) * w.second;\n return Math.ceil(raw);\n}\n\n// \u2500\u2500 period keys (UTC) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** UTC day bucket, e.g. \"2026-06-23\". */\nexport function dayKey(now: Date): string {\n return now.toISOString().slice(0, 10);\n}\n/** UTC month bucket, e.g. \"2026-06\". */\nexport function monthKey(now: Date): string {\n return now.toISOString().slice(0, 7);\n}\n",
|
|
47
|
+
"auth": "/**\n * Auth + header helpers for the Cloudflare tunnel relay.\n *\n * Implements the JWT/cookie/CORS/CSP logic on the Fetch API + `jose` (WebCrypto-based; `jsonwebtoken` does not run on Workers).\n */\nimport { type JWTPayload, jwtVerify } from 'jose';\nimport type { MeteringEnv } from './metering-types';\n\nexport interface Env extends MeteringEnv {\n TUNNEL: DurableObjectNamespace;\n TUNNEL_DOMAIN: string;\n TUNNEL_SECRET: string;\n JWT_SECRET: string;\n /** When 'true', a data-plane JWT MUST carry a `tid` claim matching the tunnel\n * (full per-tunnel isolation). Default: a JWT with a `tid` is still bound to\n * that tunnel, but a JWT without one is accepted on any tunnel (shared SSO). */\n REQUIRE_TID?: string;\n /** Idle days before a reserved tunnelId may be reclaimed by another account on\n * contention (DECISIONS D5). Default 60. */\n RESERVATION_IDLE_TTL_DAYS?: string;\n /** Optional JSON response-header rewrite rules, applied on top of the built-in\n * iframe strip: `{\"set\":{\"x-foo\":\"bar\"},\"remove\":[\"x-baz\"]}`. */\n RESPONSE_HEADER_RULES?: string;\n /** Per-tunnel request-rate burst limit (#4): sustained requests/sec. 0/unset =\n * disabled. The daily/monthly credit caps remain the primary fair-use limit. */\n BURST_RPS?: string;\n /** Token-bucket capacity for BURST_RPS (max instantaneous burst). Default 2\u00d7RPS. */\n BURST_SIZE?: string;\n /** Inspector replay + persisted history (#10): 'true'/'1' captures request\n * detail to DO storage (survives hibernation) and enables /__volter_replay. */\n INSPECT_REPLAY?: string;\n /** Max captured requests retained for replay (default 50). */\n INSPECT_MAX?: string;\n /** Max captured request-body bytes stored per request (default 65536). */\n INSPECT_BODY_MAX?: string;\n /** Comma-separated subdomain labels that serve the apex front door / management\n * plane instead of being tunnels (e.g. `www`). Default: `www`. */\n RESERVED_HOSTS?: string;\n}\n\n/**\n * Extract the tunnelId from a Host header. Port-tolerant.\n *\n * `<id>.<domain>` \u2192 `<id>`, and \u2014 wildcard support (P1 #9) \u2014 `*.<id>.<domain>`\n * also \u2192 `<id>`: every label under a reserved id routes to that id's tunnel.\n * Tunnel ids are single DNS labels (no dots), so the id is always the label\n * adjacent to the base domain (the rightmost label of the subdomain portion);\n * the leading labels are the wildcard prefix, left intact on the forwarded Host\n * so the tunneled app can sub-route on them.\n */\nexport function getTunnelIdFromHost(host: string | null, domain: string): string | null {\n if (!host) return null;\n const hostname = host.split(':')[0];\n const suffix = '.' + domain;\n if (hostname.endsWith(suffix)) {\n const sub = hostname.slice(0, -suffix.length);\n return sub.slice(sub.lastIndexOf('.') + 1) || null;\n }\n return null;\n}\n\nexport function parseCookies(cookieHeader: string | null): Map<string, string> {\n const cookies = new Map<string, string>();\n if (!cookieHeader) return cookies;\n for (const pair of cookieHeader.split(';')) {\n const idx = pair.indexOf('=');\n if (idx < 0) continue;\n cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());\n }\n return cookies;\n}\n\n/** Verify a JWT signature; return its payload (or null on failure). */\nasync function verifyToken(token: string, secret: string): Promise<JWTPayload | null> {\n try {\n const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), { algorithms: ['HS256'] });\n return payload;\n } catch {\n return null;\n }\n}\n\n/** Boolean wrapper (the cookie-bootstrap path doesn't need the payload). */\nasync function verify(token: string, secret: string): Promise<boolean> {\n return (await verifyToken(token, secret)) !== null;\n}\n\n/** Per-tunnel binding check. A token with a `tid` claim is bound to that tunnel;\n * a token without one is accepted on any tunnel unless `requireTid`. */\nfunction tidOk(payload: JWTPayload, tunnelId: string | undefined, requireTid: boolean): boolean {\n const tid = typeof payload.tid === 'string' ? payload.tid : undefined;\n if (tid !== undefined) return !tunnelId || tid === tunnelId;\n return !requireTid;\n}\n\nexport interface AuthResult {\n token: string;\n source: 'header' | 'query' | 'cookie';\n}\n\nexport interface AuthOpts {\n /** Tunnel this request is for \u2014 a `tid`-bound token must match it. */\n tunnelId?: string;\n /** Reject tokens that carry no `tid` claim (full per-tunnel isolation). */\n requireTid?: boolean;\n}\n\n/** Validate auth from an HTTP request: Bearer header \u2192 ?__volter_token= \u2192 __volter_auth cookie. */\nexport async function validateAuth(\n request: Request,\n url: URL,\n jwtSecret: string,\n opts: AuthOpts = {}\n): Promise<AuthResult | null> {\n if (!jwtSecret) return null;\n const ok = (p: JWTPayload | null) => !!p && tidOk(p, opts.tunnelId, !!opts.requireTid);\n\n const authHeader = request.headers.get('authorization');\n if (authHeader?.startsWith('Bearer ')) {\n const token = authHeader.slice(7);\n return ok(await verifyToken(token, jwtSecret)) ? { token, source: 'header' } : null;\n }\n\n const queryToken = url.searchParams.get('__volter_token');\n if (queryToken) {\n return ok(await verifyToken(queryToken, jwtSecret)) ? { token: queryToken, source: 'query' } : null;\n }\n\n const cookieToken = parseCookies(request.headers.get('cookie')).get('__volter_auth');\n if (cookieToken) {\n return ok(await verifyToken(cookieToken, jwtSecret)) ? { token: cookieToken, source: 'cookie' } : null;\n }\n\n return null;\n}\n\n/** Validate auth for a WebSocket upgrade: ?__volter_token= query param \u2192 cookie. */\nexport async function validateWsAuth(\n request: Request,\n url: URL,\n jwtSecret: string,\n opts: AuthOpts = {}\n): Promise<boolean> {\n if (!jwtSecret) return false;\n const ok = (p: JWTPayload | null) => !!p && tidOk(p, opts.tunnelId, !!opts.requireTid);\n const queryToken = url.searchParams.get('__volter_token');\n if (queryToken) return ok(await verifyToken(queryToken, jwtSecret));\n const cookieToken = parseCookies(request.headers.get('cookie')).get('__volter_auth');\n if (cookieToken) return ok(await verifyToken(cookieToken, jwtSecret));\n return false;\n}\n\n/** Remove `?__volter_token=` from a path+query string (don't leak the token downstream). */\nexport function stripTokenParam(pathWithQuery: string): string {\n try {\n const u = new URL(pathWithQuery, 'http://placeholder');\n u.searchParams.delete('__volter_token');\n u.searchParams.delete('__tunnel');\n const search = u.searchParams.toString();\n return u.pathname + (search ? '?' + search : '');\n } catch {\n return pathWithQuery;\n }\n}\n\n/** Strip the __volter_auth cookie from a headers object before forwarding downstream. */\nexport function stripAuthCookie(headers: Record<string, string>): Record<string, string> {\n const cookie = headers.cookie;\n if (!cookie) return headers;\n const cleaned = cookie\n .split(';')\n .filter((pair) => pair.trim().split('=')[0]?.trim() !== '__volter_auth')\n .join(';')\n .trim();\n const result = { ...headers };\n if (cleaned) result.cookie = cleaned;\n else delete result.cookie;\n return result;\n}\n\n/** CORS headers for a tunneled response (all tunnel traffic is cross-origin).\n *\n * The relay answers preflight itself (it never forwards OPTIONS to the\n * origin app), so the allow-headers grant must cover whatever custom headers\n * the tunneled app's clients send \u2014 which the relay cannot know in advance.\n * Reflect the browser's Access-Control-Request-Headers verbatim: the browser\n * lists exactly the headers the real request will carry, and a wildcard is\n * not an option because allow-credentials is true (spec ignores `*` there).\n * The fixed list remains as a fallback for non-preflight responses, which\n * carry no request-headers hint. */\nexport function corsHeaders(request: Request): Record<string, string> {\n const origin = request.headers.get('origin');\n if (!origin) return {};\n const requested = request.headers.get('access-control-request-headers');\n return {\n 'access-control-allow-origin': origin,\n 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',\n 'access-control-allow-headers': requested || 'Content-Type, Authorization, X-Sandbox-Id',\n 'access-control-allow-credentials': 'true',\n };\n}\n\n/** Operator-configurable response-header rewrite rules, applied on top of the\n * built-in iframe strip. Keys are lower-cased to match Workers' header casing. */\nexport interface HeaderRules {\n /** Force-set these response headers (override whatever downstream returned). */\n set?: Record<string, string>;\n /** Remove these response headers. */\n remove?: string[];\n}\n\n/** Parse RESPONSE_HEADER_RULES (JSON) into HeaderRules. Tolerant: malformed JSON\n * or unexpected shape yields no extra rules \u2014 the built-in iframe strip always\n * still applies, so a bad config can never un-strip frame-ancestors. */\nexport function parseHeaderRules(raw: string | undefined): HeaderRules {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as { set?: unknown; remove?: unknown };\n const rules: HeaderRules = {};\n if (parsed && typeof parsed.set === 'object' && parsed.set) {\n const set: Record<string, string> = {};\n for (const [k, v] of Object.entries(parsed.set as Record<string, unknown>)) {\n if (typeof v === 'string') set[k.toLowerCase()] = v;\n }\n if (Object.keys(set).length) rules.set = set;\n }\n if (Array.isArray(parsed.remove)) {\n const remove = (parsed.remove as unknown[])\n .filter((x): x is string => typeof x === 'string')\n .map((x) => x.toLowerCase());\n if (remove.length) rules.remove = remove;\n }\n return rules;\n } catch {\n return {};\n }\n}\n\n/**\n * Build the downstream response headers: drop hop-by-hop + downstream CORS,\n * strip CSP frame-ancestors / X-Frame-Options (the built-in iframe embedding \u2014\n * always on), apply any operator-configured `rules`, then re-apply our CORS +\n * cookie.\n */\nexport function buildResponseHeaders(\n downstream: Record<string, string>,\n request: Request,\n bootstrapCookie: string | null,\n rules?: HeaderRules\n): Headers {\n const headers = { ...downstream };\n for (const k of [\n 'transfer-encoding',\n 'connection',\n 'access-control-allow-origin',\n 'access-control-allow-methods',\n 'access-control-allow-headers',\n 'access-control-allow-credentials',\n ]) {\n delete headers[k];\n }\n\n for (const key of ['content-security-policy', 'content-security-policy-report-only']) {\n if (headers[key]) {\n headers[key] = headers[key]\n .split(';')\n .filter((d) => !d.trim().startsWith('frame-ancestors'))\n .join(';')\n .trim();\n if (!headers[key]) delete headers[key];\n }\n }\n delete headers['x-frame-options'];\n\n // Operator-configured rewrite rules, on top of the built-in strip. Remove\n // before set so a rule can both clear and re-set the same header deliberately.\n if (rules?.remove) for (const k of rules.remove) delete headers[k];\n if (rules?.set) for (const [k, v] of Object.entries(rules.set)) headers[k] = v;\n\n // Tunnel responses are dynamic and frequently authenticated. Make caching an\n // explicit opt-in: only a downstream response that clearly says `public` may\n // be stored. This defeats zone/cache-rule mistakes that key an authenticated\n // `/api` response only by path while preserving immutable hashed assets.\n const cacheControl = (headers['cache-control'] ?? '').toLowerCase();\n const explicitlyPublic =\n /(?:^|,)\\s*public(?:\\s|,|$)/.test(cacheControl) &&\n !/(?:^|,)\\s*(?:private|no-store|no-cache)(?:\\s|,|=|$)/.test(cacheControl);\n const pathname = new URL(request.url).pathname;\n if (!explicitlyPublic || pathname === '/api' || pathname.startsWith('/api/')) {\n headers['cache-control'] = 'private, no-store, max-age=0';\n // Cloudflare-CDN-Cache-Control is the provider-specific override; the\n // standards-track CDN-Cache-Control protects other relay deployments too.\n headers['cloudflare-cdn-cache-control'] = 'no-store';\n headers['cdn-cache-control'] = 'no-store';\n const vary = new Set(\n (headers.vary ?? '')\n .split(',')\n .map((v) => v.trim())\n .filter(Boolean)\n );\n vary.add('Cookie');\n vary.add('Authorization');\n headers.vary = [...vary].join(', ');\n }\n\n const out = new Headers();\n for (const [k, v] of Object.entries(headers)) {\n if (v != null) out.set(k, v);\n }\n for (const [k, v] of Object.entries(corsHeaders(request))) out.set(k, v);\n if (bootstrapCookie) out.append('set-cookie', bootstrapCookie);\n return out;\n}\n\n/** Build the auth cookie. `cookieDomain` is the host the cookie is scoped to \u2014\n * pass `<tunnelId>.<domain>` to isolate one tunnel (the leading dot still covers\n * its `*.<tunnelId>.<domain>` wildcard subdomains, but NOT other tunnels), or the\n * bare apex `<domain>` for the legacy shared-SSO scope. */\nexport function cookieFor(token: string, cookieDomain: string): string {\n return `__volter_auth=${token}; Domain=.${cookieDomain}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=3600`;\n}\n\n/** GET /__volter_auth?__volter_token=<jwt> \u2192 set the cross-iframe auth cookie. */\nexport async function handleCookieBootstrap(request: Request, url: URL, env: Env): Promise<Response> {\n const cors = corsHeaders(request);\n if (request.method === 'OPTIONS') {\n return new Response(null, { status: 204, headers: cors });\n }\n const token = url.searchParams.get('__volter_token');\n if (!token || !env.JWT_SECRET) {\n return Response.json({ error: 'Missing token parameter' }, { status: 400, headers: cors });\n }\n const payload = await verifyToken(token, env.JWT_SECRET);\n if (!payload) {\n return Response.json({ error: 'Invalid token' }, { status: 401, headers: cors });\n }\n // A tunnel-bound token scopes its cookie to that tunnel; an unbound token keeps\n // the apex scope (legacy shared SSO).\n const tid = typeof payload.tid === 'string' ? payload.tid : undefined;\n const cookieDomain = tid ? `${tid}.${env.TUNNEL_DOMAIN}` : env.TUNNEL_DOMAIN;\n return Response.json(\n { ok: true },\n { status: 200, headers: { ...cors, 'set-cookie': cookieFor(token, cookieDomain) } }\n );\n}\n",
|
|
48
|
+
"domain": "{\n \"$schema\": \"node_modules/wrangler/config-schema.json\",\n \"name\": \"volter-tunnel\",\n \"main\": \"src/worker.ts\",\n \"compatibility_date\": \"2026-06-16\",\n \"observability\": { \"enabled\": true },\n\n \"durable_objects\": {\n \"bindings\": [\n { \"name\": \"TUNNEL\", \"class_name\": \"TunnelDO\" },\n { \"name\": \"ACCOUNTS\", \"class_name\": \"AccountDO\" },\n { \"name\": \"REGISTRY\", \"class_name\": \"RegistryDO\" }\n ]\n },\n \"migrations\": [\n { \"tag\": \"v1\", \"new_sqlite_classes\": [\"TunnelDO\"] },\n { \"tag\": \"v2\", \"new_sqlite_classes\": [\"AccountDO\", \"RegistryDO\"] }\n ],\n\n // Durable per-account usage time-series. Requires enabling Analytics Engine on\n // the account first (dash.cloudflare.com \u2192 Workers \u2192 Analytics Engine); then\n // uncomment this binding and redeploy. The recordAE() calls are no-ops until then.\n // \"analytics_engine_datasets\": [{ \"binding\": \"USAGE_AE\", \"dataset\": \"tunnel_usage\" }],\n\n // Public base domain for tunnel URLs. Tunnel URLs are {tunnelId}.<this>.\n // Use a 1-level wildcard zone so free Universal SSL covers *.<domain>.\n // Override per-env / via `--var` in tests.\n //\n // Metering vars (non-secret) \u2014 global ceiling = \u03a3(account limits) cap, and the\n // built-in internal account's defaults. ROOT_TOKEN is a secret (wrangler secret\n // put ROOT_TOKEN). Credits are charged per request/ws-upgrade by default.\n // Limits are denominated in MONEY. Credits are ops (\u2248 CF billable events:\n // requests, ws-upgrades, relayed messages) and 1 op = COST_PER_OP_USD\n // (~$1/million, conservative). So these op-limits are a hard dollar cap:\n // INTERNAL_DAY_LIMIT 10,000,000 ops = $10/day\n // INTERNAL_MONTH_LIMIT 100,000,000 ops = $100/month\n // Every account (internal included) is gated the same way. Create/raise with\n // dollars directly: POST /admin/accounts { dayUsd: 10, monthUsd: 100 }.\n // Global = \u03a3(account limits) ceiling.\n \"vars\": {\n \"TUNNEL_DOMAIN\": \"voltertest.xyz\",\n // Per-tunnel viewer isolation. The full chain is BUILT + DEPLOYED (gateway\n // mints tid tokens via /api/tunnel/token; frontend sends them; cookie scoped\n // to .<tunnelId>.<domain>). Set \"true\" to ENFORCE (reject tokens lacking a\n // matching `tid`) \u2014 do that ONLY after a signed-in browser smoke confirms a\n // dev preview still loads (the cross-site iframe-cookie behavior is the one\n // thing unit tests can't cover). Off = safe: tid tokens flow but aren't\n // required. Only affects authRequired:true tunnels.\n \"REQUIRE_TID\": \"false\",\n \"INTERNAL_ACCOUNT\": \"volter-internal\",\n \"INTERNAL_DAY_LIMIT\": \"10000000\",\n \"INTERNAL_MONTH_LIMIT\": \"100000000\",\n \"INTERNAL_CONCURRENT\": \"1000\",\n \"GLOBAL_DAY_LIMIT\": \"1000000000\",\n \"GLOBAL_MONTH_LIMIT\": \"10000000000\",\n \"DEFAULT_CONCURRENT\": \"100\",\n \"DEFAULT_LEASE_CHUNK\": \"50\",\n\n // \u2500\u2500 Free-tier signup + reservation policy (#2/#3) \u2014 confirm vs DECISIONS D6.\n // NOTE: SIGNUP_DAY/MONTH_LIMIT are op-credits (~$1/million). The code default\n // is only 1000 (\u2248$0.001) \u2014 far too small \u2014 so set real values here.\n \"SIGNUP_DAY_LIMIT\": \"1000000\", // \u2248 $1/day per free account\n \"SIGNUP_MONTH_LIMIT\": \"10000000\", // \u2248 $10/month per free account\n \"DEFAULT_RESERVED_MAX\": \"3\", // reserved tunnel ids per free account\n \"RESERVATION_IDLE_TTL_DAYS\": \"60\", // idle days before a reserved id is reclaimable\n\n // \u2500\u2500 Fair-use request burst (#4). Generous defaults: 50 req/s/tunnel sustained,\n // 500 instantaneous (covers heavy page loads / interactive dev), so only real\n // floods are throttled. The daily/monthly credit caps remain the primary limit.\n \"BURST_RPS\": \"50\",\n \"BURST_SIZE\": \"500\",\n\n // \u2500\u2500 Cost-DoS guard on the unauthenticated public surface (/signup/*, /report).\n \"SIGNUP_RPS\": \"5\"\n\n // Optional (unset = off):\n // SIGNUP_ALLOWED_USERS \u2014 allowlist mode (comma-separated GitHub logins, or the verified email of a Volter identity without GitHub).\n // Signup FAILS CLOSED when unset/empty; set SIGNUP_OPEN=true to allow\n // everyone. Set via `wrangler secret put` (keep logins out of the repo).\n // SIGNUP_OPEN=true \u2014 open signup to everyone (only when no allowlist).\n // INSPECT_REPLAY=true \u2014 persisted inspector history + /__volter_replay.\n // GITHUB_API_BASE \u2014 override the GitHub API (default api.github.com).\n },\n\n // workers_dev is OFF: all ingress must go through the zone routes below so the\n // WAF rate-limit rule covers the unauthenticated surface (/signup,/report,\n // /waitlist). The control channel (/ws) is host-independent and served by the\n // apex route; *.workers.dev is not needed in prod (localhost covers __tunnel\n // testing). Leaving it on would expose an un-WAF'd <name>.<acct>.workers.dev.\n \"workers_dev\": false,\n \"routes\": [\n { \"pattern\": \"*.voltertest.xyz/*\", \"zone_name\": \"voltertest.xyz\" },\n // Bare apex serves the public front door (landing/docs/management). The apex\n // DNS is already proxied; the wildcard cert above doesn't match the apex, so\n // it needs its own route. Universal SSL covers the apex hostname.\n { \"pattern\": \"voltertest.xyz/*\", \"zone_name\": \"voltertest.xyz\" }\n ]\n\n // Secrets (set via `wrangler secret put` in prod, `.dev.vars` locally):\n // TUNNEL_SECRET, JWT_SECRET, ROOT_TOKEN\n}\n"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/src/cli.ts
CHANGED
|
@@ -1,48 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// world-tunnel: serve the tunnel twin.
|
|
3
3
|
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
4
|
-
import {
|
|
4
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
5
|
+
import { createTunnelTwinServer } from './server.ts';
|
|
5
6
|
|
|
6
|
-
const [
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
7
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
8
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
9
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
10
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
11
|
+
// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to
|
|
12
|
+
const database = optionValue(rest, '--database') || undefined;
|
|
13
|
+
// the World's scenario file, for a model vendor's turns
|
|
14
|
+
const scenarioPath = optionValue(rest, '--scenario') || undefined;
|
|
14
15
|
|
|
15
|
-
if (
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
...(root ? { root } : {}),
|
|
19
|
-
readOnly: hasFlag(args, '--read-only'),
|
|
20
|
-
requireTid: hasFlag(args, '--require-tid'),
|
|
21
|
-
});
|
|
22
|
-
process.stdout.write(`tunnel twin listening on ${server.url}\n`);
|
|
23
|
-
process.stdout.write(`Volter SDK host=${server.url}; Cloudflare quick service=${server.url}\n`);
|
|
24
|
-
await keepProcessAlive();
|
|
25
|
-
} else if (command === 'quick' || command === 'tunnel') {
|
|
26
|
-
const origin = optionValue(args, '--url');
|
|
27
|
-
if (!origin) { process.stderr.write('world-tunnel quick requires --url http://127.0.0.1:<port>\n'); process.exit(2); }
|
|
28
|
-
try { parseTunnelLoopbackOrigin(origin); }
|
|
29
|
-
catch (error) { process.stderr.write(`world-tunnel quick: ${error instanceof Error ? error.message : String(error)}\n`); process.exit(2); }
|
|
30
|
-
const server = createTunnelTwinServer({ ...(port ? { port } : {}), ...(root ? { root } : {}) });
|
|
31
|
-
const allocation = await fetch(`${server.url}/tunnel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
32
|
-
const body = await allocation.json() as { success: boolean; result?: { id: string } };
|
|
33
|
-
if (!allocation.ok || !body.success || !body.result) throw new Error('quick-tunnel allocation failed');
|
|
34
|
-
server.registerQuickOrigin(body.result.id, origin);
|
|
35
|
-
// The world runtime discovers the first URL in stdout. This is deliberately a loopback URL:
|
|
36
|
-
// the twin proves the control plane and genuine HTTP forwarding, not Cloudflare edge/DNS/TLS.
|
|
37
|
-
process.stdout.write(`VOLTER_SHARE_URL=${server.url}\n`);
|
|
16
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
17
|
+
const s = await createTunnelTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });
|
|
18
|
+
process.stdout.write(`tunnel twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
38
19
|
await keepProcessAlive();
|
|
39
|
-
} else if (command === 'conformance') {
|
|
40
|
-
// Dev-only and lazy: the runtime entrypoint does not pull conformance into normal serve/share.
|
|
41
|
-
const { checkTunnelConformance } = await import('./tunnel-conformance.ts');
|
|
42
|
-
const report = await checkTunnelConformance({ ...(root ? { root } : {}) });
|
|
43
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
44
|
-
if (!report.ok) process.exitCode = 1;
|
|
45
20
|
} else {
|
|
46
|
-
process.stdout.write('Usage: world-tunnel serve
|
|
47
|
-
process.stdout.write(' world-tunnel quick --url http://127.0.0.1:<port> [--port N] [--root DIR]\n');
|
|
21
|
+
process.stdout.write('Usage: world-tunnel serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\n');
|
|
48
22
|
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the discovery door, the clock,
|
|
2
|
+
// the World's doors, the vendor's screens and the API from the pack's parts (architecture, "The pack contract").
|
|
3
|
+
import { createPackFetch, type DerivedFetch, type DerivedSurface, type PackFetchOptions } from '@volter/world-core';
|
|
4
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
5
|
+
import { manifest } from './manifest.ts';
|
|
6
|
+
import { around } from './semantics/around.ts';
|
|
7
|
+
import * as sockets from './semantics/sockets.ts';
|
|
8
|
+
import * as doors from './semantics/doors.ts';
|
|
9
|
+
import { tunnelSemantics } from './semantics/index.ts';
|
|
10
|
+
|
|
11
|
+
export function createTunnelFetch(options: PackFetchOptions = {}): DerivedFetch {
|
|
12
|
+
return createPackFetch({
|
|
13
|
+
manifest,
|
|
14
|
+
surface: surface as DerivedSurface,
|
|
15
|
+
handlers: tunnelSemantics,
|
|
16
|
+
doors,
|
|
17
|
+
around,
|
|
18
|
+
sockets,
|
|
19
|
+
}, options);
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","spec":"openapi.json","format":"openapi","version":"2","resources":[],"operations":[{"id":"getStatus","method":"get","path":"/api/status","pathParams":[],"query":[],"body":[],"bodyEncoding":"none","successStatus":200,"class":"computed"}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"_account":{"actions":[],"states":{},"embeds":{}},"_tunnel":{"actions":[],"states":{},"embeds":{}}}}
|
package/src/index.ts
CHANGED
|
@@ -1,53 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
TUNNEL_BUDGET_WINDOW_MS, TUNNEL_CALL_WEIGHTS,
|
|
7
|
-
} from './tunnel-budget.ts';
|
|
8
|
-
export type { RelayControlMessage, RelaySession, TunnelRequest, TunnelResponse } from './tunnel-twin.ts';
|
|
1
|
+
// @volter/twin-tunnel — the tunnel twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the
|
|
2
|
+
// kernel derives the vendor-backed half from the generated surface (architecture, "The real-system adapters").
|
|
3
|
+
import { packOf, registerPack, type DerivedSurface } from '@volter/world-core';
|
|
4
|
+
import { manifest } from './manifest.ts';
|
|
5
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export const pack
|
|
15
|
-
vendor: 'tunnel',
|
|
16
|
-
// The SAME object tunnel-budget.ts declares at module load — one source of truth, so
|
|
17
|
-
// registering the pack and importing the connector can never arm two different ceilings.
|
|
18
|
-
rateBudget: RATE_BUDGET,
|
|
19
|
-
// The CONTROL plane is HTTP/REST (POST /tunnel, GET /api/status). The relay's control link is a
|
|
20
|
-
// WebSocket and its data path is real loopback HTTP — neither is a raw line protocol, so this is
|
|
21
|
-
// not the `raw-tcp` class; the split is described in the README and spec-sources.json.
|
|
22
|
-
transport: 'rest',
|
|
23
|
-
archetype: 'proxy',
|
|
24
|
-
bin: 'world-tunnel',
|
|
25
|
-
// The subject types the twin stores: `quick_tunnel`/`relay_tunnel` written by tunnel-twin.ts,
|
|
26
|
-
// and `relay` folded by the connector's status pull (mapTunnelRelay).
|
|
27
|
-
resources: ['quick_tunnel', 'relay_tunnel', 'relay'],
|
|
28
|
-
specSource:
|
|
29
|
-
'No single first-party machine-readable spec spans this combined pack. The Cloudflare Quick Tunnel ' +
|
|
30
|
-
'half is pinned to first-party cloudflared commit 733bfb939963e150dcf5c4faddb1603f744fbc98 (POST ' +
|
|
31
|
-
'/tunnel and its {success,result,errors} envelope) plus Cloudflare\'s own Quick Tunnels docs; the ' +
|
|
32
|
-
'owned Volter relay half is pinned to volter-ai/volter-tunnel commit ' +
|
|
33
|
-
'a5abc6287384429d32cde200d8ecd042814e6619 (@volter/tunnel 2.0.5, @volter/tunnel-core\'s 15 control ' +
|
|
34
|
-
'frames). See spec-sources.json.',
|
|
35
|
-
description:
|
|
36
|
-
'Cloudflare Quick Tunnel control-plane and owned Volter relay twin — POST /tunnel allocation and the ' +
|
|
37
|
-
'@volter/tunnel WebSocket control protocol, with a genuine loopback HTTP data path (no Cloudflare ' +
|
|
38
|
-
'edge, DNS, TLS or SLA claimed).',
|
|
39
|
-
// INTERCEPTION RULING — hostsNone, the pack's own home for it: no injector entry by design:
|
|
40
|
-
// native cloudflared is an external binary, while @volter/tunnel opens a configurable
|
|
41
|
-
// WebSocket control channel and the CLI reads TUNNEL_SERVER_URL. Worlds wire that app-read
|
|
42
|
-
// endpoint directly; claiming api.trycloudflare.com in the Node injector would not redirect
|
|
43
|
-
// cloudflared and would falsely imply the Cloudflare edge data plane was twinned.
|
|
44
|
-
hostsNone:
|
|
45
|
-
"no injector entry by design: native cloudflared is an external binary, while @volter/tunnel opens a configurable WebSocket control channel and the CLI reads TUNNEL_SERVER_URL. Worlds wire that app-read endpoint directly; claiming api.trycloudflare.com in the Node injector would not redirect cloudflared and would falsely imply the Cloudflare edge data plane was twinned",
|
|
46
|
-
// Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
47
|
-
// 2026-08-31).
|
|
48
|
-
adoption: {
|
|
49
|
-
// Internal Volter tunnel; the client is `@volter/tunnel` and there is no Python distribution.
|
|
50
|
-
pypi: [],
|
|
51
|
-
sdks: ['@volter/tunnel'], envStems: ['TUNNEL', 'VOLTERTUNNEL'],
|
|
52
|
-
},
|
|
53
|
-
};
|
|
7
|
+
export { createTunnelFetch } from './fetch.ts';
|
|
8
|
+
export { createTunnelTwinServer } from './server.ts';
|
|
9
|
+
export { manifest };
|
|
10
|
+
|
|
11
|
+
export const pack = registerPack(packOf(manifest, surface as unknown as DerivedSurface));
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The tunnel relay's manifest (volter-ai/volter-tunnel, spec/SOURCE.md): a protocol more than an API. A client holds
|
|
2
|
+
// the control socket (`control`, semantics/sockets.ts) and answers the public requests the relay sends down it
|
|
3
|
+
// (semantics/around.ts). Served is what Volter Harness's remote access uses (../journeys/demand.json).
|
|
4
|
+
import type { DerivedManifest } from '@volter/world-core';
|
|
5
|
+
|
|
6
|
+
export const manifest: DerivedManifest = {
|
|
7
|
+
vendor: 'tunnel',
|
|
8
|
+
service: 'tunnel',
|
|
9
|
+
body: { json: 'always' },
|
|
10
|
+
unmodeled: ['getStatus'],
|
|
11
|
+
vendorBacked: { none: 'The served wire forwards HTTP over a live control socket; account and reservation rows are World bookkeeping, with no served vendor resource API to perform or refresh.' },
|
|
12
|
+
// source: spec:/x-reference/relay "crypto.randomUUID().slice(0, 8)"
|
|
13
|
+
ids: { template: '{letters:8}' },
|
|
14
|
+
time: 'iso',
|
|
15
|
+
error: '{message}',
|
|
16
|
+
readOnly: { status: 403, message: 'This twin was started read-only; writes are refused.' },
|
|
17
|
+
// The unserved HTTP operations use the pack gap; the served socket refuses in protocol frames.
|
|
18
|
+
notFound: { status: 404, message: 'Not Found' },
|
|
19
|
+
gap: { status: 404, message: 'Not Found' },
|
|
20
|
+
list: { style: 'envelope', envelope: { data: '{data}' }, limit: { param: 'limit', default: 100, max: 100 } },
|
|
21
|
+
deleted: {},
|
|
22
|
+
resources: {
|
|
23
|
+
_account: { idPrefix: '' },
|
|
24
|
+
// a tunnel id an account holds (one minted when a client names none)
|
|
25
|
+
_tunnel: { idPrefix: '' },
|
|
26
|
+
},
|
|
27
|
+
// source: spec:/x-reference/protocol "for the messages exchanged over the control WebSocket between a client and the"
|
|
28
|
+
sockets: [
|
|
29
|
+
{ id: 'control', path: '/ws', note: "a client's control channel: register, then answer each request the relay sends", source: 'https://github.com/volter-ai/volter-tunnel/blob/main/packages/core/src/protocol.ts' },
|
|
30
|
+
],
|
|
31
|
+
doors: [
|
|
32
|
+
{ id: 'accounts', method: 'POST', path: '/_twin/accounts', note: '{ slug }: an account on the relay and its token (the World application\'s TUNNEL_SECRET)' },
|
|
33
|
+
],
|
|
34
|
+
discovery: {
|
|
35
|
+
twinOf: "Volter's tunnel relay (volter-tunnel) as Volter Harness's remote access uses it: a client's control socket, and every request to its tunnel answered through it",
|
|
36
|
+
stores: 'accounts and their tokens (by their SHA-256), and the tunnel ids they hold',
|
|
37
|
+
identity: "the account's token in the control channel's register frame",
|
|
38
|
+
},
|
|
39
|
+
descriptor: {
|
|
40
|
+
protocol: '3',
|
|
41
|
+
transport: 'rest',
|
|
42
|
+
archetype: 'proxy',
|
|
43
|
+
bin: 'world-tunnel',
|
|
44
|
+
resources: [],
|
|
45
|
+
specSource: 'written from volter-ai/volter-tunnel (spec/SOURCE.md)',
|
|
46
|
+
description: "Tunnel relay twin — a client's control socket (register, then each public request sent down it and answered by the frames it sends back), as Volter Harness's remote access shares a local server.",
|
|
47
|
+
// source: spec:/x-reference/protocol "Shared secret or api/login token authenticating the client."
|
|
48
|
+
adoption: { sdks: ['@volter/tunnel'], envStems: ['TUNNEL', 'VOLTERTUNNEL'] },
|
|
49
|
+
// source: spec:/x-reference/domain "voltertest.xyz"
|
|
50
|
+
hosts: [{ host: 'voltertest.xyz' }, { suffix: '.voltertest.xyz' }, { host: 'www.voltertest.xyz', exclude: true }, { host: 'twins.voltertest.xyz', exclude: true }],
|
|
51
|
+
credentialDoor: { path: '/_twin/accounts', body: { slug: 'world' }, fill: { TUNNEL_SECRET: 'token', TUNNEL_SERVER_URL: 'server', SUPERCODE_TUNNEL_SERVER_URL: 'server' } },
|
|
52
|
+
endpointEnvNone: 'the relay URL is the credential door\'s (TUNNEL_SERVER_URL, SUPERCODE_TUNNEL_SERVER_URL), the World claiming voltertest.xyz',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// THE RELAY'S FRONT: a request to a tunnel (its host `<tunnelId>.voltertest.xyz`, or `?__tunnel=<id>` on a direct host,
|
|
2
|
+
// as the relay's worker routes one) is sent down the control socket of the client holding the tunnel and answered with
|
|
3
|
+
// what the client sends back (the kernel's request answered by a session); anything else is the relay's own.
|
|
4
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
5
|
+
import { b64encode, cors, DOMAIN, holder, requests, RESERVED } from './shared.ts';
|
|
6
|
+
|
|
7
|
+
const text = (status: number, body: string): Response => new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
8
|
+
// World transport metadata belongs to the World; preserve the application's own x-volter-* headers.
|
|
9
|
+
const INTERNAL = new Set(['x-volter-token', 'x-volter-pass', 'x-volter-read-only', 'x-volter-twin-original-host', 'x-volter-scenario-decision', 'x-volter-path-prefix-params', 'x-volter-lane']);
|
|
10
|
+
|
|
11
|
+
/** The tunnel a request is for: its host's label, or `?__tunnel=` on a direct host (the relay's local testing). */
|
|
12
|
+
function tunnelOf(request: Request): string | undefined {
|
|
13
|
+
const url = new URL(request.url);
|
|
14
|
+
const host = (request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? url.host).toLowerCase().replace(/:\d+$/, '');
|
|
15
|
+
const label = host.endsWith(`.${DOMAIN}`) ? host.slice(0, -DOMAIN.length - 1) : undefined;
|
|
16
|
+
if (label && !label.includes('.') && !RESERVED.has(label)) return label;
|
|
17
|
+
// source: spec:/x-reference/routing "host.endsWith('.workers.dev')"
|
|
18
|
+
const direct = host.endsWith('.workers.dev') || host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0';
|
|
19
|
+
return direct ? url.searchParams.get('__tunnel') ?? undefined : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function around(ctx: HandlerContext, next: (request?: Request) => Promise<Response>): Promise<Response> {
|
|
23
|
+
const request = ctx.call.request;
|
|
24
|
+
const id = tunnelOf(request);
|
|
25
|
+
if (!id) return next();
|
|
26
|
+
// source: spec:/x-reference/auth "The relay answers preflight itself"
|
|
27
|
+
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors(request) });
|
|
28
|
+
const client = holder(ctx, id);
|
|
29
|
+
// source: spec:/x-reference/relay "Tunnel not connected"
|
|
30
|
+
if (!client) return text(502, 'Tunnel not connected');
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
// source: spec:/x-reference/auth "u.searchParams.delete('__volter_token');"
|
|
33
|
+
url.searchParams.delete('__tunnel');
|
|
34
|
+
url.searchParams.delete('__volter_token');
|
|
35
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? null : new Uint8Array(await request.arrayBuffer());
|
|
36
|
+
// source: spec:/x-reference/relay "bodyBytes.length > 750_000"
|
|
37
|
+
if (body && body.length > 750_000) return text(413, 'Request body too large to tunnel (limit ~750KB)');
|
|
38
|
+
const headers = Object.fromEntries([...request.headers].filter(([k]) => !INTERNAL.has(k.toLowerCase())));
|
|
39
|
+
// The injector preserves the visitor's vendor Host in the same metadata used by kernel host routing.
|
|
40
|
+
headers.host = request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? url.host;
|
|
41
|
+
// source: spec:/x-reference/relay "Never trust a visitor-supplied"
|
|
42
|
+
delete headers['x-forwarded-for'];
|
|
43
|
+
headers['x-forwarded-for'] = request.headers.get('cf-connecting-ip') || '0.0.0.0';
|
|
44
|
+
// source: spec:/x-reference/auth "Strip the __volter_auth cookie"
|
|
45
|
+
if (headers.cookie) {
|
|
46
|
+
const cookie = headers.cookie.split(';').filter((pair) => pair.trim().split('=')[0] !== '__volter_auth').join(';').trim();
|
|
47
|
+
if (cookie) headers.cookie = cookie; else delete headers.cookie;
|
|
48
|
+
}
|
|
49
|
+
// the relay's correlation key for this request on this client's socket: the socket's own count, not World state (a
|
|
50
|
+
// request the relay forwards is no subject of the World)
|
|
51
|
+
const reqId = `${id}-${(client.state.requests = Number(client.state.requests ?? 0) + 1)}`;
|
|
52
|
+
// Register the wait before sending so a synchronous client can answer the frame.
|
|
53
|
+
requests(client).set(reqId, request);
|
|
54
|
+
const answered = client.expect(reqId, 30_000);
|
|
55
|
+
// source: spec:/x-reference/protocol "An inbound HTTP request to forward to the local server."
|
|
56
|
+
client.send({ type: 'request', reqId, method: request.method, path: `${url.pathname}${url.search}`, headers, body: body?.length ? b64encode(body) : null });
|
|
57
|
+
try {
|
|
58
|
+
return (await answered) as Response;
|
|
59
|
+
} catch {
|
|
60
|
+
// source: spec:/x-reference/relay "Tunnel disconnected"
|
|
61
|
+
return !ctx.sockets('control').includes(client) ? text(502, String(client.state.disconnectReason ?? 'Tunnel disconnected')) : timeout();
|
|
62
|
+
} finally { requests(client).delete(reqId); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// source: spec:/x-reference/relay "Tunnel timeout"
|
|
66
|
+
function timeout(): Response {
|
|
67
|
+
return text(504, 'Tunnel timeout');
|
|
68
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The World's doors (architecture, "Doors, screens and the gap"): an account on the relay and its token, as its signup
|
|
2
|
+
// makes one.
|
|
3
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
4
|
+
import { ACCOUNT, sha256 } from './shared.ts';
|
|
5
|
+
|
|
6
|
+
/** An account by its slug (answered again for the same slug) and its token, drawn from the World's secret. */
|
|
7
|
+
export async function accounts(ctx: HandlerContext): Promise<Response> {
|
|
8
|
+
const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {}) as Record<string, unknown>;
|
|
9
|
+
const slug = String(b.slug ?? '');
|
|
10
|
+
// source: spec:/x-reference/credentials "const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;"
|
|
11
|
+
if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(slug)) return Response.json({ message: 'slug must be lowercase letters, digits and dashes' }, { status: 400 });
|
|
12
|
+
let account = ctx.rowsRaw(ACCOUNT).find((a) => a.deleted !== true && a.slug === slug);
|
|
13
|
+
// Where the documentation stops: a token's form is the relay's own; the twin's is `vt_` and 40 letters and digits
|
|
14
|
+
const tokenOf = async (id: string): Promise<string> => `vt_${ctx.crypto.base62From(await ctx.secret(`tunnel-token:${id}`), 40)}`;
|
|
15
|
+
if (!account) {
|
|
16
|
+
const id = ctx.mint(ACCOUNT);
|
|
17
|
+
await ctx.record(ACCOUNT, { slug, token_sha256: sha256(ctx, await tokenOf(id)) }, id);
|
|
18
|
+
account = ctx.row(ACCOUNT, id)!;
|
|
19
|
+
}
|
|
20
|
+
return Response.json({ slug, token: await tokenOf(String(account.id)), server: `https://voltertest.xyz` }, { status: 201 });
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts --index: the handler map, by operationId, from the families' files (each
|
|
2
|
+
// handler named as its operation). Do not edit; add a handler to its family's file and generate again.
|
|
3
|
+
import type { Handler } from '@volter/world-core';
|
|
4
|
+
|
|
5
|
+
export const tunnelSemantics: Record<string, Handler> = {
|
|
6
|
+
};
|