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,176 @@
1
+ // Broker forwarding core.
2
+ //
3
+ // Forwards a participant request from the broker to the host room server and
4
+ // streams the response back. The host room server stays the only authority for
5
+ // participant tokens and sender identity: this module never injects a `from`
6
+ // field and never inspects or stores request/response bodies. Long-poll `/wait`
7
+ // responses are streamed, not buffered to completion. Request and forwarded
8
+ // response sizes are capped per the broker resource limits.
9
+ import { once } from "node:events";
10
+ import { Readable } from "node:stream";
11
+ import { URL } from "node:url";
12
+ import { normalizeBaseUrl } from "../protocol/index.js";
13
+ import { TunnelError } from "./protocol.js";
14
+ // Request headers passed through to the host. The Authorization header carries
15
+ // the participant token the host uses to derive sender identity. Origin and
16
+ // Referer are translated to the host origin so the host's same-origin checks
17
+ // stay correct for remote POSTs. Hop-by-hop and host-identifying headers are
18
+ // dropped.
19
+ const FORWARDED_REQUEST_HEADERS = new Set(["authorization", "content-type", "accept"]);
20
+ /**
21
+ * Forward a single participant request to the host room server. Throws a
22
+ * TunnelError (before any response is written) when the host is unreachable or
23
+ * the request body is too large; once the response stream begins, transport
24
+ * failures or an over-limit response just close the socket.
25
+ */
26
+ export async function forwardToHost(options) {
27
+ const targetBase = normalizeBaseUrl(options.target);
28
+ const targetUrl = `${targetBase}${options.path.startsWith("/") ? options.path : `/${options.path}`}`;
29
+ const method = options.req.method ?? "GET";
30
+ const headers = selectRequestHeaders(options.req, new URL(targetBase).origin);
31
+ const body = await readRequestBody(options.req, method, options.requestBodyBytes);
32
+ const bytesIn = body?.length ?? 0;
33
+ const init = { method, headers, redirect: "manual" };
34
+ if (body !== undefined)
35
+ init.body = new Uint8Array(body);
36
+ let response;
37
+ try {
38
+ response = await fetch(targetUrl, init);
39
+ }
40
+ catch {
41
+ throw new TunnelError("internal_error", 502, "could not reach the host room server");
42
+ }
43
+ // When the host declares an over-limit content-length we can still reject
44
+ // cleanly with a stable error before committing any upstream headers.
45
+ const declaredLength = Number(response.headers.get("content-length"));
46
+ if (Number.isFinite(declaredLength) && declaredLength > options.responseBodyBytes) {
47
+ throw new TunnelError("response_too_large", 502, "forwarded response exceeds the broker limit");
48
+ }
49
+ const responseHeaders = {};
50
+ const contentType = response.headers.get("content-type");
51
+ if (contentType !== null)
52
+ responseHeaders["content-type"] = contentType;
53
+ options.res.writeHead(response.status, responseHeaders);
54
+ const bytesOut = await streamResponse(response, options.res, options.responseBodyBytes);
55
+ return { status: response.status, bytesIn, bytesOut };
56
+ }
57
+ async function streamResponse(response, res, responseBodyBytes) {
58
+ if (response.body === null) {
59
+ res.end();
60
+ return 0;
61
+ }
62
+ // Stream the body through without buffering it to completion, so held /wait
63
+ // responses are released as soon as the host responds. If a body with no (or
64
+ // an understated) content-length grows past the cap, headers are already
65
+ // committed, so we destroy the socket and throw response_too_large — the
66
+ // caller logs it as a limit failure rather than a successful forward.
67
+ const source = Readable.fromWeb(response.body);
68
+ let bytesOut = 0;
69
+ let overflow = false;
70
+ try {
71
+ for await (const chunk of source) {
72
+ const buffer = chunk;
73
+ bytesOut += buffer.length;
74
+ if (bytesOut > responseBodyBytes) {
75
+ overflow = true;
76
+ source.destroy();
77
+ break;
78
+ }
79
+ if (!res.write(buffer))
80
+ await once(res, "drain");
81
+ }
82
+ }
83
+ catch {
84
+ res.destroy();
85
+ return bytesOut;
86
+ }
87
+ if (overflow) {
88
+ res.destroy();
89
+ throw new TunnelError("response_too_large", 502, "forwarded response exceeds the broker limit");
90
+ }
91
+ res.end();
92
+ return bytesOut;
93
+ }
94
+ /**
95
+ * Host-side relay step: apply a claimed forwarded request to the local room
96
+ * server and build a response envelope. Used by the host tunnel client (and by
97
+ * relay tests). Origin/Referer are translated to the host origin so same-origin
98
+ * checks pass; the response body is buffered (capped) into base64 for return.
99
+ */
100
+ export async function relayToLocalServer(target, request, responseBodyBytes) {
101
+ const targetBase = normalizeBaseUrl(target);
102
+ const targetUrl = `${targetBase}${request.path.startsWith("/") ? request.path : `/${request.path}`}`;
103
+ const headers = translateEnvelopeHeaders(request.headers, new URL(targetBase).origin);
104
+ const init = { method: request.method, headers, redirect: "manual" };
105
+ if (request.body_base64 !== undefined && request.method !== "GET" && request.method !== "HEAD") {
106
+ init.body = new Uint8Array(Buffer.from(request.body_base64, "base64"));
107
+ }
108
+ let response;
109
+ try {
110
+ response = await fetch(targetUrl, init);
111
+ }
112
+ catch {
113
+ throw new TunnelError("host_unavailable", 502, "host room server is unreachable");
114
+ }
115
+ const buffer = Buffer.from(await response.arrayBuffer());
116
+ if (buffer.length > responseBodyBytes) {
117
+ throw new TunnelError("response_too_large", 502, "host response exceeds the broker limit");
118
+ }
119
+ const responseHeaders = {};
120
+ const contentType = response.headers.get("content-type");
121
+ if (contentType !== null)
122
+ responseHeaders["content-type"] = contentType;
123
+ const result = { status: response.status, headers: responseHeaders };
124
+ if (buffer.length > 0)
125
+ result.body_base64 = buffer.toString("base64");
126
+ return result;
127
+ }
128
+ function translateEnvelopeHeaders(headers, targetOrigin) {
129
+ const result = {};
130
+ for (const [name, value] of Object.entries(headers)) {
131
+ const lower = name.toLowerCase();
132
+ if (lower === "origin") {
133
+ result.origin = targetOrigin;
134
+ }
135
+ else if (lower === "referer") {
136
+ result.referer = `${targetOrigin}/`;
137
+ }
138
+ else if (FORWARDED_REQUEST_HEADERS.has(lower)) {
139
+ result[lower] = value;
140
+ }
141
+ }
142
+ return result;
143
+ }
144
+ function selectRequestHeaders(req, targetOrigin) {
145
+ const headers = {};
146
+ for (const [name, value] of Object.entries(req.headers)) {
147
+ if (value === undefined)
148
+ continue;
149
+ const lower = name.toLowerCase();
150
+ if (FORWARDED_REQUEST_HEADERS.has(lower)) {
151
+ headers[lower] = Array.isArray(value) ? value.join(", ") : value;
152
+ }
153
+ }
154
+ // Translate browser-supplied origin/referer to the host origin so same-origin
155
+ // protections pass through the tunnel without trusting a client-supplied from.
156
+ if (req.headers.origin !== undefined)
157
+ headers.origin = targetOrigin;
158
+ if (req.headers.referer !== undefined)
159
+ headers.referer = `${targetOrigin}/`;
160
+ return headers;
161
+ }
162
+ async function readRequestBody(req, method, limitBytes) {
163
+ if (method === "GET" || method === "HEAD")
164
+ return undefined;
165
+ const chunks = [];
166
+ let total = 0;
167
+ for await (const chunk of req) {
168
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
169
+ total += buffer.length;
170
+ if (total > limitBytes) {
171
+ throw new TunnelError("request_too_large", 413, "request body exceeds the broker limit");
172
+ }
173
+ chunks.push(buffer);
174
+ }
175
+ return chunks.length === 0 ? undefined : Buffer.concat(chunks);
176
+ }
@@ -0,0 +1,133 @@
1
+ // Foreground host tunnel session.
2
+ //
3
+ // Keeps a local room attached to the managed broker: a heartbeat loop holds the
4
+ // route, and a single poll loop claims relay requests and dispatches them to a
5
+ // bounded number of concurrent handlers. Bounded concurrency matters because a
6
+ // claimed `/wait` can hold for ~25s; a serial loop would starve normal card,
7
+ // message, and asset requests behind it.
8
+ import { TunnelError } from "./protocol.js";
9
+ const DEFAULT_CONCURRENCY = 16;
10
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
11
+ const DEFAULT_POLL_INTERVAL_MS = 50;
12
+ // A poll failure with one of these codes means the route is gone for good.
13
+ const FATAL_POLL_CODES = new Set(["route_closed", "route_expired", "route_not_found"]);
14
+ export class HostTunnelSession {
15
+ client;
16
+ routeId;
17
+ hostConnectionId;
18
+ target;
19
+ concurrency;
20
+ heartbeatIntervalMs;
21
+ pollIntervalMs;
22
+ onError;
23
+ controller = new AbortController();
24
+ handlers = new Set();
25
+ heartbeatTimer;
26
+ pollLoop;
27
+ stopped = false;
28
+ failureError;
29
+ constructor(client, options) {
30
+ this.client = client;
31
+ this.routeId = options.routeId;
32
+ this.hostConnectionId = options.hostConnectionId;
33
+ this.target = options.target;
34
+ this.concurrency = Math.max(2, options.concurrency ?? DEFAULT_CONCURRENCY);
35
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
36
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
37
+ this.onError = options.onError;
38
+ }
39
+ /** The first fatal error that shut the session down, if any. */
40
+ get failure() {
41
+ return this.failureError;
42
+ }
43
+ start() {
44
+ this.heartbeatTimer = setInterval(() => void this.beat(), this.heartbeatIntervalMs);
45
+ this.pollLoop = this.runPollLoop();
46
+ }
47
+ async stop(options = {}) {
48
+ if (this.stopped)
49
+ return;
50
+ this.stopped = true;
51
+ this.controller.abort();
52
+ if (this.heartbeatTimer !== undefined)
53
+ clearInterval(this.heartbeatTimer);
54
+ if (this.pollLoop !== undefined)
55
+ await this.pollLoop;
56
+ await Promise.allSettled([...this.handlers]);
57
+ if (options.closeRoute === true) {
58
+ try {
59
+ await this.client.close(this.routeId, this.hostConnectionId);
60
+ }
61
+ catch {
62
+ // Best effort: the route may already be closed or expired.
63
+ }
64
+ }
65
+ }
66
+ async runPollLoop() {
67
+ while (!this.controller.signal.aborted) {
68
+ if (this.handlers.size >= this.concurrency) {
69
+ await this.idle(this.pollIntervalMs);
70
+ continue;
71
+ }
72
+ let request;
73
+ try {
74
+ request = await this.client.poll(this.routeId, this.hostConnectionId);
75
+ }
76
+ catch (error) {
77
+ if (error instanceof TunnelError && FATAL_POLL_CODES.has(error.code)) {
78
+ this.fail(error);
79
+ return;
80
+ }
81
+ await this.idle(this.pollIntervalMs);
82
+ continue;
83
+ }
84
+ if (request === null) {
85
+ await this.idle(this.pollIntervalMs);
86
+ continue;
87
+ }
88
+ const handler = this.handle(request);
89
+ this.handlers.add(handler);
90
+ void handler.finally(() => this.handlers.delete(handler));
91
+ }
92
+ }
93
+ async handle(request) {
94
+ try {
95
+ await this.client.handleClaim(this.routeId, this.hostConnectionId, request, this.target);
96
+ }
97
+ catch {
98
+ // The request may have already settled (closed/timed out); nothing to do.
99
+ }
100
+ }
101
+ async beat() {
102
+ if (this.controller.signal.aborted)
103
+ return;
104
+ try {
105
+ await this.client.heartbeat(this.routeId, this.hostConnectionId);
106
+ }
107
+ catch (error) {
108
+ this.fail(error);
109
+ }
110
+ }
111
+ fail(error) {
112
+ if (this.failureError === undefined)
113
+ this.failureError = error;
114
+ this.controller.abort();
115
+ this.onError?.(error);
116
+ }
117
+ idle(ms) {
118
+ return new Promise((resolve) => {
119
+ const signal = this.controller.signal;
120
+ if (signal.aborted) {
121
+ resolve();
122
+ return;
123
+ }
124
+ const done = () => {
125
+ clearTimeout(timer);
126
+ signal.removeEventListener("abort", done);
127
+ resolve();
128
+ };
129
+ const timer = setTimeout(done, ms);
130
+ signal.addEventListener("abort", done, { once: true });
131
+ });
132
+ }
133
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./protocol.js";
2
+ export * from "./broker.js";
3
+ export * from "./client.js";
4
+ export * from "./forwarding.js";
5
+ export * from "./limits.js";
6
+ export * from "./logging.js";
7
+ export * from "./relay.js";
8
+ export * from "./host-session.js";
@@ -0,0 +1,81 @@
1
+ // Prototype broker resource limits and abuse controls.
2
+ //
3
+ // These are local prototype guards, not a production WAF. They cap concurrency,
4
+ // request/response size, route lifetime, and request rate so managed tunnel
5
+ // routing can be exercised without runaway resource use. Limit errors carry
6
+ // stable codes and generic messages — never raw request details.
7
+ import { TunnelError } from "./protocol.js";
8
+ export const BROKER_LIMITS = {
9
+ hostConnectionsPerRoute: 1,
10
+ concurrentRequestsPerRoute: 64,
11
+ concurrentWaitsPerRoute: 32,
12
+ requestBodyBytes: 64 * 1024,
13
+ responseBodyBytes: 1024 * 1024,
14
+ idleTimeoutMs: 15 * 60_000,
15
+ maxRouteLifetimeMs: 8 * 60 * 60_000,
16
+ unauthenticatedPerIpPerMinute: 60,
17
+ requestsPerRoutePerMinute: 600
18
+ };
19
+ const RATE_WINDOW_MS = 60_000;
20
+ /**
21
+ * Tracks per-route concurrency and per-route / per-IP request rate. `enter`
22
+ * admits a request or throws a stable limit error; it returns a release handle
23
+ * that the caller must invoke when the request completes.
24
+ */
25
+ export class BrokerGuards {
26
+ now;
27
+ limits;
28
+ inflight = new Map();
29
+ routeRate = new Map();
30
+ ipRate = new Map();
31
+ constructor(now, limits = BROKER_LIMITS) {
32
+ this.now = now;
33
+ this.limits = limits;
34
+ }
35
+ enter(input) {
36
+ this.admitRate(this.routeRate, input.routeSlug, this.limits.requestsPerRoutePerMinute, "rate_limited");
37
+ if (!input.authenticated) {
38
+ this.admitRate(this.ipRate, input.clientIp, this.limits.unauthenticatedPerIpPerMinute, "rate_limited");
39
+ }
40
+ const counts = this.inflight.get(input.routeSlug) ?? { total: 0, waits: 0 };
41
+ if (counts.total >= this.limits.concurrentRequestsPerRoute) {
42
+ throw limitError("route_request_limit", "too many concurrent requests for this route");
43
+ }
44
+ if (input.isWait && counts.waits >= this.limits.concurrentWaitsPerRoute) {
45
+ throw limitError("wait_limit", "too many concurrent wait requests for this route");
46
+ }
47
+ counts.total += 1;
48
+ if (input.isWait)
49
+ counts.waits += 1;
50
+ this.inflight.set(input.routeSlug, counts);
51
+ let released = false;
52
+ return () => {
53
+ if (released)
54
+ return;
55
+ released = true;
56
+ const current = this.inflight.get(input.routeSlug);
57
+ if (current === undefined)
58
+ return;
59
+ current.total = Math.max(0, current.total - 1);
60
+ if (input.isWait)
61
+ current.waits = Math.max(0, current.waits - 1);
62
+ if (current.total === 0 && current.waits === 0)
63
+ this.inflight.delete(input.routeSlug);
64
+ };
65
+ }
66
+ admitRate(windows, key, perMinute, code) {
67
+ const nowMs = this.now();
68
+ const window = windows.get(key);
69
+ if (window === undefined || nowMs >= window.resetAt) {
70
+ windows.set(key, { resetAt: nowMs + RATE_WINDOW_MS, count: 1 });
71
+ return;
72
+ }
73
+ if (window.count >= perMinute) {
74
+ throw limitError(code, "request rate limit exceeded");
75
+ }
76
+ window.count += 1;
77
+ }
78
+ }
79
+ export function limitError(code, message) {
80
+ return new TunnelError(code, code === "request_too_large" ? 413 : 429, message);
81
+ }
@@ -0,0 +1,70 @@
1
+ // Broker structured logging with deny-by-default redaction.
2
+ //
3
+ // The broker must never log secrets or room content. This module emits only a
4
+ // fixed allowlist of coarse, safe fields. It never accepts headers, bodies,
5
+ // tokens, query strings, or full paths: callers pass a derived path class and a
6
+ // route hash, and the emitter copies only known-safe keys onto the record.
7
+ import { createHash } from "node:crypto";
8
+ // Only these keys are ever emitted. Anything else a caller attaches is dropped.
9
+ const ALLOWED_FIELDS = [
10
+ "event",
11
+ "route_hash",
12
+ "method",
13
+ "path_class",
14
+ "status",
15
+ "duration_ms",
16
+ "bytes_in",
17
+ "bytes_out",
18
+ "wait_held_ms",
19
+ "error"
20
+ ];
21
+ /** Default sink: structured JSON line to stderr. */
22
+ export const stderrLogSink = (record) => {
23
+ process.stderr.write(`${JSON.stringify(record)}\n`);
24
+ };
25
+ export class BrokerLogger {
26
+ sink;
27
+ constructor(sink = stderrLogSink) {
28
+ this.sink = sink;
29
+ }
30
+ log(fields) {
31
+ const record = {};
32
+ for (const key of ALLOWED_FIELDS) {
33
+ const value = fields[key];
34
+ if (value !== undefined)
35
+ record[key] = value;
36
+ }
37
+ this.sink(record);
38
+ }
39
+ }
40
+ /** Stable, non-reversible identifier for a route slug, safe to log. */
41
+ export function routeHash(slug) {
42
+ return createHash("sha256").update(slug).digest("hex").slice(0, 12);
43
+ }
44
+ const PATH_CLASSES = new Set([
45
+ "room.css",
46
+ "room.js",
47
+ "brief",
48
+ "attendance",
49
+ "status",
50
+ "messages",
51
+ "wait",
52
+ "card",
53
+ "profile",
54
+ "join",
55
+ "leave",
56
+ "close"
57
+ ]);
58
+ /**
59
+ * Reduce a forwarded path to a coarse class with no query string, parameters,
60
+ * or identifiers. Unknown paths collapse to "other" so nothing sensitive leaks.
61
+ */
62
+ export function classifyPath(pathname) {
63
+ const withoutQuery = pathname.split("?", 1)[0] ?? "/";
64
+ const first = withoutQuery.split("/").filter(Boolean)[0];
65
+ if (first === undefined)
66
+ return "shell";
67
+ if (first === "room.css" || first === "room.js")
68
+ return "asset";
69
+ return PATH_CLASSES.has(first) ? first : "other";
70
+ }
@@ -0,0 +1,46 @@
1
+ // Local tunnel protocol for managed agentgather.dev routing.
2
+ //
3
+ // This module defines the wire shapes exchanged between a host tunnel client,
4
+ // the routing broker, and remote participants. It is transport-agnostic and
5
+ // local-only: later tickets (#35 host client, #36 forwarding core) implement
6
+ // real forwarding against these types. Nothing here opens a public network
7
+ // connection or requires credentials.
8
+ /**
9
+ * Error raised by broker operations. Carries a stable code and the HTTP status
10
+ * the participant listener should return. Messages are intentionally generic so
11
+ * they never leak raw request URLs or tokens.
12
+ */
13
+ export class TunnelError extends Error {
14
+ code;
15
+ status;
16
+ constructor(code, status, message) {
17
+ super(message);
18
+ this.name = "TunnelError";
19
+ this.code = code;
20
+ this.status = status;
21
+ }
22
+ body() {
23
+ return { ok: false, error: this.code, message: this.message };
24
+ }
25
+ }
26
+ /** Header names stripped before any tunnel-layer logging or metrics. */
27
+ const SENSITIVE_HEADERS = new Set([
28
+ "authorization",
29
+ "cookie",
30
+ "set-cookie",
31
+ "proxy-authorization"
32
+ ]);
33
+ /**
34
+ * Remove credential-bearing headers from a forwarded header map so tunnel logs
35
+ * and metrics can never capture participant tokens. Pure; returns a new map and
36
+ * does not mutate the input.
37
+ */
38
+ export function redactHeaders(headers) {
39
+ const safe = {};
40
+ for (const [name, value] of Object.entries(headers)) {
41
+ if (SENSITIVE_HEADERS.has(name.toLowerCase()))
42
+ continue;
43
+ safe[name] = value;
44
+ }
45
+ return safe;
46
+ }
@@ -0,0 +1,106 @@
1
+ // Host-connected relay queue.
2
+ //
3
+ // In managed mode the broker cannot reach the host's private room server. A
4
+ // participant request becomes a bounded in-flight forwarded request: the broker
5
+ // holds it in memory, the host tunnel client claims it over an outbound poll,
6
+ // forwards it to its local room server, and posts the response back. Bodies live
7
+ // only in memory while in flight and are never persisted or logged here.
8
+ import { TunnelError } from "./protocol.js";
9
+ /**
10
+ * Tracks in-flight participant requests waiting for a host relay response. One
11
+ * hub serves all routes; requests are keyed by a broker-minted request id.
12
+ */
13
+ export class RelayHub {
14
+ options;
15
+ mintId;
16
+ pending = new Map();
17
+ inflight = new Map();
18
+ constructor(options, mintId) {
19
+ this.options = options;
20
+ this.mintId = mintId;
21
+ }
22
+ /** Enqueue a participant request and resolve once the host responds. */
23
+ enqueue(slug, envelope) {
24
+ const requestId = this.mintId();
25
+ const full = { ...envelope, request_id: requestId };
26
+ return new Promise((resolve, reject) => {
27
+ const timer = setTimeout(() => {
28
+ this.fail(requestId, new TunnelError("host_unavailable", 504, "host tunnel did not attend the request"));
29
+ }, this.options.claimTimeoutMs);
30
+ timer.unref();
31
+ this.inflight.set(requestId, { envelope: full, slug, resolve, reject, claimed: false, timer });
32
+ const queue = this.pending.get(slug) ?? [];
33
+ queue.push(requestId);
34
+ this.pending.set(slug, queue);
35
+ });
36
+ }
37
+ /** Host claims the next pending request for a route, or null if none. */
38
+ claim(slug) {
39
+ const queue = this.pending.get(slug);
40
+ while (queue !== undefined && queue.length > 0) {
41
+ const requestId = queue.shift();
42
+ if (requestId === undefined)
43
+ break;
44
+ const item = this.inflight.get(requestId);
45
+ if (item === undefined || item.claimed)
46
+ continue;
47
+ item.claimed = true;
48
+ clearTimeout(item.timer);
49
+ item.timer = setTimeout(() => {
50
+ this.fail(requestId, new TunnelError("host_unavailable", 504, "host tunnel did not respond in time"));
51
+ }, this.options.responseTimeoutMs);
52
+ item.timer.unref();
53
+ if (queue.length === 0)
54
+ this.pending.delete(slug);
55
+ return item.envelope;
56
+ }
57
+ if (queue !== undefined && queue.length === 0)
58
+ this.pending.delete(slug);
59
+ return null;
60
+ }
61
+ /** Host posts the response for exactly one in-flight request id. */
62
+ respond(requestId, response) {
63
+ const item = this.inflight.get(requestId);
64
+ if (item === undefined) {
65
+ throw new TunnelError("unknown_request", 404, "no in-flight request for this id");
66
+ }
67
+ const bodyBytes = typeof response.body_base64 === "string" ? Buffer.byteLength(response.body_base64, "base64") : 0;
68
+ if (bodyBytes > this.options.responseBodyBytes) {
69
+ this.fail(requestId, new TunnelError("response_too_large", 502, "forwarded response exceeds the broker limit"));
70
+ throw new TunnelError("response_too_large", 502, "forwarded response exceeds the broker limit");
71
+ }
72
+ clearTimeout(item.timer);
73
+ this.remove(requestId);
74
+ item.resolve(response);
75
+ }
76
+ /** Reject every in-flight request for a closed route. */
77
+ closeRoute(slug) {
78
+ for (const [requestId, item] of [...this.inflight]) {
79
+ if (item.slug === slug) {
80
+ this.fail(requestId, new TunnelError("route_closed", 410, "this route has been closed"));
81
+ }
82
+ }
83
+ }
84
+ fail(requestId, error) {
85
+ const item = this.inflight.get(requestId);
86
+ if (item === undefined)
87
+ return;
88
+ clearTimeout(item.timer);
89
+ this.remove(requestId);
90
+ item.reject(error);
91
+ }
92
+ remove(requestId) {
93
+ const item = this.inflight.get(requestId);
94
+ this.inflight.delete(requestId);
95
+ if (item === undefined)
96
+ return;
97
+ const queue = this.pending.get(item.slug);
98
+ if (queue === undefined)
99
+ return;
100
+ const index = queue.indexOf(requestId);
101
+ if (index >= 0)
102
+ queue.splice(index, 1);
103
+ if (queue.length === 0)
104
+ this.pending.delete(item.slug);
105
+ }
106
+ }