@forwardimpact/libbridge 0.1.10 → 0.1.11

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.10",
3
+ "version": "0.1.11",
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",
@@ -111,7 +111,15 @@ export function createCallbackHandler({
111
111
  }
112
112
 
113
113
  const discussionId = loadDiscussionId(meta);
114
- const ctx = await store.loadByChannel(channel, discussionId);
114
+ // The dispatcher bound the resolved tenant on the callback token's domain
115
+ // meta (`default` in single-tenant). Thread it into the load so
116
+ // multi-tenant lookups hit the tenant-scoped key rather than re-resolving
117
+ // by channel (which the registry cannot do — the channel is not a key).
118
+ const ctx = await store.loadByChannel(
119
+ channel,
120
+ discussionId,
121
+ meta.meta?.tenant_id,
122
+ );
115
123
  if (!ctx) {
116
124
  logger.error?.("callback", "context missing", {
117
125
  discussion_id: discussionId,
package/src/dispatcher.js CHANGED
@@ -44,7 +44,12 @@ export class Dispatcher {
44
44
  throw new Error("callbackBaseUrl is required");
45
45
  }
46
46
  if (!workflowFile) throw new Error("workflowFile is required");
47
- if (!githubRepo) throw new Error("githubRepo is required");
47
+ // A non-empty static repo is required in single-tenant mode; multi-tenant
48
+ // mode derives the repo per request from the resolved tenant, so an empty
49
+ // string is accepted (the dispatch falls back to `tenant.repo`).
50
+ if (typeof githubRepo !== "string") {
51
+ throw new Error("githubRepo is required");
52
+ }
48
53
  if (!tokenResolver) throw new Error("tokenResolver is required");
49
54
  if (!tenantResolver) throw new Error("tenantResolver is required");
50
55
  if (!clock) throw new Error("clock is required");
@@ -81,9 +86,11 @@ export class Dispatcher {
81
86
  if (typeof prompt !== "string") throw new Error("prompt is required");
82
87
  if (typeof requester !== "string") throw new Error("requester is required");
83
88
 
84
- const auth = await this.#tokenResolver.resolve(ctx.channel, requester);
85
- if (auth.kind !== "token") return auth;
86
-
89
+ // Resolve the tenant before the dispatch credential. The hosted dispatch
90
+ // identity is a repo-scoped App installation token (see design § Hosted
91
+ // dispatch identity), so a token resolver that mints per repo needs the
92
+ // resolved tenant's `repo`. The third argument is additive — the
93
+ // self-hosted `TokenResolver` (per-user OAuth) ignores it.
87
94
  const tenant = await this.#tenantResolver.resolve({
88
95
  channel: ctx.channel,
89
96
  key: ctx.channel_tenant_key,
@@ -93,19 +100,33 @@ export class Dispatcher {
93
100
  }
94
101
  const tenant_id = tenant.tenant_id;
95
102
 
103
+ const auth = await this.#tokenResolver.resolve(
104
+ ctx.channel,
105
+ requester,
106
+ tenant,
107
+ );
108
+ if (auth.kind !== "token") return auth;
109
+
96
110
  const correlationId = randomUUID();
97
111
  const mergedMeta = { ...(callbackMeta ?? {}), requester, tenant_id };
98
112
  const token = this.#callbacks.register(correlationId, mergedMeta);
99
113
  ctx.pending_callbacks[token] = correlationId;
100
114
  ctx.active_requester = requester;
101
115
  const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${tenant_id}/${token}`;
102
- const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}`;
116
+ const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}?tenant_id=${encodeURIComponent(tenant_id)}`;
117
+
118
+ // The workflow_dispatch targets the resolved tenant's repository when the
119
+ // resolver supplies one (multi-tenant); otherwise the static configured
120
+ // repo (single-tenant). Both modes fire against the customer's runner.
121
+ const dispatchRepo = tenant.repo
122
+ ? `${tenant.repo.owner}/${tenant.repo.name}`
123
+ : this.#githubRepo;
103
124
 
104
125
  if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
105
126
  try {
106
127
  await dispatchWorkflow({
107
128
  workflowFile: this.#workflowFile,
108
- repo: this.#githubRepo,
129
+ repo: dispatchRepo,
109
130
  token: auth.token,
110
131
  prompt,
111
132
  callbackUrl,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Hosted dispatch identity for multi-tenant bridges. The `workflow_dispatch`
3
+ * credential is a repo-scoped GitHub App installation token minted by
4
+ * `services/ghserver` for the resolved tenant's repository — not the per-user
5
+ * OAuth token used by the self-hosted path (see design § Hosted dispatch
6
+ * identity). Both `services/ghbridge` and `services/msbridge` share this
7
+ * resolver in multi-tenant mode; the channel-specific reply credential (Bot
8
+ * Framework for Teams, App installation for GitHub) is unaffected.
9
+ *
10
+ * This resolver satisfies the same duck-typed surface the `Dispatcher`
11
+ * expects from a `TokenResolver`: `resolve(surface, requester, tenant) ->
12
+ * DispatchAuth`. The `tenant` argument carries the registry row (with its
13
+ * `repo`) the dispatcher resolved before requesting the credential, so the
14
+ * mint is scoped to exactly that repository — there is no per-user link step.
15
+ *
16
+ * Lives in libbridge (not a channel adapter) because it imports no channel
17
+ * SDK: it depends only on the duck-typed ghserver client, keeping libbridge's
18
+ * "no channel SDKs" invariant intact.
19
+ */
20
+ export class GhServerTokenResolver {
21
+ #client;
22
+ #requestedBy;
23
+
24
+ /**
25
+ * @param {object} client - ghserver gRPC client exposing
26
+ * `MintInstallationToken({owner, name, requested_by})`.
27
+ * @param {object} [options]
28
+ * @param {string} [options.requestedBy] - Audit tag forwarded as
29
+ * `requested_by` on the mint; identifies the calling bridge.
30
+ */
31
+ constructor(client, { requestedBy = "bridge" } = {}) {
32
+ if (!client) throw new Error("ghserver client is required");
33
+ this.#client = client;
34
+ this.#requestedBy = requestedBy;
35
+ }
36
+
37
+ /**
38
+ * @param {string} _surface - Unused; the App installation is repo-scoped.
39
+ * @param {string} _requester - Unused; the App authors the dispatch.
40
+ * @param {import("./tenant-resolver.js").Tenant} [tenant]
41
+ * @returns {Promise<{kind: string, token?: string, error?: Error}>}
42
+ */
43
+ async resolve(_surface, _requester, tenant) {
44
+ const repo = tenant?.repo;
45
+ if (!repo?.owner || !repo?.name) {
46
+ return {
47
+ kind: "transient",
48
+ error: new Error("tenant_repo_unresolved"),
49
+ };
50
+ }
51
+ try {
52
+ const { installation_token } = await this.#client.MintInstallationToken({
53
+ owner: repo.owner,
54
+ name: repo.name,
55
+ requested_by: this.#requestedBy,
56
+ });
57
+ return { kind: "token", token: installation_token };
58
+ } catch (err) {
59
+ return { kind: "transient", error: err };
60
+ }
61
+ }
62
+ }
@@ -23,6 +23,15 @@ export function createInboxHandler({
23
23
  return async (c) => {
24
24
  const correlationId = c.req.param("correlationId");
25
25
  const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
26
+ // The dispatcher embeds the resolved tenant on the inbox URL it hands the
27
+ // run (single-tenant binds the literal `default`). `services/bridge`
28
+ // rejects a `DrainInbox` without a `tenant_id`, so a poll that omits it is
29
+ // a caller error: fail fast rather than leaking a cross-tenant queue.
30
+ const tenant_id = c.req.query("tenant_id");
31
+ if (!tenant_id) {
32
+ logger.error?.("inbox", "missing tenant_id");
33
+ return c.json({ error: "tenant_id required" }, 400);
34
+ }
26
35
  const deadline = clock.now() + pollTimeoutMs;
27
36
 
28
37
  while (clock.now() < deadline) {
@@ -31,6 +40,7 @@ export function createInboxHandler({
31
40
  bridge.DrainInboxRequest.fromObject({
32
41
  correlation_id: correlationId,
33
42
  since_seq: sinceSeq,
43
+ tenant_id,
34
44
  }),
35
45
  );
36
46
  if (result.messages?.length > 0) {
package/src/index.js CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  RegistryTenantResolver,
37
37
  } from "./tenant-resolver.js";
38
38
  export { TokenResolver } from "./token-resolver.js";
39
+ export { GhServerTokenResolver } from "./ghserver-token-resolver.js";
39
40
  export {
40
41
  CallbackHandlerError,
41
42
  createCallbackHandler,