@forwardimpact/libbridge 0.1.7 → 0.1.10
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 +5 -5
- package/src/callback-handler.js +9 -4
- package/src/callback-payload.js +2 -3
- package/src/callback-registry.js +28 -11
- package/src/dispatcher.js +19 -6
- package/src/elapsed-scheduler.js +2 -3
- package/src/inbox-handler.js +2 -2
- package/src/index.js +6 -5
- package/src/link-resume.js +100 -9
- package/src/rate-limit.js +2 -7
- package/src/resume-scheduler.js +2 -3
- package/src/server.js +59 -90
- package/src/tenant-resolver.js +102 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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,13 +40,13 @@
|
|
|
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
|
-
"@forwardimpact/libutil": "^0.1.84"
|
|
45
|
-
"@hono/node-server": "^2.0.4",
|
|
46
|
-
"hono": "^4.12.23"
|
|
45
|
+
"@forwardimpact/libutil": "^0.1.84"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
49
|
-
"@forwardimpact/libmock": "^0.1.0"
|
|
48
|
+
"@forwardimpact/libmock": "^0.1.0",
|
|
49
|
+
"@forwardimpact/libtelemetry": "^0.1.0"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"bun": ">=1.2.0",
|
package/src/callback-handler.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
import { validateCallbackPayload } from "./callback-payload.js";
|
|
4
2
|
|
|
5
3
|
/**
|
|
@@ -60,8 +58,9 @@ export function createCallbackHandler({
|
|
|
60
58
|
loadDiscussionId,
|
|
61
59
|
ackFinishTarget,
|
|
62
60
|
handleReply,
|
|
63
|
-
clock
|
|
61
|
+
clock,
|
|
64
62
|
}) {
|
|
63
|
+
if (!clock) throw new Error("clock is required");
|
|
65
64
|
if (!channel) throw new Error("channel is required");
|
|
66
65
|
if (!callbacks) throw new Error("callbacks is required");
|
|
67
66
|
if (!ack) throw new Error("ack is required");
|
|
@@ -88,8 +87,14 @@ export function createCallbackHandler({
|
|
|
88
87
|
if (!payload) return c.json({ error: "Invalid payload" }, 400);
|
|
89
88
|
|
|
90
89
|
const token = c.req.param("token");
|
|
90
|
+
const tenant_id = c.req.param("tenant_id");
|
|
91
|
+
if (!tenant_id) {
|
|
92
|
+
return c.json({ error: "Unknown callback token" }, 404);
|
|
93
|
+
}
|
|
91
94
|
const isTerminal = payload.kind === "terminal";
|
|
92
|
-
const meta = isTerminal
|
|
95
|
+
const meta = isTerminal
|
|
96
|
+
? callbacks.consume(token, { tenant_id })
|
|
97
|
+
: callbacks.peek(token, { tenant_id });
|
|
93
98
|
if (!meta) {
|
|
94
99
|
logger.debug?.("callback", "unknown token");
|
|
95
100
|
return c.json({ error: "Unknown callback token" }, 404);
|
package/src/callback-payload.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
export const MAX_FIELD_LENGTH = 2000;
|
|
4
2
|
export const MAX_REPLY_COUNT = 50;
|
|
5
3
|
|
|
@@ -129,8 +127,9 @@ export function newDiscussionContext({
|
|
|
129
127
|
channel,
|
|
130
128
|
discussionId,
|
|
131
129
|
participant,
|
|
132
|
-
clock
|
|
130
|
+
clock,
|
|
133
131
|
}) {
|
|
132
|
+
if (!clock) throw new Error("clock is required");
|
|
134
133
|
return {
|
|
135
134
|
id: `${channel}:${discussionId}`,
|
|
136
135
|
channel,
|
package/src/callback-registry.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
4
|
-
|
|
5
3
|
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
6
4
|
|
|
7
5
|
/**
|
|
@@ -9,6 +7,11 @@ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
|
9
7
|
* the (token, correlationId) pairs via the discussion store so the registry
|
|
10
8
|
* can be rehydrated after a restart; this class only owns the live token →
|
|
11
9
|
* metadata mapping and TTL sweep.
|
|
10
|
+
*
|
|
11
|
+
* Every entry is tenant-bound. `register` requires `meta.tenant_id` (single
|
|
12
|
+
* tenant deployments pass `"default"`); `consume` and `peek` require the
|
|
13
|
+
* caller's `tenant_id` and return `null` when the stored value does not
|
|
14
|
+
* match — the same null shape callers already handle for unknown tokens.
|
|
12
15
|
*/
|
|
13
16
|
export class CallbackRegistry {
|
|
14
17
|
#ttlMs;
|
|
@@ -20,7 +23,8 @@ export class CallbackRegistry {
|
|
|
20
23
|
* @param {number} [options.ttlMs] - Time-to-live in ms (default: 2h)
|
|
21
24
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
22
25
|
*/
|
|
23
|
-
constructor({ ttlMs = DEFAULT_TTL_MS, clock
|
|
26
|
+
constructor({ ttlMs = DEFAULT_TTL_MS, clock } = {}) {
|
|
27
|
+
if (!clock) throw new Error("clock is required");
|
|
24
28
|
this.#ttlMs = ttlMs;
|
|
25
29
|
this.#clock = clock;
|
|
26
30
|
}
|
|
@@ -32,13 +36,16 @@ export class CallbackRegistry {
|
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
38
|
* @param {string} correlationId
|
|
35
|
-
* @param {object}
|
|
39
|
+
* @param {object} meta - Caller-defined metadata; `meta.tenant_id` is required
|
|
36
40
|
* @returns {string} The newly issued callback token
|
|
37
41
|
*/
|
|
38
|
-
register(correlationId, meta
|
|
42
|
+
register(correlationId, meta) {
|
|
39
43
|
if (typeof correlationId !== "string" || !correlationId) {
|
|
40
44
|
throw new Error("correlationId is required");
|
|
41
45
|
}
|
|
46
|
+
if (!meta || typeof meta.tenant_id !== "string" || !meta.tenant_id) {
|
|
47
|
+
throw new Error("meta.tenant_id is required");
|
|
48
|
+
}
|
|
42
49
|
const token = randomUUID();
|
|
43
50
|
this.#entries.set(token, {
|
|
44
51
|
correlationId,
|
|
@@ -49,28 +56,38 @@ export class CallbackRegistry {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
52
|
-
* Atomic lookup + delete. Returns null
|
|
59
|
+
* Atomic lookup + delete. Returns null when the token is unknown or when
|
|
60
|
+
* the supplied `tenant_id` does not match the stored binding.
|
|
53
61
|
* @param {string} token
|
|
62
|
+
* @param {{tenant_id: string}} bind
|
|
54
63
|
* @returns {{correlationId: string, meta: object, createdAt: number} | null}
|
|
55
64
|
*/
|
|
56
|
-
consume(token) {
|
|
65
|
+
consume(token, bind) {
|
|
66
|
+
if (!bind || typeof bind.tenant_id !== "string" || !bind.tenant_id) {
|
|
67
|
+
throw new Error("tenant_id is required");
|
|
68
|
+
}
|
|
57
69
|
const entry = this.#entries.get(token);
|
|
58
70
|
if (!entry) return null;
|
|
71
|
+
if (entry.meta.tenant_id !== bind.tenant_id) return null;
|
|
59
72
|
this.#entries.delete(token);
|
|
60
73
|
return entry;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/**
|
|
64
77
|
* Returns a shallow clone of the stored metadata for a token without
|
|
65
|
-
* consuming it.
|
|
66
|
-
*
|
|
67
|
-
* `correlationId`, `meta`, or `createdAt` work unchanged.
|
|
78
|
+
* consuming it. Returns null on unknown token or `tenant_id` mismatch —
|
|
79
|
+
* matching `consume`'s shape so callers handle one missing case.
|
|
68
80
|
* @param {string} token
|
|
81
|
+
* @param {{tenant_id: string}} bind
|
|
69
82
|
* @returns {{correlationId: string, meta: object, createdAt: number} | null}
|
|
70
83
|
*/
|
|
71
|
-
peek(token) {
|
|
84
|
+
peek(token, bind) {
|
|
85
|
+
if (!bind || typeof bind.tenant_id !== "string" || !bind.tenant_id) {
|
|
86
|
+
throw new Error("tenant_id is required");
|
|
87
|
+
}
|
|
72
88
|
const entry = this.#entries.get(token);
|
|
73
89
|
if (!entry) return null;
|
|
90
|
+
if (entry.meta.tenant_id !== bind.tenant_id) return null;
|
|
74
91
|
return { ...entry };
|
|
75
92
|
}
|
|
76
93
|
|
package/src/dispatcher.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
4
|
-
|
|
5
3
|
import { dispatchWorkflow } from "./dispatch.js";
|
|
6
4
|
|
|
7
5
|
/** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
|
|
@@ -13,6 +11,7 @@ export class Dispatcher {
|
|
|
13
11
|
#workflowFile;
|
|
14
12
|
#githubRepo;
|
|
15
13
|
#tokenResolver;
|
|
14
|
+
#tenantResolver;
|
|
16
15
|
#clock;
|
|
17
16
|
|
|
18
17
|
/**
|
|
@@ -24,6 +23,7 @@ export class Dispatcher {
|
|
|
24
23
|
* @param {string} options.workflowFile
|
|
25
24
|
* @param {string} options.githubRepo
|
|
26
25
|
* @param {import("./token-resolver.js").TokenResolver} options.tokenResolver
|
|
26
|
+
* @param {import("./tenant-resolver.js").TenantResolver} options.tenantResolver
|
|
27
27
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
28
28
|
*/
|
|
29
29
|
constructor({
|
|
@@ -34,7 +34,8 @@ export class Dispatcher {
|
|
|
34
34
|
workflowFile,
|
|
35
35
|
githubRepo,
|
|
36
36
|
tokenResolver,
|
|
37
|
-
|
|
37
|
+
tenantResolver,
|
|
38
|
+
clock,
|
|
38
39
|
}) {
|
|
39
40
|
if (!callbacks) throw new Error("callbacks is required");
|
|
40
41
|
if (!ack) throw new Error("ack is required");
|
|
@@ -45,6 +46,8 @@ export class Dispatcher {
|
|
|
45
46
|
if (!workflowFile) throw new Error("workflowFile is required");
|
|
46
47
|
if (!githubRepo) throw new Error("githubRepo is required");
|
|
47
48
|
if (!tokenResolver) throw new Error("tokenResolver is required");
|
|
49
|
+
if (!tenantResolver) throw new Error("tenantResolver is required");
|
|
50
|
+
if (!clock) throw new Error("clock is required");
|
|
48
51
|
this.#callbacks = callbacks;
|
|
49
52
|
this.#ack = ack;
|
|
50
53
|
this.#store = store;
|
|
@@ -52,6 +55,7 @@ export class Dispatcher {
|
|
|
52
55
|
this.#workflowFile = workflowFile;
|
|
53
56
|
this.#githubRepo = githubRepo;
|
|
54
57
|
this.#tokenResolver = tokenResolver;
|
|
58
|
+
this.#tenantResolver = tenantResolver;
|
|
55
59
|
this.#clock = clock;
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -80,12 +84,21 @@ export class Dispatcher {
|
|
|
80
84
|
const auth = await this.#tokenResolver.resolve(ctx.channel, requester);
|
|
81
85
|
if (auth.kind !== "token") return auth;
|
|
82
86
|
|
|
87
|
+
const tenant = await this.#tenantResolver.resolve({
|
|
88
|
+
channel: ctx.channel,
|
|
89
|
+
key: ctx.channel_tenant_key,
|
|
90
|
+
});
|
|
91
|
+
if (!tenant) {
|
|
92
|
+
return { kind: "transient", error: new Error("tenant_unresolved") };
|
|
93
|
+
}
|
|
94
|
+
const tenant_id = tenant.tenant_id;
|
|
95
|
+
|
|
83
96
|
const correlationId = randomUUID();
|
|
84
|
-
const mergedMeta = { ...(callbackMeta ?? {}), requester };
|
|
97
|
+
const mergedMeta = { ...(callbackMeta ?? {}), requester, tenant_id };
|
|
85
98
|
const token = this.#callbacks.register(correlationId, mergedMeta);
|
|
86
99
|
ctx.pending_callbacks[token] = correlationId;
|
|
87
100
|
ctx.active_requester = requester;
|
|
88
|
-
const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${token}`;
|
|
101
|
+
const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${tenant_id}/${token}`;
|
|
89
102
|
const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}`;
|
|
90
103
|
|
|
91
104
|
if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
|
|
@@ -107,7 +120,7 @@ export class Dispatcher {
|
|
|
107
120
|
return { kind: "dispatched", token, correlationId };
|
|
108
121
|
} catch (err) {
|
|
109
122
|
if (ackTarget !== undefined) await this.#ack.finish(token, ackTarget);
|
|
110
|
-
this.#callbacks.consume(token);
|
|
123
|
+
this.#callbacks.consume(token, { tenant_id });
|
|
111
124
|
delete ctx.pending_callbacks[token];
|
|
112
125
|
ctx.active_requester = null;
|
|
113
126
|
throw err;
|
package/src/elapsed-scheduler.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
const CHUNK_CAP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
2
|
|
|
5
3
|
/**
|
|
@@ -22,8 +20,9 @@ export class ElapsedScheduler {
|
|
|
22
20
|
* @param {(err: Error, correlationId: string) => void} [options.onError] - Invoked when `onFire` rejects.
|
|
23
21
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
24
22
|
*/
|
|
25
|
-
constructor({ onFire, onError = () => {}, clock
|
|
23
|
+
constructor({ onFire, onError = () => {}, clock }) {
|
|
26
24
|
if (typeof onFire !== "function") throw new Error("onFire is required");
|
|
25
|
+
if (!clock) throw new Error("clock is required");
|
|
27
26
|
this.#onFire = onFire;
|
|
28
27
|
this.#onError = onError;
|
|
29
28
|
this.#clock = clock;
|
package/src/inbox-handler.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { bridge } from "@forwardimpact/libtype";
|
|
2
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Long-poll handler for the per-correlation inbox. The run's InboxPoller
|
|
@@ -18,8 +17,9 @@ export function createInboxHandler({
|
|
|
18
17
|
logger,
|
|
19
18
|
pollTimeoutMs = 30_000,
|
|
20
19
|
pollIntervalMs = 1_000,
|
|
21
|
-
clock
|
|
20
|
+
clock,
|
|
22
21
|
}) {
|
|
22
|
+
if (!clock) throw new Error("clock is required");
|
|
23
23
|
return async (c) => {
|
|
24
24
|
const correlationId = c.req.param("correlationId");
|
|
25
25
|
const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
|
package/src/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @property {() => Promise<void>} flush
|
|
20
20
|
* @property {() => Promise<void>} shutdown
|
|
21
21
|
* @property {(target: object) => Promise<void>} [putPendingDispatch]
|
|
22
|
-
* @property {(linkToken: string) => Promise<object|null>} [resolvePendingDispatch]
|
|
22
|
+
* @property {(linkToken: string, expectedSurfaceUserId?: string) => Promise<object|null>} [resolvePendingDispatch]
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
export { createBridgeServer } from "./server.js";
|
|
@@ -29,11 +29,12 @@ export { appendHistory } from "./history.js";
|
|
|
29
29
|
export { RateLimiter } from "./rate-limit.js";
|
|
30
30
|
export { dispatchWorkflow } from "./dispatch.js";
|
|
31
31
|
export { ProgressTicker } from "./progress-ticker.js";
|
|
32
|
-
export {
|
|
33
|
-
Acknowledgement,
|
|
34
|
-
DEFAULT_TYPING_VERBS,
|
|
35
|
-
} from "./acknowledgement.js";
|
|
32
|
+
export { Acknowledgement, DEFAULT_TYPING_VERBS } from "./acknowledgement.js";
|
|
36
33
|
export { Dispatcher } from "./dispatcher.js";
|
|
34
|
+
export {
|
|
35
|
+
DefaultTenantResolver,
|
|
36
|
+
RegistryTenantResolver,
|
|
37
|
+
} from "./tenant-resolver.js";
|
|
37
38
|
export { TokenResolver } from "./token-resolver.js";
|
|
38
39
|
export {
|
|
39
40
|
CallbackHandlerError,
|
package/src/link-resume.js
CHANGED
|
@@ -1,25 +1,81 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isTrusted } from "@forwardimpact/libutil/trusted-origins";
|
|
3
|
+
import { verifyCompletionTicket } from "@forwardimpact/libutil/completion-ticket";
|
|
2
4
|
import { normalizeBaseUrl } from "./callback-payload.js";
|
|
3
5
|
import { buildPrompt } from "./prompt.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Prepare a link-resume URL for the IdP authorize step.
|
|
9
|
+
*
|
|
10
|
+
* Discriminated return so a missing `catch` cannot become a 5xx oracle in
|
|
11
|
+
* the caller. The keyword-arg shape makes "forgot to pass trustedOrigins"
|
|
12
|
+
* a loud boot-time `TypeError` for any future xbridge.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} args
|
|
15
|
+
* @param {string} args.authorizeUrl Upstream IdP authorize URL the bridge
|
|
16
|
+
* intends to post into the channel.
|
|
17
|
+
* @param {string} args.callbackBaseUrl Bridge's own callback base URL; the
|
|
18
|
+
* per-bridge `/api/link-complete` is composed from this.
|
|
19
|
+
* @param {Set<string>} args.trustedOrigins Trusted-origin set produced by
|
|
20
|
+
* `loadTrustedIdpOrigins`. Required — a missing or non-Set value throws.
|
|
21
|
+
* @returns {{linkToken: string, augmentedUrl: string} | {skipped: true, reason: string}}
|
|
22
|
+
* On a trusted, parseable URL: `{ linkToken, augmentedUrl }`. On any
|
|
23
|
+
* refusal: `{ skipped: true, reason: "untrusted_origin" }`.
|
|
9
24
|
*/
|
|
10
|
-
export function prepareLinkResume(
|
|
25
|
+
export function prepareLinkResume({
|
|
26
|
+
authorizeUrl,
|
|
27
|
+
callbackBaseUrl,
|
|
28
|
+
trustedOrigins,
|
|
29
|
+
}) {
|
|
30
|
+
if (!(trustedOrigins instanceof Set))
|
|
31
|
+
throw new TypeError("prepareLinkResume: trustedOrigins must be a Set");
|
|
32
|
+
let originUrl;
|
|
33
|
+
try {
|
|
34
|
+
originUrl = new URL(authorizeUrl);
|
|
35
|
+
} catch {
|
|
36
|
+
return { skipped: true, reason: "untrusted_origin" };
|
|
37
|
+
}
|
|
38
|
+
if (!isTrusted(originUrl.origin, trustedOrigins))
|
|
39
|
+
return { skipped: true, reason: "untrusted_origin" };
|
|
40
|
+
|
|
11
41
|
const linkToken = randomUUID();
|
|
12
|
-
|
|
13
|
-
url.searchParams.set(
|
|
42
|
+
originUrl.searchParams.set(
|
|
14
43
|
"redirect_uri",
|
|
15
44
|
`${normalizeBaseUrl(callbackBaseUrl)}/api/link-complete`,
|
|
16
45
|
);
|
|
17
|
-
|
|
18
|
-
return { linkToken, augmentedUrl:
|
|
46
|
+
originUrl.searchParams.set("client_state", linkToken);
|
|
47
|
+
return { linkToken, augmentedUrl: originUrl.toString() };
|
|
19
48
|
}
|
|
20
49
|
|
|
50
|
+
const UNABLE_TO_VERIFY_HTML =
|
|
51
|
+
"<!DOCTYPE html><html><body><h1>Unable to verify completion</h1>" +
|
|
52
|
+
"<p>The completion request could not be verified. Please try " +
|
|
53
|
+
"linking again from the conversation.</p></body></html>";
|
|
54
|
+
|
|
21
55
|
/**
|
|
56
|
+
* Factory for the `/api/link-complete` GET handler.
|
|
57
|
+
*
|
|
58
|
+
* Handler ordering: the ticket is verified **before** any store touch —
|
|
59
|
+
* an attacker without a valid ticket exits at the verify step and never
|
|
60
|
+
* sees a present-vs-absent timing oracle on `linkToken`.
|
|
61
|
+
*
|
|
62
|
+
* The `surface_user_id` cross-check is performed **server-side** by passing
|
|
63
|
+
* `verify.claims.surfaceUserId` as `expectedSurfaceUserId` to
|
|
64
|
+
* `store.resolvePendingDispatch`. The bridge refuses to consume the entry
|
|
65
|
+
* on mismatch — so an attacker who minted a valid ticket against the
|
|
66
|
+
* victim's `link_token` (e.g. by driving the IdP round-trip under their
|
|
67
|
+
* own account with `client_state=victim_link_token`) cannot drain the
|
|
68
|
+
* auto-resume affordance: the bridge returns `{ unattributable: true }`
|
|
69
|
+
* and the entry stays available for the legitimate user.
|
|
70
|
+
*
|
|
22
71
|
* @param {object} options
|
|
72
|
+
* @param {string} options.channel Channel id (e.g. `"github-discussions"`).
|
|
73
|
+
* @param {object} options.store
|
|
74
|
+
* @param {object} options.dispatcher
|
|
75
|
+
* @param {(ctx: object) => object} options.buildCallbackMeta
|
|
76
|
+
* @param {Set<string>} options.trustedOrigins Required.
|
|
77
|
+
* @param {string} options.ticketSecret Required.
|
|
78
|
+
* @param {{now: () => number}} options.clock Required.
|
|
23
79
|
* @returns {(c: import("hono").Context) => Promise<Response>}
|
|
24
80
|
*/
|
|
25
81
|
export function createLinkCompleteHandler({
|
|
@@ -27,7 +83,21 @@ export function createLinkCompleteHandler({
|
|
|
27
83
|
store,
|
|
28
84
|
dispatcher,
|
|
29
85
|
buildCallbackMeta,
|
|
86
|
+
trustedOrigins,
|
|
87
|
+
ticketSecret,
|
|
88
|
+
clock,
|
|
30
89
|
}) {
|
|
90
|
+
if (!(trustedOrigins instanceof Set))
|
|
91
|
+
throw new TypeError(
|
|
92
|
+
"createLinkCompleteHandler: trustedOrigins must be a Set",
|
|
93
|
+
);
|
|
94
|
+
if (typeof ticketSecret !== "string" || ticketSecret.length === 0)
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
"createLinkCompleteHandler: ticketSecret must be a non-empty string",
|
|
97
|
+
);
|
|
98
|
+
if (!clock || typeof clock.now !== "function")
|
|
99
|
+
throw new TypeError("createLinkCompleteHandler: clock is required");
|
|
100
|
+
|
|
31
101
|
return async (c) => {
|
|
32
102
|
const linkToken = c.req.query("state");
|
|
33
103
|
if (!linkToken) {
|
|
@@ -38,7 +108,22 @@ export function createLinkCompleteHandler({
|
|
|
38
108
|
);
|
|
39
109
|
}
|
|
40
110
|
|
|
41
|
-
const
|
|
111
|
+
const ticket = c.req.query("ticket");
|
|
112
|
+
const verify = verifyCompletionTicket({
|
|
113
|
+
ticket,
|
|
114
|
+
expected: { linkToken },
|
|
115
|
+
trustedOrigins,
|
|
116
|
+
secret: ticketSecret,
|
|
117
|
+
now: clock.now(),
|
|
118
|
+
});
|
|
119
|
+
if (!verify.ok) {
|
|
120
|
+
return c.html(UNABLE_TO_VERIFY_HTML);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const target = await store.resolvePendingDispatch(
|
|
124
|
+
linkToken,
|
|
125
|
+
verify.claims.surfaceUserId,
|
|
126
|
+
);
|
|
42
127
|
if (!target) {
|
|
43
128
|
return c.html(
|
|
44
129
|
"<!DOCTYPE html><html><body><h1>Already processed</h1>" +
|
|
@@ -46,6 +131,12 @@ export function createLinkCompleteHandler({
|
|
|
46
131
|
"</p></body></html>",
|
|
47
132
|
);
|
|
48
133
|
}
|
|
134
|
+
if (target.unattributable) {
|
|
135
|
+
// Bridge refused to consume because the ticket's surfaceUserId does
|
|
136
|
+
// not match the pending row. The pending entry is left intact for
|
|
137
|
+
// the legitimate user.
|
|
138
|
+
return c.html(UNABLE_TO_VERIFY_HTML);
|
|
139
|
+
}
|
|
49
140
|
|
|
50
141
|
const ctx = await store.loadByChannel(channel, target.discussion_id);
|
|
51
142
|
if (!ctx) {
|
package/src/rate-limit.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Sliding-window rate limiter. Operates on a caller-owned `dispatches: number[]`
|
|
5
3
|
* array of timestamps and returns a structured result so callers can both
|
|
@@ -16,11 +14,8 @@ export class RateLimiter {
|
|
|
16
14
|
* @param {number} [options.max] - Max dispatches allowed in the window (default: 5)
|
|
17
15
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
18
16
|
*/
|
|
19
|
-
constructor({
|
|
20
|
-
|
|
21
|
-
max = 5,
|
|
22
|
-
clock = createDefaultClock(),
|
|
23
|
-
} = {}) {
|
|
17
|
+
constructor({ windowMs = 60_000, max = 5, clock } = {}) {
|
|
18
|
+
if (!clock) throw new Error("clock is required");
|
|
24
19
|
this.#windowMs = windowMs;
|
|
25
20
|
this.#max = max;
|
|
26
21
|
this.#clock = clock;
|
package/src/resume-scheduler.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
import { ElapsedScheduler } from "./elapsed-scheduler.js";
|
|
4
2
|
import { evaluateTrigger, parseIsoDuration } from "./triggers.js";
|
|
5
3
|
|
|
@@ -36,7 +34,7 @@ export class ResumeScheduler {
|
|
|
36
34
|
buildCallbackMeta = (ctx) => ({ discussionId: ctx.discussion_id }),
|
|
37
35
|
buildResumeInputs = () => ({}),
|
|
38
36
|
onDeclined = null,
|
|
39
|
-
clock
|
|
37
|
+
clock,
|
|
40
38
|
}) {
|
|
41
39
|
if (!dispatcher) throw new Error("dispatcher is required");
|
|
42
40
|
if (!store) throw new Error("store is required");
|
|
@@ -49,6 +47,7 @@ export class ResumeScheduler {
|
|
|
49
47
|
if (onDeclined != null && typeof onDeclined !== "function") {
|
|
50
48
|
throw new Error("onDeclined must be a function");
|
|
51
49
|
}
|
|
50
|
+
if (!clock) throw new Error("clock is required");
|
|
52
51
|
this.#dispatcher = dispatcher;
|
|
53
52
|
this.#store = store;
|
|
54
53
|
this.#logger = logger ?? null;
|
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
|
|
@@ -30,7 +30,7 @@ import { serve } from "@hono/node-server";
|
|
|
30
30
|
export function createBridgeServer({
|
|
31
31
|
config,
|
|
32
32
|
logger,
|
|
33
|
-
tracer
|
|
33
|
+
tracer,
|
|
34
34
|
webhookPath,
|
|
35
35
|
onWebhook,
|
|
36
36
|
onCallback,
|
|
@@ -47,97 +47,66 @@ export function createBridgeServer({
|
|
|
47
47
|
throw new Error("onCallback is required");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
c.set("rawBody", buf);
|
|
69
|
-
}
|
|
70
|
-
await next();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
app.options(webhookPath, (c) => c.body(null, 200));
|
|
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
|
+
});
|
|
74
68
|
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
return await onWebhook(c);
|
|
78
|
-
} catch (err) {
|
|
79
|
-
logger.error("bridge.webhook", err);
|
|
80
|
-
return c.json({ error: "Webhook failure" }, 500);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
69
|
+
app.options(webhookPath, (c) => c.body(null, 200));
|
|
83
70
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
});
|
|
92
79
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
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
|
+
});
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
});
|
|
111
98
|
}
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let server = null;
|
|
116
99
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
async start() {
|
|
126
|
-
const { host, port } = config;
|
|
127
|
-
await new Promise((resolve) => {
|
|
128
|
-
server = serve({ fetch: app.fetch, port, hostname: host }, (info) => {
|
|
129
|
-
logger.info("bridge.server", "listening", {
|
|
130
|
-
host,
|
|
131
|
-
port: info?.port ?? port,
|
|
132
|
-
});
|
|
133
|
-
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
|
+
}
|
|
134
108
|
});
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
async stop() {
|
|
138
|
-
if (!server) return;
|
|
139
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
140
|
-
server = null;
|
|
109
|
+
}
|
|
141
110
|
},
|
|
142
|
-
};
|
|
111
|
+
});
|
|
143
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
|
+
}
|