@forwardimpact/libbridge 0.1.12 → 0.1.13

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.13",
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",
@@ -91,6 +91,25 @@ export class CallbackRegistry {
91
91
  return { ...entry };
92
92
  }
93
93
 
94
+ /**
95
+ * Return the bound `tenant_id` for any active token whose correlationId
96
+ * matches; null if no active token binds the correlation. The inbox
97
+ * route uses this to verify a path-supplied tenant against the binding
98
+ * the dispatcher recorded. Single-pass scan of the entries map; the
99
+ * registry is one entry per in-flight dispatch per bridge process.
100
+ * @param {string} correlationId
101
+ * @returns {string | null}
102
+ */
103
+ tenantOf(correlationId) {
104
+ if (typeof correlationId !== "string" || !correlationId) return null;
105
+ for (const entry of this.#entries.values()) {
106
+ if (entry.correlationId === correlationId) {
107
+ return entry.meta.tenant_id;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
94
113
  /**
95
114
  * Drop entries older than ttlMs. Caller drives the clock so tests stay
96
115
  * deterministic.
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) {