@forwardimpact/libbridge 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libbridge",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Threaded-channel bridge primitives — relay messages between human channels (GitHub Discussions, Microsoft Teams) and the Kata agent team.",
5
5
  "keywords": [
6
6
  "bridge",
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
3
  const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
4
+ const DEFAULT_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
4
5
 
5
6
  /**
6
7
  * In-memory registry of pending bridge → workflow callbacks. Hosts persist
@@ -17,6 +18,7 @@ export class CallbackRegistry {
17
18
  #ttlMs;
18
19
  #clock;
19
20
  #entries = new Map();
21
+ #sweepTimer = null;
20
22
 
21
23
  /**
22
24
  * @param {object} [options]
@@ -56,8 +58,20 @@ export class CallbackRegistry {
56
58
  }
57
59
 
58
60
  /**
59
- * Atomic lookup + delete. Returns null when the token is unknown or when
60
- * the supplied `tenant_id` does not match the stored binding.
61
+ * Whether an entry's TTL has elapsed; expired entries are dropped at the
62
+ * lookup that observes them so a stale token stops being a credential
63
+ * even when no sweep has run yet.
64
+ * @param {{createdAt: number}} entry
65
+ * @param {number} now
66
+ * @returns {boolean}
67
+ */
68
+ #expired(entry, now) {
69
+ return now - entry.createdAt > this.#ttlMs;
70
+ }
71
+
72
+ /**
73
+ * Atomic lookup + delete. Returns null when the token is unknown, expired,
74
+ * or when the supplied `tenant_id` does not match the stored binding.
61
75
  * @param {string} token
62
76
  * @param {{tenant_id: string}} bind
63
77
  * @returns {{correlationId: string, meta: object, createdAt: number} | null}
@@ -68,6 +82,10 @@ export class CallbackRegistry {
68
82
  }
69
83
  const entry = this.#entries.get(token);
70
84
  if (!entry) return null;
85
+ if (this.#expired(entry, this.#clock.now())) {
86
+ this.#entries.delete(token);
87
+ return null;
88
+ }
71
89
  if (entry.meta.tenant_id !== bind.tenant_id) return null;
72
90
  this.#entries.delete(token);
73
91
  return entry;
@@ -87,10 +105,37 @@ export class CallbackRegistry {
87
105
  }
88
106
  const entry = this.#entries.get(token);
89
107
  if (!entry) return null;
108
+ if (this.#expired(entry, this.#clock.now())) {
109
+ this.#entries.delete(token);
110
+ return null;
111
+ }
90
112
  if (entry.meta.tenant_id !== bind.tenant_id) return null;
91
113
  return { ...entry };
92
114
  }
93
115
 
116
+ /**
117
+ * Return the bound `tenant_id` for any active token whose correlationId
118
+ * matches; null if no active token binds the correlation. The inbox
119
+ * route uses this to verify a path-supplied tenant against the binding
120
+ * the dispatcher recorded. Single-pass scan of the entries map; the
121
+ * registry is one entry per in-flight dispatch per bridge process.
122
+ * @param {string} correlationId
123
+ * @returns {string | null}
124
+ */
125
+ tenantOf(correlationId) {
126
+ if (typeof correlationId !== "string" || !correlationId) return null;
127
+ const now = this.#clock.now();
128
+ for (const [token, entry] of this.#entries) {
129
+ if (entry.correlationId !== correlationId) continue;
130
+ if (this.#expired(entry, now)) {
131
+ this.#entries.delete(token);
132
+ continue;
133
+ }
134
+ return entry.meta.tenant_id;
135
+ }
136
+ return null;
137
+ }
138
+
94
139
  /**
95
140
  * Drop entries older than ttlMs. Caller drives the clock so tests stay
96
141
  * deterministic.
@@ -100,11 +145,32 @@ export class CallbackRegistry {
100
145
  sweep(now = this.#clock.now()) {
101
146
  let evicted = 0;
102
147
  for (const [token, entry] of this.#entries) {
103
- if (now - entry.createdAt > this.#ttlMs) {
148
+ if (this.#expired(entry, now)) {
104
149
  this.#entries.delete(token);
105
150
  evicted++;
106
151
  }
107
152
  }
108
153
  return evicted;
109
154
  }
155
+
156
+ /**
157
+ * Start the periodic sweep so tokens whose dispatch never calls back are
158
+ * reclaimed instead of accumulating for the life of the process. Idempotent;
159
+ * the handle is unref'd so it never holds the process open.
160
+ * @param {number} [intervalMs]
161
+ */
162
+ startSweepTimer(intervalMs = DEFAULT_SWEEP_INTERVAL_MS) {
163
+ if (this.#sweepTimer) return;
164
+ this.#sweepTimer = this.#clock.setInterval(() => this.sweep(), intervalMs);
165
+ this.#sweepTimer.unref?.();
166
+ }
167
+
168
+ /**
169
+ * Stop the periodic sweep. Safe to call when no timer is running.
170
+ */
171
+ stopSweepTimer() {
172
+ if (!this.#sweepTimer) return;
173
+ this.#clock.clearInterval(this.#sweepTimer);
174
+ this.#sweepTimer = null;
175
+ }
110
176
  }
package/src/dispatcher.js CHANGED
@@ -113,7 +113,7 @@ export class Dispatcher {
113
113
  ctx.pending_callbacks[token] = correlationId;
114
114
  ctx.active_requester = requester;
115
115
  const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${tenant_id}/${token}`;
116
- const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}?tenant_id=${encodeURIComponent(tenant_id)}`;
116
+ const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${tenant_id}/${correlationId}`;
117
117
 
118
118
  // The workflow_dispatch targets the resolved tenant's repository when the
119
119
  // resolver supplies one (multi-tenant); otherwise the static configured
@@ -4,9 +4,16 @@ import { bridge } from "@forwardimpact/libtype";
4
4
  * Long-poll handler for the per-correlation inbox. The run's InboxPoller
5
5
  * fetches injected messages via this endpoint.
6
6
  *
7
+ * The handler verifies the path `tenant_id` against the tenant bound to
8
+ * the `correlationId` in `callbacks` (the `CallbackRegistry`) before
9
+ * entering the poll loop. Unknown or mismatched correlations return
10
+ * `404 {error: "Unknown correlation"}` — the same shape the sister
11
+ * callback route emits for an unknown token (`callback-handler.js:100`).
12
+ *
7
13
  * @param {object} deps
8
14
  * @param {object} deps.client - Bridge gRPC client with DrainInbox
9
15
  * @param {object} deps.logger
16
+ * @param {import("./callback-registry.js").CallbackRegistry} deps.callbacks
10
17
  * @param {number} [deps.pollTimeoutMs] - Max wait before returning empty (default 30s)
11
18
  * @param {number} [deps.pollIntervalMs] - Poll interval (default 1s)
12
19
  * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [deps.clock]
@@ -15,23 +22,22 @@ import { bridge } from "@forwardimpact/libtype";
15
22
  export function createInboxHandler({
16
23
  client,
17
24
  logger,
25
+ callbacks,
18
26
  pollTimeoutMs = 30_000,
19
27
  pollIntervalMs = 1_000,
20
28
  clock,
21
29
  }) {
22
30
  if (!clock) throw new Error("clock is required");
31
+ if (!callbacks) throw new Error("callbacks is required");
23
32
  return async (c) => {
33
+ const tenant_id = c.req.param("tenant_id");
24
34
  const correlationId = c.req.param("correlationId");
25
- const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
26
- // The dispatcher embeds the resolved tenant on the inbox URL it hands the
27
- // run (single-tenant binds the literal `default`). `services/bridge`
28
- // rejects a `DrainInbox` without a `tenant_id`, so a poll that omits it is
29
- // a caller error: fail fast rather than leaking a cross-tenant queue.
30
- const tenant_id = c.req.query("tenant_id");
31
- if (!tenant_id) {
32
- logger.error?.("inbox", "missing tenant_id");
33
- return c.json({ error: "tenant_id required" }, 400);
35
+ const bound = callbacks.tenantOf(correlationId);
36
+ if (!bound || bound !== tenant_id) {
37
+ logger.debug?.("inbox", "unknown correlation");
38
+ return c.json({ error: "Unknown correlation" }, 404);
34
39
  }
40
+ const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
35
41
  const deadline = clock.now() + pollTimeoutMs;
36
42
 
37
43
  while (clock.now() < deadline) {
package/src/server.js CHANGED
@@ -98,7 +98,7 @@ export function createBridgeServer({
98
98
  }
99
99
 
100
100
  if (onInbox) {
101
- app.get("/api/inbox/:correlationId", async (c) => {
101
+ app.get("/api/inbox/:tenant_id/:correlationId", async (c) => {
102
102
  try {
103
103
  return await onInbox(c);
104
104
  } catch (err) {