@forwardimpact/libbridge 0.1.6 → 0.1.9
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 +3 -3
- package/src/callback-handler.js +86 -49
- package/src/callback-payload.js +27 -2
- package/src/callback-registry.js +34 -11
- package/src/dispatch.js +3 -0
- package/src/dispatcher.js +29 -5
- package/src/elapsed-scheduler.js +10 -5
- package/src/inbox-handler.js +47 -0
- package/src/index.js +5 -0
- package/src/rate-limit.js +11 -2
- package/src/resume-scheduler.js +10 -3
- package/src/server.js +61 -79
- package/src/tenant-resolver.js +102 -0
- package/src/token-resolver.js +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"test": "bun test test/*.test.js"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@forwardimpact/libhttp": "^0.1.0",
|
|
43
44
|
"@forwardimpact/libtype": "^0.1.0",
|
|
44
|
-
"@
|
|
45
|
-
"hono": "^4.12.23"
|
|
45
|
+
"@forwardimpact/libutil": "^0.1.84"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@forwardimpact/libmock": "^0.1.0"
|
package/src/callback-handler.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
+
|
|
1
3
|
import { validateCallbackPayload } from "./callback-payload.js";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -44,6 +46,7 @@ export class CallbackHandlerError extends Error {
|
|
|
44
46
|
* @param {(meta: object) => string} options.loadDiscussionId
|
|
45
47
|
* @param {(meta: object) => unknown} [options.ackFinishTarget]
|
|
46
48
|
* @param {(ctx: object, payload: object, meta: object) => Promise<void>} options.handleReply
|
|
49
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
47
50
|
* @returns {(c: object) => Promise<Response>}
|
|
48
51
|
*/
|
|
49
52
|
export function createCallbackHandler({
|
|
@@ -57,6 +60,7 @@ export function createCallbackHandler({
|
|
|
57
60
|
loadDiscussionId,
|
|
58
61
|
ackFinishTarget,
|
|
59
62
|
handleReply,
|
|
63
|
+
clock = createDefaultClock(),
|
|
60
64
|
}) {
|
|
61
65
|
if (!channel) throw new Error("channel is required");
|
|
62
66
|
if (!callbacks) throw new Error("callbacks is required");
|
|
@@ -72,20 +76,70 @@ export function createCallbackHandler({
|
|
|
72
76
|
throw new Error("handleReply is required");
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: session-aware callback handler branches on kind
|
|
75
80
|
return async (c) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
let body;
|
|
82
|
+
try {
|
|
83
|
+
body = await c.req.json();
|
|
84
|
+
} catch {
|
|
85
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
86
|
+
}
|
|
87
|
+
const payload = validateCallbackPayload(body);
|
|
88
|
+
if (!payload) return c.json({ error: "Invalid payload" }, 400);
|
|
89
|
+
|
|
90
|
+
const token = c.req.param("token");
|
|
91
|
+
const tenant_id = c.req.param("tenant_id");
|
|
92
|
+
if (!tenant_id) {
|
|
93
|
+
return c.json({ error: "Unknown callback token" }, 404);
|
|
94
|
+
}
|
|
95
|
+
const isTerminal = payload.kind === "terminal";
|
|
96
|
+
const meta = isTerminal
|
|
97
|
+
? callbacks.consume(token, { tenant_id })
|
|
98
|
+
: callbacks.peek(token, { tenant_id });
|
|
99
|
+
if (!meta) {
|
|
100
|
+
logger.debug?.("callback", "unknown token");
|
|
101
|
+
return c.json({ error: "Unknown callback token" }, 404);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isTerminal) {
|
|
105
|
+
await ack.finish(
|
|
106
|
+
token,
|
|
107
|
+
ackFinishTarget ? ackFinishTarget(meta) : undefined,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (payload.correlation_id !== meta.correlationId) {
|
|
111
|
+
return c.json({ error: "Correlation ID mismatch" }, 400);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const discussionId = loadDiscussionId(meta);
|
|
115
|
+
const ctx = await store.loadByChannel(channel, discussionId);
|
|
116
|
+
if (!ctx) {
|
|
117
|
+
logger.error?.("callback", "context missing", {
|
|
118
|
+
discussion_id: discussionId,
|
|
119
|
+
});
|
|
120
|
+
return c.json({ error: "Discussion context missing" }, 410);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
!isTerminal &&
|
|
125
|
+
payload.seq >= 0 &&
|
|
126
|
+
payload.seq <= (ctx.last_posted_seq ?? -1)
|
|
127
|
+
) {
|
|
128
|
+
return c.json({ ok: true, dedupe: true }, 200);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!isTerminal) {
|
|
132
|
+
payload.replies = payload.body
|
|
133
|
+
? [{ body: payload.body, agent: payload.agent }]
|
|
134
|
+
: [];
|
|
135
|
+
payload.verdict = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (isTerminal) {
|
|
139
|
+
delete ctx.pending_callbacks[token];
|
|
140
|
+
ctx.active_requester = null;
|
|
141
|
+
}
|
|
86
142
|
|
|
87
|
-
const { token, ctx, meta, payload } = prelude;
|
|
88
|
-
delete ctx.pending_callbacks[token];
|
|
89
143
|
return runHandleReply(c, {
|
|
90
144
|
ctx,
|
|
91
145
|
meta,
|
|
@@ -95,45 +149,16 @@ export function createCallbackHandler({
|
|
|
95
149
|
logger,
|
|
96
150
|
tracer,
|
|
97
151
|
spanName,
|
|
152
|
+
clock,
|
|
153
|
+
postReply() {
|
|
154
|
+
if (!isTerminal) {
|
|
155
|
+
ctx.last_posted_seq = payload.seq;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
98
158
|
});
|
|
99
159
|
};
|
|
100
160
|
}
|
|
101
161
|
|
|
102
|
-
async function resolveContext(
|
|
103
|
-
c,
|
|
104
|
-
{ callbacks, ack, store, channel, logger, loadDiscussionId, ackFinishTarget },
|
|
105
|
-
) {
|
|
106
|
-
const token = c.req.param("token");
|
|
107
|
-
const meta = callbacks.consume(token);
|
|
108
|
-
if (!meta) {
|
|
109
|
-
logger.debug?.("callback", "unknown token");
|
|
110
|
-
return { status: 404, error: "Unknown callback token" };
|
|
111
|
-
}
|
|
112
|
-
await ack.finish(token, ackFinishTarget ? ackFinishTarget(meta) : undefined);
|
|
113
|
-
|
|
114
|
-
let body;
|
|
115
|
-
try {
|
|
116
|
-
body = await c.req.json();
|
|
117
|
-
} catch {
|
|
118
|
-
return { status: 400, error: "Invalid JSON" };
|
|
119
|
-
}
|
|
120
|
-
const payload = validateCallbackPayload(body);
|
|
121
|
-
if (!payload) return { status: 400, error: "Invalid payload" };
|
|
122
|
-
if (payload.correlation_id !== meta.correlationId) {
|
|
123
|
-
return { status: 400, error: "Correlation ID mismatch" };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const discussionId = loadDiscussionId(meta);
|
|
127
|
-
const ctx = await store.loadByChannel(channel, discussionId);
|
|
128
|
-
if (!ctx) {
|
|
129
|
-
logger.error?.("callback", "context missing", {
|
|
130
|
-
discussion_id: discussionId,
|
|
131
|
-
});
|
|
132
|
-
return { status: 410, error: "Discussion context missing" };
|
|
133
|
-
}
|
|
134
|
-
return { token, ctx, meta, payload };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
162
|
const STATUS_MESSAGES = {
|
|
138
163
|
400: "Bad request",
|
|
139
164
|
404: "Not found",
|
|
@@ -149,7 +174,18 @@ function sanitizeErrorMessage(status) {
|
|
|
149
174
|
|
|
150
175
|
async function runHandleReply(
|
|
151
176
|
c,
|
|
152
|
-
{
|
|
177
|
+
{
|
|
178
|
+
ctx,
|
|
179
|
+
meta,
|
|
180
|
+
payload,
|
|
181
|
+
handleReply,
|
|
182
|
+
store,
|
|
183
|
+
logger,
|
|
184
|
+
tracer,
|
|
185
|
+
spanName,
|
|
186
|
+
clock,
|
|
187
|
+
postReply,
|
|
188
|
+
},
|
|
153
189
|
) {
|
|
154
190
|
const span = tracer.startSpan(spanName, {
|
|
155
191
|
kind: "SERVER",
|
|
@@ -157,7 +193,8 @@ async function runHandleReply(
|
|
|
157
193
|
});
|
|
158
194
|
try {
|
|
159
195
|
await handleReply(ctx, payload, meta);
|
|
160
|
-
|
|
196
|
+
if (postReply) postReply();
|
|
197
|
+
ctx.last_active_at = clock.now();
|
|
161
198
|
await store.add(ctx);
|
|
162
199
|
await store.flush();
|
|
163
200
|
span.addEvent("reply_delivered", { verdict: payload.verdict });
|
package/src/callback-payload.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
+
|
|
1
3
|
export const MAX_FIELD_LENGTH = 2000;
|
|
2
4
|
export const MAX_REPLY_COUNT = 50;
|
|
3
5
|
|
|
@@ -11,6 +13,7 @@ export const MAX_REPLY_COUNT = 50;
|
|
|
11
13
|
* @param {unknown} body
|
|
12
14
|
* @returns {object | null}
|
|
13
15
|
*/
|
|
16
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: lenient validator with per-field type checks
|
|
14
17
|
export function validateCallbackPayload(body) {
|
|
15
18
|
if (!body || typeof body !== "object") return null;
|
|
16
19
|
const cid = body.correlation_id;
|
|
@@ -37,8 +40,22 @@ export function validateCallbackPayload(body) {
|
|
|
37
40
|
const trigger = validateTrigger(body.trigger);
|
|
38
41
|
const runUrl = typeof body.run_url === "string" ? body.run_url : undefined;
|
|
39
42
|
|
|
43
|
+
const kind = typeof body.kind === "string" ? body.kind : "terminal";
|
|
44
|
+
const seq = typeof body.seq === "number" ? body.seq : -1;
|
|
45
|
+
const eventBody =
|
|
46
|
+
typeof body.body === "string" ? body.body.slice(0, MAX_FIELD_LENGTH) : "";
|
|
47
|
+
const agent =
|
|
48
|
+
typeof body.agent === "string" ? body.agent.slice(0, MAX_FIELD_LENGTH) : "";
|
|
49
|
+
const lastActedSeq =
|
|
50
|
+
typeof body.last_acted_seq === "number" ? body.last_acted_seq : -1;
|
|
51
|
+
|
|
40
52
|
return {
|
|
41
53
|
correlation_id: cid,
|
|
54
|
+
kind,
|
|
55
|
+
seq,
|
|
56
|
+
body: eventBody,
|
|
57
|
+
agent,
|
|
58
|
+
last_acted_seq: lastActedSeq,
|
|
42
59
|
verdict,
|
|
43
60
|
summary,
|
|
44
61
|
replies,
|
|
@@ -105,9 +122,15 @@ export function normalizeBaseUrl(url) {
|
|
|
105
122
|
* @param {string} args.channel
|
|
106
123
|
* @param {string} args.discussionId
|
|
107
124
|
* @param {object} args.participant
|
|
125
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [args.clock]
|
|
108
126
|
* @returns {object}
|
|
109
127
|
*/
|
|
110
|
-
export function newDiscussionContext({
|
|
128
|
+
export function newDiscussionContext({
|
|
129
|
+
channel,
|
|
130
|
+
discussionId,
|
|
131
|
+
participant,
|
|
132
|
+
clock = createDefaultClock(),
|
|
133
|
+
}) {
|
|
111
134
|
return {
|
|
112
135
|
id: `${channel}:${discussionId}`,
|
|
113
136
|
channel,
|
|
@@ -118,6 +141,8 @@ export function newDiscussionContext({ channel, discussionId, participant }) {
|
|
|
118
141
|
lead: "release-engineer",
|
|
119
142
|
pending_callbacks: {},
|
|
120
143
|
dispatches: [],
|
|
121
|
-
last_active_at:
|
|
144
|
+
last_active_at: clock.now(),
|
|
145
|
+
active_requester: null,
|
|
146
|
+
last_posted_seq: -1,
|
|
122
147
|
};
|
|
123
148
|
}
|
package/src/callback-registry.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
4
|
+
|
|
3
5
|
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -7,17 +9,25 @@ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
|
7
9
|
* the (token, correlationId) pairs via the discussion store so the registry
|
|
8
10
|
* can be rehydrated after a restart; this class only owns the live token →
|
|
9
11
|
* metadata mapping and TTL sweep.
|
|
12
|
+
*
|
|
13
|
+
* Every entry is tenant-bound. `register` requires `meta.tenant_id` (single
|
|
14
|
+
* tenant deployments pass `"default"`); `consume` and `peek` require the
|
|
15
|
+
* caller's `tenant_id` and return `null` when the stored value does not
|
|
16
|
+
* match — the same null shape callers already handle for unknown tokens.
|
|
10
17
|
*/
|
|
11
18
|
export class CallbackRegistry {
|
|
12
19
|
#ttlMs;
|
|
20
|
+
#clock;
|
|
13
21
|
#entries = new Map();
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
24
|
* @param {object} [options]
|
|
17
25
|
* @param {number} [options.ttlMs] - Time-to-live in ms (default: 2h)
|
|
26
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
18
27
|
*/
|
|
19
|
-
constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
|
|
28
|
+
constructor({ ttlMs = DEFAULT_TTL_MS, clock = createDefaultClock() } = {}) {
|
|
20
29
|
this.#ttlMs = ttlMs;
|
|
30
|
+
this.#clock = clock;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/** @returns {number} */
|
|
@@ -27,45 +37,58 @@ export class CallbackRegistry {
|
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
39
|
* @param {string} correlationId
|
|
30
|
-
* @param {object}
|
|
40
|
+
* @param {object} meta - Caller-defined metadata; `meta.tenant_id` is required
|
|
31
41
|
* @returns {string} The newly issued callback token
|
|
32
42
|
*/
|
|
33
|
-
register(correlationId, meta
|
|
43
|
+
register(correlationId, meta) {
|
|
34
44
|
if (typeof correlationId !== "string" || !correlationId) {
|
|
35
45
|
throw new Error("correlationId is required");
|
|
36
46
|
}
|
|
47
|
+
if (!meta || typeof meta.tenant_id !== "string" || !meta.tenant_id) {
|
|
48
|
+
throw new Error("meta.tenant_id is required");
|
|
49
|
+
}
|
|
37
50
|
const token = randomUUID();
|
|
38
51
|
this.#entries.set(token, {
|
|
39
52
|
correlationId,
|
|
40
53
|
meta,
|
|
41
|
-
createdAt:
|
|
54
|
+
createdAt: this.#clock.now(),
|
|
42
55
|
});
|
|
43
56
|
return token;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
/**
|
|
47
|
-
* Atomic lookup + delete. Returns null
|
|
60
|
+
* Atomic lookup + delete. Returns null when the token is unknown or when
|
|
61
|
+
* the supplied `tenant_id` does not match the stored binding.
|
|
48
62
|
* @param {string} token
|
|
63
|
+
* @param {{tenant_id: string}} bind
|
|
49
64
|
* @returns {{correlationId: string, meta: object, createdAt: number} | null}
|
|
50
65
|
*/
|
|
51
|
-
consume(token) {
|
|
66
|
+
consume(token, bind) {
|
|
67
|
+
if (!bind || typeof bind.tenant_id !== "string" || !bind.tenant_id) {
|
|
68
|
+
throw new Error("tenant_id is required");
|
|
69
|
+
}
|
|
52
70
|
const entry = this.#entries.get(token);
|
|
53
71
|
if (!entry) return null;
|
|
72
|
+
if (entry.meta.tenant_id !== bind.tenant_id) return null;
|
|
54
73
|
this.#entries.delete(token);
|
|
55
74
|
return entry;
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
/**
|
|
59
78
|
* Returns a shallow clone of the stored metadata for a token without
|
|
60
|
-
* consuming it.
|
|
61
|
-
*
|
|
62
|
-
* `correlationId`, `meta`, or `createdAt` work unchanged.
|
|
79
|
+
* consuming it. Returns null on unknown token or `tenant_id` mismatch —
|
|
80
|
+
* matching `consume`'s shape so callers handle one missing case.
|
|
63
81
|
* @param {string} token
|
|
82
|
+
* @param {{tenant_id: string}} bind
|
|
64
83
|
* @returns {{correlationId: string, meta: object, createdAt: number} | null}
|
|
65
84
|
*/
|
|
66
|
-
peek(token) {
|
|
85
|
+
peek(token, bind) {
|
|
86
|
+
if (!bind || typeof bind.tenant_id !== "string" || !bind.tenant_id) {
|
|
87
|
+
throw new Error("tenant_id is required");
|
|
88
|
+
}
|
|
67
89
|
const entry = this.#entries.get(token);
|
|
68
90
|
if (!entry) return null;
|
|
91
|
+
if (entry.meta.tenant_id !== bind.tenant_id) return null;
|
|
69
92
|
return { ...entry };
|
|
70
93
|
}
|
|
71
94
|
|
|
@@ -75,7 +98,7 @@ export class CallbackRegistry {
|
|
|
75
98
|
* @param {number} [now]
|
|
76
99
|
* @returns {number} Number of entries evicted
|
|
77
100
|
*/
|
|
78
|
-
sweep(now =
|
|
101
|
+
sweep(now = this.#clock.now()) {
|
|
79
102
|
let evicted = 0;
|
|
80
103
|
for (const [token, entry] of this.#entries) {
|
|
81
104
|
if (now - entry.createdAt > this.#ttlMs) {
|
package/src/dispatch.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* @param {string} params.prompt - The facilitator prompt
|
|
17
17
|
* @param {string} params.callbackUrl - Where the workflow posts the reply
|
|
18
18
|
* @param {string} params.correlationId - UUID linking dispatch → callback
|
|
19
|
+
* @param {string} [params.inboxUrl] - Long-poll URL for injecting messages into a live run
|
|
19
20
|
* @param {string} [params.discussionId] - For trace linkage in libeval
|
|
20
21
|
* @param {string} [params.resumeContext] - JSON string carried across resumes
|
|
21
22
|
* @returns {Promise<void>}
|
|
@@ -28,6 +29,7 @@ export async function dispatchWorkflow({
|
|
|
28
29
|
prompt,
|
|
29
30
|
callbackUrl,
|
|
30
31
|
correlationId,
|
|
32
|
+
inboxUrl,
|
|
31
33
|
discussionId,
|
|
32
34
|
resumeContext,
|
|
33
35
|
}) {
|
|
@@ -40,6 +42,7 @@ export async function dispatchWorkflow({
|
|
|
40
42
|
callback_url: callbackUrl,
|
|
41
43
|
correlation_id: correlationId,
|
|
42
44
|
};
|
|
45
|
+
if (inboxUrl !== undefined) inputs.inbox_url = inboxUrl;
|
|
43
46
|
if (discussionId !== undefined) inputs.discussion_id = discussionId;
|
|
44
47
|
if (resumeContext !== undefined) inputs.resume_context = resumeContext;
|
|
45
48
|
|
package/src/dispatcher.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
4
|
+
|
|
3
5
|
import { dispatchWorkflow } from "./dispatch.js";
|
|
4
6
|
|
|
5
7
|
/** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
|
|
@@ -11,6 +13,8 @@ export class Dispatcher {
|
|
|
11
13
|
#workflowFile;
|
|
12
14
|
#githubRepo;
|
|
13
15
|
#tokenResolver;
|
|
16
|
+
#tenantResolver;
|
|
17
|
+
#clock;
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* @param {object} options
|
|
@@ -21,6 +25,8 @@ export class Dispatcher {
|
|
|
21
25
|
* @param {string} options.workflowFile
|
|
22
26
|
* @param {string} options.githubRepo
|
|
23
27
|
* @param {import("./token-resolver.js").TokenResolver} options.tokenResolver
|
|
28
|
+
* @param {import("./tenant-resolver.js").TenantResolver} options.tenantResolver
|
|
29
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
24
30
|
*/
|
|
25
31
|
constructor({
|
|
26
32
|
callbacks,
|
|
@@ -30,6 +36,8 @@ export class Dispatcher {
|
|
|
30
36
|
workflowFile,
|
|
31
37
|
githubRepo,
|
|
32
38
|
tokenResolver,
|
|
39
|
+
tenantResolver,
|
|
40
|
+
clock = createDefaultClock(),
|
|
33
41
|
}) {
|
|
34
42
|
if (!callbacks) throw new Error("callbacks is required");
|
|
35
43
|
if (!ack) throw new Error("ack is required");
|
|
@@ -40,6 +48,7 @@ export class Dispatcher {
|
|
|
40
48
|
if (!workflowFile) throw new Error("workflowFile is required");
|
|
41
49
|
if (!githubRepo) throw new Error("githubRepo is required");
|
|
42
50
|
if (!tokenResolver) throw new Error("tokenResolver is required");
|
|
51
|
+
if (!tenantResolver) throw new Error("tenantResolver is required");
|
|
43
52
|
this.#callbacks = callbacks;
|
|
44
53
|
this.#ack = ack;
|
|
45
54
|
this.#store = store;
|
|
@@ -47,6 +56,8 @@ export class Dispatcher {
|
|
|
47
56
|
this.#workflowFile = workflowFile;
|
|
48
57
|
this.#githubRepo = githubRepo;
|
|
49
58
|
this.#tokenResolver = tokenResolver;
|
|
59
|
+
this.#tenantResolver = tenantResolver;
|
|
60
|
+
this.#clock = clock;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
/**
|
|
@@ -74,11 +85,22 @@ export class Dispatcher {
|
|
|
74
85
|
const auth = await this.#tokenResolver.resolve(ctx.channel, requester);
|
|
75
86
|
if (auth.kind !== "token") return auth;
|
|
76
87
|
|
|
88
|
+
const tenant = await this.#tenantResolver.resolve({
|
|
89
|
+
channel: ctx.channel,
|
|
90
|
+
key: ctx.channel_tenant_key,
|
|
91
|
+
});
|
|
92
|
+
if (!tenant) {
|
|
93
|
+
return { kind: "transient", error: new Error("tenant_unresolved") };
|
|
94
|
+
}
|
|
95
|
+
const tenant_id = tenant.tenant_id;
|
|
96
|
+
|
|
77
97
|
const correlationId = randomUUID();
|
|
78
|
-
const mergedMeta = { ...(callbackMeta ?? {}), requester };
|
|
98
|
+
const mergedMeta = { ...(callbackMeta ?? {}), requester, tenant_id };
|
|
79
99
|
const token = this.#callbacks.register(correlationId, mergedMeta);
|
|
80
100
|
ctx.pending_callbacks[token] = correlationId;
|
|
81
|
-
|
|
101
|
+
ctx.active_requester = requester;
|
|
102
|
+
const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${tenant_id}/${token}`;
|
|
103
|
+
const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}`;
|
|
82
104
|
|
|
83
105
|
if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
|
|
84
106
|
try {
|
|
@@ -89,17 +111,19 @@ export class Dispatcher {
|
|
|
89
111
|
prompt,
|
|
90
112
|
callbackUrl,
|
|
91
113
|
correlationId,
|
|
114
|
+
inboxUrl,
|
|
92
115
|
...(workflowInputs ?? {}),
|
|
93
116
|
});
|
|
94
|
-
ctx.dispatches.push(
|
|
95
|
-
ctx.last_active_at =
|
|
117
|
+
ctx.dispatches.push(this.#clock.now());
|
|
118
|
+
ctx.last_active_at = this.#clock.now();
|
|
96
119
|
await this.#store.add(ctx);
|
|
97
120
|
await this.#store.flush();
|
|
98
121
|
return { kind: "dispatched", token, correlationId };
|
|
99
122
|
} catch (err) {
|
|
100
123
|
if (ackTarget !== undefined) await this.#ack.finish(token, ackTarget);
|
|
101
|
-
this.#callbacks.consume(token);
|
|
124
|
+
this.#callbacks.consume(token, { tenant_id });
|
|
102
125
|
delete ctx.pending_callbacks[token];
|
|
126
|
+
ctx.active_requester = null;
|
|
103
127
|
throw err;
|
|
104
128
|
}
|
|
105
129
|
}
|
package/src/elapsed-scheduler.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
+
|
|
1
3
|
const CHUNK_CAP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -12,16 +14,19 @@ export class ElapsedScheduler {
|
|
|
12
14
|
#timers = new Map();
|
|
13
15
|
#onFire;
|
|
14
16
|
#onError;
|
|
17
|
+
#clock;
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* @param {object} options
|
|
18
21
|
* @param {(correlationId: string) => Promise<void>} options.onFire - Invoked when the deadline passes.
|
|
19
22
|
* @param {(err: Error, correlationId: string) => void} [options.onError] - Invoked when `onFire` rejects.
|
|
23
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
20
24
|
*/
|
|
21
|
-
constructor({ onFire, onError = () => {} }) {
|
|
25
|
+
constructor({ onFire, onError = () => {}, clock = createDefaultClock() }) {
|
|
22
26
|
if (typeof onFire !== "function") throw new Error("onFire is required");
|
|
23
27
|
this.#onFire = onFire;
|
|
24
28
|
this.#onError = onError;
|
|
29
|
+
this.#clock = clock;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** @returns {number} */
|
|
@@ -38,13 +43,13 @@ export class ElapsedScheduler {
|
|
|
38
43
|
*/
|
|
39
44
|
schedule(correlationId, dueAt) {
|
|
40
45
|
this.cancel(correlationId);
|
|
41
|
-
const remaining = dueAt -
|
|
46
|
+
const remaining = dueAt - this.#clock.now();
|
|
42
47
|
if (remaining <= 0) {
|
|
43
48
|
this.#fire(correlationId);
|
|
44
49
|
return;
|
|
45
50
|
}
|
|
46
51
|
const delay = Math.min(remaining, CHUNK_CAP_MS);
|
|
47
|
-
const timer = setTimeout(() => {
|
|
52
|
+
const timer = this.#clock.setTimeout(() => {
|
|
48
53
|
this.#timers.delete(correlationId);
|
|
49
54
|
if (remaining > CHUNK_CAP_MS) {
|
|
50
55
|
this.schedule(correlationId, dueAt);
|
|
@@ -63,14 +68,14 @@ export class ElapsedScheduler {
|
|
|
63
68
|
cancel(correlationId) {
|
|
64
69
|
const timer = this.#timers.get(correlationId);
|
|
65
70
|
if (timer) {
|
|
66
|
-
clearTimeout(timer);
|
|
71
|
+
this.#clock.clearTimeout(timer);
|
|
67
72
|
this.#timers.delete(correlationId);
|
|
68
73
|
}
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
/** Cancel every scheduled timer. */
|
|
72
77
|
clear() {
|
|
73
|
-
for (const timer of this.#timers.values()) clearTimeout(timer);
|
|
78
|
+
for (const timer of this.#timers.values()) this.#clock.clearTimeout(timer);
|
|
74
79
|
this.#timers.clear();
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { bridge } from "@forwardimpact/libtype";
|
|
2
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Long-poll handler for the per-correlation inbox. The run's InboxPoller
|
|
6
|
+
* fetches injected messages via this endpoint.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} deps
|
|
9
|
+
* @param {object} deps.client - Bridge gRPC client with DrainInbox
|
|
10
|
+
* @param {object} deps.logger
|
|
11
|
+
* @param {number} [deps.pollTimeoutMs] - Max wait before returning empty (default 30s)
|
|
12
|
+
* @param {number} [deps.pollIntervalMs] - Poll interval (default 1s)
|
|
13
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [deps.clock]
|
|
14
|
+
* @returns {(c: import("hono").Context) => Promise<Response>}
|
|
15
|
+
*/
|
|
16
|
+
export function createInboxHandler({
|
|
17
|
+
client,
|
|
18
|
+
logger,
|
|
19
|
+
pollTimeoutMs = 30_000,
|
|
20
|
+
pollIntervalMs = 1_000,
|
|
21
|
+
clock = createDefaultClock(),
|
|
22
|
+
}) {
|
|
23
|
+
return async (c) => {
|
|
24
|
+
const correlationId = c.req.param("correlationId");
|
|
25
|
+
const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
|
|
26
|
+
const deadline = clock.now() + pollTimeoutMs;
|
|
27
|
+
|
|
28
|
+
while (clock.now() < deadline) {
|
|
29
|
+
try {
|
|
30
|
+
const result = await client.DrainInbox(
|
|
31
|
+
bridge.DrainInboxRequest.fromObject({
|
|
32
|
+
correlation_id: correlationId,
|
|
33
|
+
since_seq: sinceSeq,
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
if (result.messages?.length > 0) {
|
|
37
|
+
return c.json({ messages: result.messages });
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
logger.error?.("inbox", err);
|
|
41
|
+
return c.json({ error: "Inbox failure" }, 500);
|
|
42
|
+
}
|
|
43
|
+
await clock.sleep(pollIntervalMs);
|
|
44
|
+
}
|
|
45
|
+
return c.json({ messages: [] });
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,10 @@ export {
|
|
|
34
34
|
DEFAULT_TYPING_VERBS,
|
|
35
35
|
} from "./acknowledgement.js";
|
|
36
36
|
export { Dispatcher } from "./dispatcher.js";
|
|
37
|
+
export {
|
|
38
|
+
DefaultTenantResolver,
|
|
39
|
+
RegistryTenantResolver,
|
|
40
|
+
} from "./tenant-resolver.js";
|
|
37
41
|
export { TokenResolver } from "./token-resolver.js";
|
|
38
42
|
export {
|
|
39
43
|
CallbackHandlerError,
|
|
@@ -50,3 +54,4 @@ export {
|
|
|
50
54
|
} from "./callback-payload.js";
|
|
51
55
|
export { evaluateTrigger, parseIsoDuration } from "./triggers.js";
|
|
52
56
|
export { prepareLinkResume, createLinkCompleteHandler } from "./link-resume.js";
|
|
57
|
+
export { createInboxHandler } from "./inbox-handler.js";
|
package/src/rate-limit.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Sliding-window rate limiter. Operates on a caller-owned `dispatches: number[]`
|
|
3
5
|
* array of timestamps and returns a structured result so callers can both
|
|
@@ -6,15 +8,22 @@
|
|
|
6
8
|
export class RateLimiter {
|
|
7
9
|
#windowMs;
|
|
8
10
|
#max;
|
|
11
|
+
#clock;
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* @param {object} [options]
|
|
12
15
|
* @param {number} [options.windowMs] - Sliding window length in ms (default: 60_000)
|
|
13
16
|
* @param {number} [options.max] - Max dispatches allowed in the window (default: 5)
|
|
17
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
14
18
|
*/
|
|
15
|
-
constructor({
|
|
19
|
+
constructor({
|
|
20
|
+
windowMs = 60_000,
|
|
21
|
+
max = 5,
|
|
22
|
+
clock = createDefaultClock(),
|
|
23
|
+
} = {}) {
|
|
16
24
|
this.#windowMs = windowMs;
|
|
17
25
|
this.#max = max;
|
|
26
|
+
this.#clock = clock;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
/**
|
|
@@ -29,7 +38,7 @@ export class RateLimiter {
|
|
|
29
38
|
if (!Array.isArray(dispatches)) {
|
|
30
39
|
throw new Error("dispatches must be an array");
|
|
31
40
|
}
|
|
32
|
-
const now =
|
|
41
|
+
const now = this.#clock.now();
|
|
33
42
|
const cutoff = now - this.#windowMs;
|
|
34
43
|
let i = 0;
|
|
35
44
|
while (i < dispatches.length && dispatches[i] < cutoff) i++;
|
package/src/resume-scheduler.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
+
|
|
1
3
|
import { ElapsedScheduler } from "./elapsed-scheduler.js";
|
|
2
4
|
import { evaluateTrigger, parseIsoDuration } from "./triggers.js";
|
|
3
5
|
|
|
@@ -13,6 +15,7 @@ export class ResumeScheduler {
|
|
|
13
15
|
#buildResumeInputs;
|
|
14
16
|
#buildCallbackMeta;
|
|
15
17
|
#onDeclined;
|
|
18
|
+
#clock;
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* @param {object} options
|
|
@@ -23,6 +26,7 @@ export class ResumeScheduler {
|
|
|
23
26
|
* @param {(ctx: object) => object} [options.buildCallbackMeta]
|
|
24
27
|
* @param {(ctx: object) => object} [options.buildResumeInputs]
|
|
25
28
|
* @param {((ctx: object, outcome: object) => Promise<void>) | null} [options.onDeclined]
|
|
29
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
26
30
|
*/
|
|
27
31
|
constructor({
|
|
28
32
|
dispatcher,
|
|
@@ -32,6 +36,7 @@ export class ResumeScheduler {
|
|
|
32
36
|
buildCallbackMeta = (ctx) => ({ discussionId: ctx.discussion_id }),
|
|
33
37
|
buildResumeInputs = () => ({}),
|
|
34
38
|
onDeclined = null,
|
|
39
|
+
clock = createDefaultClock(),
|
|
35
40
|
}) {
|
|
36
41
|
if (!dispatcher) throw new Error("dispatcher is required");
|
|
37
42
|
if (!store) throw new Error("store is required");
|
|
@@ -51,12 +56,14 @@ export class ResumeScheduler {
|
|
|
51
56
|
this.#buildCallbackMeta = buildCallbackMeta;
|
|
52
57
|
this.#buildResumeInputs = buildResumeInputs;
|
|
53
58
|
this.#onDeclined = onDeclined;
|
|
59
|
+
this.#clock = clock;
|
|
54
60
|
this.#elapsed = new ElapsedScheduler({
|
|
55
61
|
onFire: (cid) => this.#fireElapsed(cid),
|
|
56
62
|
onError: (err, cid) =>
|
|
57
63
|
this.#logger?.error?.("resume.elapsed", err, {
|
|
58
64
|
correlation_id: cid,
|
|
59
65
|
}),
|
|
66
|
+
clock,
|
|
60
67
|
});
|
|
61
68
|
}
|
|
62
69
|
|
|
@@ -81,7 +88,7 @@ export class ResumeScheduler {
|
|
|
81
88
|
*/
|
|
82
89
|
enterRecess(ctx, correlationId, trigger, requester) {
|
|
83
90
|
if (!trigger) return;
|
|
84
|
-
const openedAt =
|
|
91
|
+
const openedAt = this.#clock.now();
|
|
85
92
|
ctx.open_rfcs[correlationId] = {
|
|
86
93
|
trigger,
|
|
87
94
|
opened_at: openedAt,
|
|
@@ -157,7 +164,7 @@ export class ResumeScheduler {
|
|
|
157
164
|
replies: ctx.history.length - rfc.history_index_at_open,
|
|
158
165
|
opened_at: rfc.opened_at,
|
|
159
166
|
};
|
|
160
|
-
if (evaluateTrigger(trigger, observed,
|
|
167
|
+
if (evaluateTrigger(trigger, observed, this.#clock.now()).fired) {
|
|
161
168
|
fired.push({ correlationId, rfc });
|
|
162
169
|
}
|
|
163
170
|
}
|
|
@@ -207,7 +214,7 @@ export class ResumeScheduler {
|
|
|
207
214
|
const result = await this.#redispatch(ctx, correlationId, historySince);
|
|
208
215
|
if (result.kind === "dispatched") {
|
|
209
216
|
this.cancelRecess(ctx, correlationId);
|
|
210
|
-
ctx.last_active_at =
|
|
217
|
+
ctx.last_active_at = this.#clock.now();
|
|
211
218
|
await this.#store.add(ctx);
|
|
212
219
|
await this.#store.flush();
|
|
213
220
|
}
|
package/src/server.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { bodyLimit } from "hono/body-limit";
|
|
3
|
-
import { serve } from "@hono/node-server";
|
|
1
|
+
import { createHttpService } from "@forwardimpact/libhttp";
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* Create the channel-agnostic HTTP server that bridges (ghbridge, msbridge)
|
|
7
5
|
* share. The server mounts two routes:
|
|
8
6
|
* - `OPTIONS|POST <webhookPath>` — channel-specific intake. The raw POST
|
|
9
7
|
* body is captured on `c.get("rawBody")` for signature verification.
|
|
10
|
-
* - `POST /api/callback/:token` — workflow → bridge reply
|
|
8
|
+
* - `POST /api/callback/:tenant_id/:token` — workflow → bridge reply
|
|
9
|
+
* intake. Single-tenant deployments hit the same route with the literal
|
|
10
|
+
* `default` segment; multi-tenant deployments with the resolved tenant.
|
|
11
11
|
*
|
|
12
12
|
* Handlers receive Hono's context `c` (matching the monorepo standard) and
|
|
13
13
|
* return a `Response` (or use `c.json` / `c.text` / `c.body`). The caller
|
|
@@ -24,16 +24,18 @@ import { serve } from "@hono/node-server";
|
|
|
24
24
|
* @param {(c: import("hono").Context) => Promise<Response> | Response} options.onWebhook
|
|
25
25
|
* @param {(c: import("hono").Context) => Promise<Response> | Response} options.onCallback
|
|
26
26
|
* @param {((c: import("hono").Context) => Promise<Response> | Response)} [options.onLinkComplete]
|
|
27
|
+
* @param {(c: import("hono").Context) => Promise<Response> | Response} [options.onInbox] - Long-poll inbox handler
|
|
27
28
|
* @returns {{ start: () => Promise<void>, stop: () => Promise<void>, app: import("hono").Hono, address: () => ({port: number} | null) }}
|
|
28
29
|
*/
|
|
29
30
|
export function createBridgeServer({
|
|
30
31
|
config,
|
|
31
32
|
logger,
|
|
32
|
-
tracer
|
|
33
|
+
tracer,
|
|
33
34
|
webhookPath,
|
|
34
35
|
onWebhook,
|
|
35
36
|
onCallback,
|
|
36
37
|
onLinkComplete,
|
|
38
|
+
onInbox,
|
|
37
39
|
}) {
|
|
38
40
|
if (!config) throw new Error("config is required");
|
|
39
41
|
if (!logger) throw new Error("logger is required");
|
|
@@ -45,86 +47,66 @@ export function createBridgeServer({
|
|
|
45
47
|
throw new Error("onCallback is required");
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
c.set("rawBody", buf);
|
|
67
|
-
}
|
|
68
|
-
await next();
|
|
69
|
-
});
|
|
50
|
+
// Lifecycle, security headers, body limit, and the health route are owned by
|
|
51
|
+
// `@forwardimpact/libhttp`. This factory only mounts the bridge routes (and
|
|
52
|
+
// the raw-body capture they depend on) through the `configure` callback.
|
|
53
|
+
return createHttpService({
|
|
54
|
+
name: "bridge",
|
|
55
|
+
config,
|
|
56
|
+
logger,
|
|
57
|
+
tracer,
|
|
58
|
+
configure(app) {
|
|
59
|
+
// Capture the raw POST body once, before downstream handlers parse it.
|
|
60
|
+
// Channel adapters use this buffer to verify HMAC signatures.
|
|
61
|
+
app.use("*", async (c, next) => {
|
|
62
|
+
if (c.req.method === "POST") {
|
|
63
|
+
const buf = Buffer.from(await c.req.raw.clone().arrayBuffer());
|
|
64
|
+
c.set("rawBody", buf);
|
|
65
|
+
}
|
|
66
|
+
await next();
|
|
67
|
+
});
|
|
70
68
|
|
|
71
|
-
|
|
69
|
+
app.options(webhookPath, (c) => c.body(null, 200));
|
|
72
70
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
app.post(webhookPath, async (c) => {
|
|
72
|
+
try {
|
|
73
|
+
return await onWebhook(c);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.error("bridge.webhook", err);
|
|
76
|
+
return c.json({ error: "Webhook failure" }, 500);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
81
79
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
app.post("/api/callback/:tenant_id/:token", async (c) => {
|
|
81
|
+
try {
|
|
82
|
+
return await onCallback(c);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logger.error("bridge.callback", err);
|
|
85
|
+
return c.json({ error: "Callback failure" }, 500);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
if (onLinkComplete) {
|
|
90
|
+
app.get("/api/link-complete", async (c) => {
|
|
91
|
+
try {
|
|
92
|
+
return await onLinkComplete(c);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.error("bridge.link-complete", err);
|
|
95
|
+
return c.json({ error: "Link completion failure" }, 500);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
98
|
}
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
99
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return { port: addr.port };
|
|
111
|
-
},
|
|
112
|
-
async start() {
|
|
113
|
-
const { host, port } = config;
|
|
114
|
-
await new Promise((resolve) => {
|
|
115
|
-
server = serve({ fetch: app.fetch, port, hostname: host }, (info) => {
|
|
116
|
-
logger.info("bridge.server", "listening", {
|
|
117
|
-
host,
|
|
118
|
-
port: info?.port ?? port,
|
|
119
|
-
});
|
|
120
|
-
resolve();
|
|
100
|
+
if (onInbox) {
|
|
101
|
+
app.get("/api/inbox/:correlationId", async (c) => {
|
|
102
|
+
try {
|
|
103
|
+
return await onInbox(c);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logger.error("bridge.inbox", err);
|
|
106
|
+
return c.json({ error: "Inbox failure" }, 500);
|
|
107
|
+
}
|
|
121
108
|
});
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
async stop() {
|
|
125
|
-
if (!server) return;
|
|
126
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
127
|
-
server = null;
|
|
109
|
+
}
|
|
128
110
|
},
|
|
129
|
-
};
|
|
111
|
+
});
|
|
130
112
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-agnostic tenant resolution.
|
|
3
|
+
*
|
|
4
|
+
* Bridges supply a resolver to libbridge primitives (`Dispatcher`,
|
|
5
|
+
* `CallbackRegistry`, callback handlers). The two implementations share a
|
|
6
|
+
* duck-typed surface — `resolve`, `resolveByRepo`, `resolveByTenantId` —
|
|
7
|
+
* so libbridge depends on the shape, not the implementation.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {object} Tenant
|
|
10
|
+
* @property {string} tenant_id
|
|
11
|
+
* @property {string} channel
|
|
12
|
+
* @property {string} channel_tenant_key
|
|
13
|
+
* @property {{owner: string, name: string}} [repo]
|
|
14
|
+
* @property {"pending_consent" | "active" | "revoked"} state
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} TenantResolver
|
|
17
|
+
* @property {(key: {channel: string, key: string}) => Promise<Tenant | null>} resolve
|
|
18
|
+
* @property {(repo: {owner: string, name: string}) => Promise<Tenant | null>} resolveByRepo
|
|
19
|
+
* @property {(key: {tenant_id: string}) => Promise<Tenant | null>} resolveByTenantId
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single-tenant resolver. Returns one fixed `default` tenant for every
|
|
24
|
+
* resolution call. Used in single-tenant deployments where the bridge does
|
|
25
|
+
* not reach `services/tenancy`.
|
|
26
|
+
*/
|
|
27
|
+
export class DefaultTenantResolver {
|
|
28
|
+
#default;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} options.channel
|
|
33
|
+
* @param {string} [options.channel_tenant_key]
|
|
34
|
+
* @param {{owner: string, name: string}} [options.repo]
|
|
35
|
+
*/
|
|
36
|
+
constructor({ channel, channel_tenant_key = "default", repo }) {
|
|
37
|
+
if (!channel) throw new Error("channel is required");
|
|
38
|
+
this.#default = {
|
|
39
|
+
tenant_id: "default",
|
|
40
|
+
channel,
|
|
41
|
+
channel_tenant_key,
|
|
42
|
+
repo,
|
|
43
|
+
state: "active",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {Promise<Tenant>} */
|
|
48
|
+
async resolve(_key) {
|
|
49
|
+
return this.#default;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @returns {Promise<Tenant>} */
|
|
53
|
+
async resolveByRepo(_repo) {
|
|
54
|
+
return this.#default;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {Promise<Tenant | null>} */
|
|
58
|
+
async resolveByTenantId({ tenant_id }) {
|
|
59
|
+
return tenant_id === "default" ? this.#default : null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Multi-tenant resolver. Wraps a `services/tenancy` gRPC client; returns
|
|
65
|
+
* only `active` tenants from `resolve` and `resolveByRepo` (callers must
|
|
66
|
+
* treat a `null` return as "no active tenant"). `resolveByTenantId` returns
|
|
67
|
+
* the registry row regardless of state so callback verification can compare
|
|
68
|
+
* the URL's tenant id against any known tenant.
|
|
69
|
+
*/
|
|
70
|
+
export class RegistryTenantResolver {
|
|
71
|
+
#client;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} options
|
|
75
|
+
* @param {{
|
|
76
|
+
* ResolveByChannelKey: (req: {channel: string, key: string}) => Promise<Tenant | null>,
|
|
77
|
+
* ResolveByRepo: (req: {owner: string, name: string}) => Promise<Tenant | null>,
|
|
78
|
+
* ResolveByTenantId: (req: {tenant_id: string}) => Promise<Tenant | null>,
|
|
79
|
+
* }} options.client - Duck-typed tenancy client (typed at construction)
|
|
80
|
+
*/
|
|
81
|
+
constructor({ client }) {
|
|
82
|
+
if (!client) throw new Error("client is required");
|
|
83
|
+
this.#client = client;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @returns {Promise<Tenant | null>} */
|
|
87
|
+
async resolve({ channel, key }) {
|
|
88
|
+
const t = await this.#client.ResolveByChannelKey({ channel, key });
|
|
89
|
+
return t?.state === "active" ? t : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @returns {Promise<Tenant | null>} */
|
|
93
|
+
async resolveByRepo({ owner, name }) {
|
|
94
|
+
const t = await this.#client.ResolveByRepo({ owner, name });
|
|
95
|
+
return t?.state === "active" ? t : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @returns {Promise<Tenant | null>} */
|
|
99
|
+
async resolveByTenantId({ tenant_id }) {
|
|
100
|
+
return this.#client.ResolveByTenantId({ tenant_id });
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/token-resolver.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ghuser } from "@forwardimpact/libtype";
|
|
2
2
|
|
|
3
|
-
/** Maps
|
|
3
|
+
/** Maps ghuser GetToken oneof + gRPC transport into a discriminated DispatchAuth result. */
|
|
4
4
|
export class TokenResolver {
|
|
5
5
|
#client;
|
|
6
6
|
|
|
7
|
-
/** @param {object} client -
|
|
7
|
+
/** @param {object} client - ghuser gRPC client */
|
|
8
8
|
constructor(client) {
|
|
9
|
-
if (!client) throw new Error("
|
|
9
|
+
if (!client) throw new Error("ghuser client is required");
|
|
10
10
|
this.#client = client;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/** @returns {Promise<{kind: string, token?: string, authorizeUrl?: string, error?: Error}>} */
|
|
14
14
|
async resolve(surface, surfaceUserId) {
|
|
15
15
|
try {
|
|
16
|
-
const req = new
|
|
16
|
+
const req = new ghuser.GetTokenRequest({
|
|
17
17
|
surface,
|
|
18
18
|
surface_user_id: surfaceUserId,
|
|
19
19
|
});
|