@forwardimpact/libbridge 0.1.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libbridge",
3
- "version": "0.1.9",
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",
@@ -45,7 +45,8 @@
45
45
  "@forwardimpact/libutil": "^0.1.84"
46
46
  },
47
47
  "devDependencies": {
48
- "@forwardimpact/libmock": "^0.1.0"
48
+ "@forwardimpact/libmock": "^0.1.0",
49
+ "@forwardimpact/libtelemetry": "^0.1.0"
49
50
  },
50
51
  "engines": {
51
52
  "bun": ">=1.2.0",
@@ -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 = createDefaultClock(),
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");
@@ -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 = createDefaultClock(),
130
+ clock,
133
131
  }) {
132
+ if (!clock) throw new Error("clock is required");
134
133
  return {
135
134
  id: `${channel}:${discussionId}`,
136
135
  channel,
@@ -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
  /**
@@ -25,7 +23,8 @@ export class CallbackRegistry {
25
23
  * @param {number} [options.ttlMs] - Time-to-live in ms (default: 2h)
26
24
  * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
27
25
  */
28
- constructor({ ttlMs = DEFAULT_TTL_MS, clock = createDefaultClock() } = {}) {
26
+ constructor({ ttlMs = DEFAULT_TTL_MS, clock } = {}) {
27
+ if (!clock) throw new Error("clock is required");
29
28
  this.#ttlMs = ttlMs;
30
29
  this.#clock = clock;
31
30
  }
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. */
@@ -37,7 +35,7 @@ export class Dispatcher {
37
35
  githubRepo,
38
36
  tokenResolver,
39
37
  tenantResolver,
40
- clock = createDefaultClock(),
38
+ clock,
41
39
  }) {
42
40
  if (!callbacks) throw new Error("callbacks is required");
43
41
  if (!ack) throw new Error("ack is required");
@@ -49,6 +47,7 @@ export class Dispatcher {
49
47
  if (!githubRepo) throw new Error("githubRepo is required");
50
48
  if (!tokenResolver) throw new Error("tokenResolver is required");
51
49
  if (!tenantResolver) throw new Error("tenantResolver is required");
50
+ if (!clock) throw new Error("clock is required");
52
51
  this.#callbacks = callbacks;
53
52
  this.#ack = ack;
54
53
  this.#store = store;
@@ -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 = createDefaultClock() }) {
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;
@@ -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 = createDefaultClock(),
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,10 +29,7 @@ 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";
37
34
  export {
38
35
  DefaultTenantResolver,
@@ -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
- * @param {string} authorizeUrl
7
- * @param {string} callbackBaseUrl
8
- * @returns {{ linkToken: string, augmentedUrl: string }}
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(authorizeUrl, callbackBaseUrl) {
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
- const url = new URL(authorizeUrl);
13
- url.searchParams.set(
42
+ originUrl.searchParams.set(
14
43
  "redirect_uri",
15
44
  `${normalizeBaseUrl(callbackBaseUrl)}/api/link-complete`,
16
45
  );
17
- url.searchParams.set("client_state", linkToken);
18
- return { linkToken, augmentedUrl: url.toString() };
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 target = await store.resolvePendingDispatch(linkToken);
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
- windowMs = 60_000,
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;
@@ -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 = createDefaultClock(),
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;