agentgather 0.1.0

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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/SECURITY.md +104 -0
  4. package/dist/src/auth/index.js +1 -0
  5. package/dist/src/auth/tokens.js +12 -0
  6. package/dist/src/browser/room.css +666 -0
  7. package/dist/src/browser/room.html +80 -0
  8. package/dist/src/browser/room.js +435 -0
  9. package/dist/src/cli/args.js +29 -0
  10. package/dist/src/cli/commands/attend/index.js +26 -0
  11. package/dist/src/cli/commands/broker/index.js +61 -0
  12. package/dist/src/cli/commands/doctor/index.js +93 -0
  13. package/dist/src/cli/commands/export/index.js +42 -0
  14. package/dist/src/cli/commands/handoff/index.js +41 -0
  15. package/dist/src/cli/commands/instructions/index.js +7 -0
  16. package/dist/src/cli/commands/message/index.js +50 -0
  17. package/dist/src/cli/commands/message/transport.js +108 -0
  18. package/dist/src/cli/commands/room/index.js +350 -0
  19. package/dist/src/cli/commands/tunnel/index.js +131 -0
  20. package/dist/src/cli/commands/watch/index.js +16 -0
  21. package/dist/src/cli/context.js +9 -0
  22. package/dist/src/cli/help.js +53 -0
  23. package/dist/src/cli/index.js +63 -0
  24. package/dist/src/cli/state.js +40 -0
  25. package/dist/src/protocol/attendance.js +20 -0
  26. package/dist/src/protocol/index.js +7 -0
  27. package/dist/src/protocol/instructions.js +29 -0
  28. package/dist/src/protocol/mentions.js +48 -0
  29. package/dist/src/protocol/messages.js +71 -0
  30. package/dist/src/protocol/types.js +1 -0
  31. package/dist/src/protocol/urls.js +9 -0
  32. package/dist/src/protocol/validation.js +21 -0
  33. package/dist/src/server/errors.js +12 -0
  34. package/dist/src/server/http.js +583 -0
  35. package/dist/src/server/index.js +2 -0
  36. package/dist/src/server/wait.js +44 -0
  37. package/dist/src/storage/index.js +4 -0
  38. package/dist/src/storage/lock.js +93 -0
  39. package/dist/src/storage/paths.js +18 -0
  40. package/dist/src/storage/room-store.js +302 -0
  41. package/dist/src/storage/secure-fs.js +28 -0
  42. package/dist/src/tunnel/broker.js +440 -0
  43. package/dist/src/tunnel/client.js +144 -0
  44. package/dist/src/tunnel/forwarding.js +176 -0
  45. package/dist/src/tunnel/host-session.js +133 -0
  46. package/dist/src/tunnel/index.js +8 -0
  47. package/dist/src/tunnel/limits.js +81 -0
  48. package/dist/src/tunnel/logging.js +70 -0
  49. package/dist/src/tunnel/protocol.js +46 -0
  50. package/dist/src/tunnel/relay.js +106 -0
  51. package/docs/FOUNDING-TICKETS.md +759 -0
  52. package/docs/PROPOSAL.md +2120 -0
  53. package/docs/agentgather-dev-deployment-guide.md +305 -0
  54. package/docs/agentgather-dev-tunnel-architecture.md +349 -0
  55. package/docs/deploy-rooms-agentgather-dev.md +152 -0
  56. package/docs/dogfood/release-dogfood.md +61 -0
  57. package/docs/dogfood/sanitized-room-log.jsonl +6 -0
  58. package/docs/host-guide.md +282 -0
  59. package/docs/operator-runbook.md +248 -0
  60. package/docs/remote-exposure.md +269 -0
  61. package/docs/room-brief-and-attend-card.md +110 -0
  62. package/package.json +49 -0
@@ -0,0 +1,440 @@
1
+ // Local tunnel broker harness.
2
+ //
3
+ // The broker registers a single host route per slug and exposes a local HTTP
4
+ // listener that resolves participant requests to route status. It does not
5
+ // forward real room endpoints yet (that is ticket #36) and never opens a public
6
+ // network connection. The only state it keeps is ephemeral RouteMetadata.
7
+ import { createServer } from "node:http";
8
+ import { randomBytes } from "node:crypto";
9
+ import { URL } from "node:url";
10
+ import { TunnelError } from "./protocol.js";
11
+ import { forwardToHost } from "./forwarding.js";
12
+ import { BROKER_LIMITS, BrokerGuards } from "./limits.js";
13
+ import { BrokerLogger, classifyPath, routeHash } from "./logging.js";
14
+ import { RelayHub } from "./relay.js";
15
+ const DEFAULT_CLAIM_TIMEOUT_MS = 10_000;
16
+ const DEFAULT_RESPONSE_TIMEOUT_MS = 35_000;
17
+ const DEFAULT_ROUTE_TTL_MS = BROKER_LIMITS.idleTimeoutMs;
18
+ const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
19
+ const LOCAL_TARGET_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
20
+ /**
21
+ * Validate a forwarding target before the broker will store and fetch it. This
22
+ * ticket is local-only, so the target must be a loopback http(s) URL. Rejecting
23
+ * non-local hosts blocks server-side request forgery to internal-network or
24
+ * cloud-metadata addresses through the unauthenticated register endpoint.
25
+ * Authenticated host registration and egress allowlists for non-local targets
26
+ * are deferred to the #37 hardening ticket.
27
+ */
28
+ function assertLocalTarget(target) {
29
+ let url;
30
+ try {
31
+ url = new URL(target);
32
+ }
33
+ catch {
34
+ throw new TunnelError("invalid_registration", 400, "target is not a valid URL");
35
+ }
36
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
37
+ throw new TunnelError("invalid_registration", 400, "target must use http or https");
38
+ }
39
+ if (!LOCAL_TARGET_HOSTS.has(url.hostname)) {
40
+ throw new TunnelError("invalid_registration", 400, "target must be a local address");
41
+ }
42
+ }
43
+ /**
44
+ * In-memory tunnel broker. One active route per slug; routes expire after a TTL
45
+ * unless refreshed by a heartbeat, and can be closed explicitly by the host.
46
+ */
47
+ export class TunnelBroker {
48
+ now;
49
+ routeTtlMs;
50
+ maxRouteLifetimeMs;
51
+ limits;
52
+ guards;
53
+ logger;
54
+ relay;
55
+ routes = new Map();
56
+ // Optional local room server URL per slug for direct-fetch mode (local tests).
57
+ // Managed relay mode registers no target and never stores one.
58
+ targets = new Map();
59
+ constructor(options = {}) {
60
+ this.now = options.now ?? (() => Date.now());
61
+ this.limits = { ...BROKER_LIMITS, ...options.limits };
62
+ this.routeTtlMs = options.routeTtlMs ?? this.limits.idleTimeoutMs;
63
+ this.maxRouteLifetimeMs = options.maxRouteLifetimeMs ?? this.limits.maxRouteLifetimeMs;
64
+ this.guards = new BrokerGuards(this.now, this.limits);
65
+ this.logger = new BrokerLogger(options.logSink);
66
+ this.relay = new RelayHub({
67
+ claimTimeoutMs: options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS,
68
+ responseTimeoutMs: options.responseTimeoutMs ?? DEFAULT_RESPONSE_TIMEOUT_MS,
69
+ responseBodyBytes: this.limits.responseBodyBytes
70
+ }, () => mintId("req"));
71
+ }
72
+ /** Register a route for a slug. Rejects a duplicate active slug. */
73
+ register(registration) {
74
+ const slug = registration.route_slug;
75
+ if (typeof slug !== "string" || !SLUG_PATTERN.test(slug)) {
76
+ throw new TunnelError("invalid_registration", 400, "route slug is missing or malformed");
77
+ }
78
+ if (registration.target !== undefined)
79
+ assertLocalTarget(registration.target);
80
+ const existing = this.currentRoute(slug);
81
+ if (existing && existing.status === "active") {
82
+ throw new TunnelError("route_slug_taken", 409, "an active route already exists for this slug");
83
+ }
84
+ const nowMs = this.now();
85
+ const route = {
86
+ route_slug: slug,
87
+ route_id: mintId("rte"),
88
+ host_connection_id: mintId("conn"),
89
+ created_at: isoFrom(nowMs),
90
+ last_seen_at: isoFrom(nowMs),
91
+ expires_at: isoFrom(nowMs + this.routeTtlMs),
92
+ status: "active"
93
+ };
94
+ this.routes.set(slug, route);
95
+ // Always re-sync the target so a re-registration without a target cannot
96
+ // inherit a previous route's forwarding host.
97
+ if (registration.target !== undefined) {
98
+ this.targets.set(slug, registration.target);
99
+ }
100
+ else {
101
+ this.targets.delete(slug);
102
+ }
103
+ return { ...route };
104
+ }
105
+ /** Local room server URL a slug forwards to, if one was registered. */
106
+ target(slug) {
107
+ return this.targets.get(slug);
108
+ }
109
+ /** Refresh an active route's last-seen and expiry timestamps. */
110
+ heartbeat(beat) {
111
+ if (typeof beat.route_id !== "string" || typeof beat.host_connection_id !== "string") {
112
+ throw new TunnelError("invalid_heartbeat", 400, "heartbeat is missing route identifiers");
113
+ }
114
+ const route = this.findByConnection(beat.route_id, beat.host_connection_id);
115
+ if (route.status === "closed") {
116
+ throw new TunnelError("route_closed", 410, "this route has been closed");
117
+ }
118
+ if (route.status === "expired") {
119
+ throw new TunnelError("route_expired", 410, "this route has expired");
120
+ }
121
+ const nowMs = this.now();
122
+ route.last_seen_at = isoFrom(nowMs);
123
+ route.expires_at = isoFrom(nowMs + this.routeTtlMs);
124
+ return { ...route };
125
+ }
126
+ /** Close a route. Idempotent identifiers must match the registered route. */
127
+ closeRoute(request) {
128
+ const route = this.findByConnection(request.route_id, request.host_connection_id);
129
+ route.status = "closed";
130
+ this.targets.delete(route.route_slug);
131
+ this.relay.closeRoute(route.route_slug);
132
+ return { ok: true, route_slug: route.route_slug, status: "closed" };
133
+ }
134
+ /**
135
+ * Resolve a participant-facing slug to its active route, or throw a stable
136
+ * tunnel error. Returns a copy so callers cannot mutate stored metadata.
137
+ */
138
+ resolve(slug) {
139
+ const route = this.currentRoute(slug);
140
+ if (!route) {
141
+ throw new TunnelError("route_not_found", 404, "no route is registered for this slug");
142
+ }
143
+ if (route.status === "closed") {
144
+ throw new TunnelError("route_closed", 410, "this route has been closed");
145
+ }
146
+ if (route.status === "expired") {
147
+ throw new TunnelError("route_expired", 410, "this route has expired");
148
+ }
149
+ return { ...route };
150
+ }
151
+ /** Copy of all stored route metadata, with lazy expiry applied. */
152
+ snapshot() {
153
+ return [...this.routes.values()].map((route) => ({ ...this.refresh(route) }));
154
+ }
155
+ /** Host claims the next pending relay request for its route, or null. */
156
+ claimRelay(routeId, hostConnectionId) {
157
+ const route = this.findByConnection(routeId, hostConnectionId);
158
+ if (route.status !== "active") {
159
+ throw new TunnelError(route.status === "closed" ? "route_closed" : "route_expired", 410, "route is not active");
160
+ }
161
+ this.touch(route.route_slug);
162
+ return this.relay.claim(route.route_slug);
163
+ }
164
+ /** Host posts the response for exactly one in-flight relay request id. */
165
+ respondRelay(routeId, hostConnectionId, requestId, response) {
166
+ this.findByConnection(routeId, hostConnectionId);
167
+ this.relay.respond(requestId, response);
168
+ }
169
+ /**
170
+ * Forward a participant request under `/<slug>/`, applying concurrency and
171
+ * rate guards and emitting a redaction-safe access log. A route registered
172
+ * with a local target is fetched directly (local tests); otherwise the
173
+ * request is held for the host tunnel client to claim and answer. The caller
174
+ * must have resolved the slug as an active route first.
175
+ */
176
+ async forward(slug, url, req, res) {
177
+ const forwardPath = url.pathname.slice(`/${slug}`.length) || "/";
178
+ const pathClass = classifyPath(forwardPath);
179
+ const isWait = pathClass === "wait";
180
+ const method = req.method ?? "GET";
181
+ const startedAt = this.now();
182
+ let release;
183
+ try {
184
+ release = this.guards.enter({
185
+ routeSlug: slug,
186
+ clientIp: req.socket.remoteAddress ?? "unknown",
187
+ isWait,
188
+ authenticated: typeof req.headers.authorization === "string"
189
+ });
190
+ }
191
+ catch (error) {
192
+ this.logger.log({ event: "limit_rejected", route_hash: routeHash(slug), method, path_class: pathClass, error: errorCode(error) });
193
+ throw error;
194
+ }
195
+ this.touch(slug);
196
+ try {
197
+ const target = this.targets.get(slug);
198
+ const result = target !== undefined
199
+ ? await this.forwardDirect(target, forwardPath, url, req, res)
200
+ : await this.forwardViaRelay(slug, forwardPath, url, req, res);
201
+ this.logger.log({
202
+ event: "forward",
203
+ route_hash: routeHash(slug),
204
+ method,
205
+ path_class: pathClass,
206
+ status: result.status,
207
+ duration_ms: this.now() - startedAt,
208
+ bytes_in: result.bytesIn,
209
+ bytes_out: result.bytesOut,
210
+ ...(isWait ? { wait_held_ms: this.now() - startedAt } : {})
211
+ });
212
+ }
213
+ catch (error) {
214
+ this.logger.log({ event: "forward_error", route_hash: routeHash(slug), method, path_class: pathClass, error: errorCode(error) });
215
+ throw error;
216
+ }
217
+ finally {
218
+ release();
219
+ }
220
+ }
221
+ async forwardDirect(target, forwardPath, url, req, res) {
222
+ return forwardToHost({
223
+ target,
224
+ path: `${forwardPath}${url.search}`,
225
+ req,
226
+ res,
227
+ requestBodyBytes: this.limits.requestBodyBytes,
228
+ responseBodyBytes: this.limits.responseBodyBytes
229
+ });
230
+ }
231
+ async forwardViaRelay(slug, forwardPath, url, req, res) {
232
+ const body = await readBody(req, this.limits.requestBodyBytes);
233
+ const response = await this.relay.enqueue(slug, {
234
+ route_slug: slug,
235
+ method: req.method ?? "GET",
236
+ path: `${forwardPath}${url.search}`,
237
+ headers: selectEnvelopeHeaders(req),
238
+ ...(body !== undefined ? { body_base64: body.toString("base64") } : {})
239
+ });
240
+ const responseBody = typeof response.body_base64 === "string" ? Buffer.from(response.body_base64, "base64") : Buffer.alloc(0);
241
+ const responseHeaders = {};
242
+ const contentType = response.headers["content-type"] ?? response.headers["Content-Type"];
243
+ if (typeof contentType === "string")
244
+ responseHeaders["content-type"] = contentType;
245
+ res.writeHead(response.status, responseHeaders);
246
+ res.end(responseBody);
247
+ return { status: response.status, bytesIn: body?.length ?? 0, bytesOut: responseBody.length };
248
+ }
249
+ touch(slug) {
250
+ const route = this.routes.get(slug);
251
+ if (route && route.status === "active") {
252
+ const nowMs = this.now();
253
+ route.last_seen_at = isoFrom(nowMs);
254
+ route.expires_at = isoFrom(nowMs + this.routeTtlMs);
255
+ }
256
+ }
257
+ currentRoute(slug) {
258
+ const route = this.routes.get(slug);
259
+ return route ? this.refresh(route) : undefined;
260
+ }
261
+ refresh(route) {
262
+ if (route.status === "active") {
263
+ const nowMs = this.now();
264
+ const idleExpired = Date.parse(route.expires_at) <= nowMs;
265
+ const lifetimeExceeded = Date.parse(route.created_at) + this.maxRouteLifetimeMs <= nowMs;
266
+ if (idleExpired || lifetimeExceeded)
267
+ route.status = "expired";
268
+ }
269
+ return route;
270
+ }
271
+ findByConnection(routeId, connectionId) {
272
+ for (const route of this.routes.values()) {
273
+ if (route.route_id === routeId && route.host_connection_id === connectionId) {
274
+ return this.refresh(route);
275
+ }
276
+ }
277
+ throw new TunnelError("route_not_found", 404, "no route matches these identifiers");
278
+ }
279
+ }
280
+ // Reserved path prefix for host control requests. The slug pattern forbids the
281
+ // underscore, so this prefix can never collide with a participant route slug.
282
+ const HOST_PREFIX = "_host";
283
+ const MAX_HOST_CONTROL_BODY_BYTES = 16_000;
284
+ const MAX_HOST_RESPOND_BODY_BYTES = Math.ceil((BROKER_LIMITS.responseBodyBytes * 4) / 3) + 16_000;
285
+ /**
286
+ * Create a local HTTP listener for the broker. Host control requests use the
287
+ * reserved `/_host/<action>` prefix (register, heartbeat, close); every other
288
+ * path is treated as a participant request whose first segment is the route
289
+ * slug. Active slugs return route status; unknown, expired, or closed slugs
290
+ * return a stable tunnel error. The listener never forwards to a host room
291
+ * server in this ticket (forwarding lands with #36).
292
+ */
293
+ export function createBrokerHttpServer(broker) {
294
+ return createServer((req, res) => {
295
+ void routeBrokerRequest(broker, req, res);
296
+ });
297
+ }
298
+ async function routeBrokerRequest(broker, req, res) {
299
+ try {
300
+ const url = new URL(req.url ?? "/", "http://broker.local");
301
+ const segments = url.pathname.split("/").filter(Boolean);
302
+ if (segments[0] === HOST_PREFIX) {
303
+ await handleHostRequest(broker, segments[1], req, res);
304
+ return;
305
+ }
306
+ await handleParticipantRequest(broker, url, req, res);
307
+ }
308
+ catch (error) {
309
+ sendTunnelError(res, error);
310
+ }
311
+ }
312
+ async function handleParticipantRequest(broker, url, req, res) {
313
+ const slug = url.pathname.split("/").filter(Boolean)[0];
314
+ if (slug === undefined) {
315
+ throw new TunnelError("unsupported_route", 404, "request path does not name a route");
316
+ }
317
+ const route = broker.resolve(slug);
318
+ // A bare `/<slug>` with no trailing slash is a route-status probe; anything
319
+ // under `/<slug>/` is forwarded to the host room server.
320
+ if (url.pathname === `/${slug}`) {
321
+ sendJson(res, 200, { ok: true, route_slug: route.route_slug, status: route.status });
322
+ return;
323
+ }
324
+ await broker.forward(slug, url, req, res);
325
+ }
326
+ async function handleHostRequest(broker, action, req, res) {
327
+ if (req.method !== "POST") {
328
+ throw new TunnelError("unsupported_route", 405, "host control requires POST");
329
+ }
330
+ const body = await readJsonBody(req, action === "respond" ? MAX_HOST_RESPOND_BODY_BYTES : MAX_HOST_CONTROL_BODY_BYTES);
331
+ if (action === "register") {
332
+ const route = broker.register({
333
+ route_slug: body.route_slug,
334
+ ...(typeof body.target === "string" ? { target: body.target } : {})
335
+ });
336
+ sendJson(res, 200, { ok: true, route });
337
+ return;
338
+ }
339
+ if (action === "heartbeat") {
340
+ const route = broker.heartbeat({
341
+ route_id: body.route_id,
342
+ host_connection_id: body.host_connection_id
343
+ });
344
+ sendJson(res, 200, { ok: true, route });
345
+ return;
346
+ }
347
+ if (action === "close") {
348
+ const result = broker.closeRoute({
349
+ route_id: body.route_id,
350
+ host_connection_id: body.host_connection_id
351
+ });
352
+ sendJson(res, 200, result);
353
+ return;
354
+ }
355
+ if (action === "poll") {
356
+ const request = broker.claimRelay(body.route_id, body.host_connection_id);
357
+ sendJson(res, 200, { ok: true, request });
358
+ return;
359
+ }
360
+ if (action === "respond") {
361
+ broker.respondRelay(body.route_id, body.host_connection_id, body.request_id, body.response);
362
+ sendJson(res, 200, { ok: true });
363
+ return;
364
+ }
365
+ throw new TunnelError("unsupported_route", 404, "unknown host control action");
366
+ }
367
+ async function readJsonBody(req, limitBytes) {
368
+ const chunks = [];
369
+ let total = 0;
370
+ for await (const chunk of req) {
371
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
372
+ total += buffer.length;
373
+ if (total > limitBytes) {
374
+ throw new TunnelError("invalid_registration", 413, "host control body is too large");
375
+ }
376
+ chunks.push(buffer);
377
+ }
378
+ if (total === 0)
379
+ return {};
380
+ try {
381
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
382
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
383
+ }
384
+ catch {
385
+ return {};
386
+ }
387
+ }
388
+ function sendTunnelError(res, error) {
389
+ if (res.headersSent) {
390
+ res.destroy();
391
+ return;
392
+ }
393
+ if (error instanceof TunnelError) {
394
+ sendJson(res, error.status, error.body());
395
+ return;
396
+ }
397
+ sendJson(res, 500, { ok: false, error: "internal_error", message: "internal tunnel error" });
398
+ }
399
+ function sendJson(res, status, body) {
400
+ const payload = JSON.stringify(body);
401
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
402
+ res.end(payload);
403
+ }
404
+ function mintId(prefix) {
405
+ return `${prefix}_${randomBytes(12).toString("base64url")}`;
406
+ }
407
+ function isoFrom(ms) {
408
+ return new Date(ms).toISOString();
409
+ }
410
+ function errorCode(error) {
411
+ return error instanceof TunnelError ? error.code : "internal_error";
412
+ }
413
+ const ENVELOPE_HEADERS = new Set(["authorization", "content-type", "accept", "origin", "referer"]);
414
+ function selectEnvelopeHeaders(req) {
415
+ const headers = {};
416
+ for (const [name, value] of Object.entries(req.headers)) {
417
+ if (value === undefined)
418
+ continue;
419
+ const lower = name.toLowerCase();
420
+ if (ENVELOPE_HEADERS.has(lower))
421
+ headers[lower] = Array.isArray(value) ? value.join(", ") : value;
422
+ }
423
+ return headers;
424
+ }
425
+ async function readBody(req, limitBytes) {
426
+ const method = req.method ?? "GET";
427
+ if (method === "GET" || method === "HEAD")
428
+ return undefined;
429
+ const chunks = [];
430
+ let total = 0;
431
+ for await (const chunk of req) {
432
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
433
+ total += buffer.length;
434
+ if (total > limitBytes) {
435
+ throw new TunnelError("request_too_large", 413, "request body exceeds the broker limit");
436
+ }
437
+ chunks.push(buffer);
438
+ }
439
+ return chunks.length === 0 ? undefined : Buffer.concat(chunks);
440
+ }
@@ -0,0 +1,144 @@
1
+ // Host tunnel client.
2
+ //
3
+ // Connects a host to a local tunnel broker over HTTP, registers a route for a
4
+ // room slug, and records the resulting public base URL on disk so the running
5
+ // room server and the CLI can advertise it. The host tunnel session is kept
6
+ // distinct from participant tokens: the broker-minted host_connection_id is a
7
+ // routing credential, never a participant bearer token.
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { ensureSecureDir, writeSecureFile } from "../storage/index.js";
11
+ import { normalizeBaseUrl, roomUrl } from "../protocol/index.js";
12
+ import { relayToLocalServer } from "./forwarding.js";
13
+ import { TunnelError } from "./protocol.js";
14
+ const RELAY_RESPONSE_BODY_BYTES = 1024 * 1024;
15
+ /** HTTP client for a local tunnel broker's host control endpoints. */
16
+ export class TunnelClient {
17
+ brokerBaseUrl;
18
+ constructor(brokerBaseUrl) {
19
+ this.brokerBaseUrl = normalizeBaseUrl(brokerBaseUrl);
20
+ }
21
+ /** Register a route for a slug and compute its public base URL. */
22
+ async register(slug, target) {
23
+ const payload = await this.post("/_host/register", {
24
+ route_slug: slug,
25
+ ...(target === undefined ? {} : { target })
26
+ });
27
+ const route = payload.route;
28
+ return { route, publicBaseUrl: this.publicBaseUrlFor(route.route_slug) };
29
+ }
30
+ /** Refresh a route to keep the host session alive. */
31
+ async heartbeat(routeId, hostConnectionId) {
32
+ const payload = await this.post("/_host/heartbeat", {
33
+ route_id: routeId,
34
+ host_connection_id: hostConnectionId
35
+ });
36
+ return payload.route;
37
+ }
38
+ /** Close a route the host owns. */
39
+ async close(routeId, hostConnectionId) {
40
+ const payload = await this.post("/_host/close", {
41
+ route_id: routeId,
42
+ host_connection_id: hostConnectionId
43
+ });
44
+ return payload;
45
+ }
46
+ /** Claim the next pending relay request for the route, or null if none. */
47
+ async poll(routeId, hostConnectionId) {
48
+ const payload = await this.post("/_host/poll", { route_id: routeId, host_connection_id: hostConnectionId });
49
+ return payload.request ?? null;
50
+ }
51
+ /** Post the response for exactly one in-flight relay request id. */
52
+ async respond(routeId, hostConnectionId, requestId, response) {
53
+ await this.post("/_host/respond", {
54
+ route_id: routeId,
55
+ host_connection_id: hostConnectionId,
56
+ request_id: requestId,
57
+ response
58
+ });
59
+ }
60
+ /**
61
+ * Attend the route once: claim a pending request, forward it to the local
62
+ * room server, and post the response back. Returns true if a request was
63
+ * handled, false if none were pending. This is the host outbound relay step;
64
+ * the broker never reaches the local server itself.
65
+ */
66
+ async attendOnce(routeId, hostConnectionId, target) {
67
+ const request = await this.poll(routeId, hostConnectionId);
68
+ if (request === null)
69
+ return false;
70
+ await this.handleClaim(routeId, hostConnectionId, request, target);
71
+ return true;
72
+ }
73
+ /**
74
+ * Forward an already-claimed request to the local room server and post the
75
+ * response. Failures become a stable error response so the participant never
76
+ * hangs. Used by the foreground run loop to process claims concurrently.
77
+ */
78
+ async handleClaim(routeId, hostConnectionId, request, target) {
79
+ let response;
80
+ try {
81
+ response = await relayToLocalServer(target, request, RELAY_RESPONSE_BODY_BYTES);
82
+ }
83
+ catch (error) {
84
+ response = {
85
+ status: error instanceof TunnelError ? error.status : 502,
86
+ headers: { "content-type": "application/json; charset=utf-8" },
87
+ body_base64: Buffer.from(JSON.stringify({ ok: false, error: error instanceof TunnelError ? error.code : "host_unavailable" })).toString("base64")
88
+ };
89
+ }
90
+ await this.respond(routeId, hostConnectionId, request.request_id, response);
91
+ }
92
+ publicBaseUrlFor(slug) {
93
+ return normalizeBaseUrl(roomUrl(this.brokerBaseUrl, slug));
94
+ }
95
+ async post(action, body) {
96
+ let response;
97
+ try {
98
+ response = await fetch(new URL(action, `${this.brokerBaseUrl}/`), {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify(body)
102
+ });
103
+ }
104
+ catch {
105
+ throw new TunnelError("route_not_found", 502, "could not reach the tunnel broker");
106
+ }
107
+ const payload = await readJson(response);
108
+ if (!response.ok || payload.ok === false) {
109
+ const error = payload;
110
+ throw new TunnelError(error.error ?? "internal_error", response.status, typeof error.message === "string" ? error.message : "tunnel broker rejected the request");
111
+ }
112
+ return payload;
113
+ }
114
+ }
115
+ async function readJson(response) {
116
+ try {
117
+ const parsed = JSON.parse(await response.text());
118
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
119
+ }
120
+ catch {
121
+ return {};
122
+ }
123
+ }
124
+ export function tunnelStatePath(home, roomId) {
125
+ return path.join(home, "rooms", roomId, "tunnel.json");
126
+ }
127
+ export async function writeHostTunnelState(home, roomId, state) {
128
+ const file = tunnelStatePath(home, roomId);
129
+ await ensureSecureDir(path.dirname(file));
130
+ await writeSecureFile(file, `${JSON.stringify(state, null, 2)}\n`);
131
+ }
132
+ /**
133
+ * Read the published public base URL for a room, or undefined if no tunnel has
134
+ * been started. Synchronous so the room server can resolve it per request.
135
+ */
136
+ export function readPublicBaseUrl(home, roomId) {
137
+ try {
138
+ const state = JSON.parse(readFileSync(tunnelStatePath(home, roomId), "utf8"));
139
+ return typeof state.public_base_url === "string" ? state.public_base_url : undefined;
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }