@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 +1 -1
- package/src/callback-registry.js +69 -3
- package/src/dispatcher.js +1 -1
- package/src/inbox-handler.js +15 -9
- package/src/server.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/callback-registry.js
CHANGED
|
@@ -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
|
-
*
|
|
60
|
-
*
|
|
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 (
|
|
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/${
|
|
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
|
package/src/inbox-handler.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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