@forwardimpact/libbridge 0.1.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libbridge",
3
- "version": "0.1.7",
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,10 +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
- "@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
48
  "@forwardimpact/libmock": "^0.1.0"
@@ -88,8 +88,14 @@ export function createCallbackHandler({
88
88
  if (!payload) return c.json({ error: "Invalid payload" }, 400);
89
89
 
90
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
+ }
91
95
  const isTerminal = payload.kind === "terminal";
92
- const meta = isTerminal ? callbacks.consume(token) : callbacks.peek(token);
96
+ const meta = isTerminal
97
+ ? callbacks.consume(token, { tenant_id })
98
+ : callbacks.peek(token, { tenant_id });
93
99
  if (!meta) {
94
100
  logger.debug?.("callback", "unknown token");
95
101
  return c.json({ error: "Unknown callback token" }, 404);
@@ -9,6 +9,11 @@ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
9
9
  * the (token, correlationId) pairs via the discussion store so the registry
10
10
  * can be rehydrated after a restart; this class only owns the live token →
11
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.
12
17
  */
13
18
  export class CallbackRegistry {
14
19
  #ttlMs;
@@ -32,13 +37,16 @@ export class CallbackRegistry {
32
37
 
33
38
  /**
34
39
  * @param {string} correlationId
35
- * @param {object} [meta] - Caller-defined metadata stored alongside the token
40
+ * @param {object} meta - Caller-defined metadata; `meta.tenant_id` is required
36
41
  * @returns {string} The newly issued callback token
37
42
  */
38
- register(correlationId, meta = {}) {
43
+ register(correlationId, meta) {
39
44
  if (typeof correlationId !== "string" || !correlationId) {
40
45
  throw new Error("correlationId is required");
41
46
  }
47
+ if (!meta || typeof meta.tenant_id !== "string" || !meta.tenant_id) {
48
+ throw new Error("meta.tenant_id is required");
49
+ }
42
50
  const token = randomUUID();
43
51
  this.#entries.set(token, {
44
52
  correlationId,
@@ -49,28 +57,38 @@ export class CallbackRegistry {
49
57
  }
50
58
 
51
59
  /**
52
- * Atomic lookup + delete. Returns null if the token is unknown.
60
+ * Atomic lookup + delete. Returns null when the token is unknown or when
61
+ * the supplied `tenant_id` does not match the stored binding.
53
62
  * @param {string} token
63
+ * @param {{tenant_id: string}} bind
54
64
  * @returns {{correlationId: string, meta: object, createdAt: number} | null}
55
65
  */
56
- 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
+ }
57
70
  const entry = this.#entries.get(token);
58
71
  if (!entry) return null;
72
+ if (entry.meta.tenant_id !== bind.tenant_id) return null;
59
73
  this.#entries.delete(token);
60
74
  return entry;
61
75
  }
62
76
 
63
77
  /**
64
78
  * Returns a shallow clone of the stored metadata for a token without
65
- * consuming it. Cloning prevents callers from corrupting internal state
66
- * via the returned reference; diagnostic code paths that need to read
67
- * `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.
68
81
  * @param {string} token
82
+ * @param {{tenant_id: string}} bind
69
83
  * @returns {{correlationId: string, meta: object, createdAt: number} | null}
70
84
  */
71
- 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
+ }
72
89
  const entry = this.#entries.get(token);
73
90
  if (!entry) return null;
91
+ if (entry.meta.tenant_id !== bind.tenant_id) return null;
74
92
  return { ...entry };
75
93
  }
76
94
 
package/src/dispatcher.js CHANGED
@@ -13,6 +13,7 @@ export class Dispatcher {
13
13
  #workflowFile;
14
14
  #githubRepo;
15
15
  #tokenResolver;
16
+ #tenantResolver;
16
17
  #clock;
17
18
 
18
19
  /**
@@ -24,6 +25,7 @@ export class Dispatcher {
24
25
  * @param {string} options.workflowFile
25
26
  * @param {string} options.githubRepo
26
27
  * @param {import("./token-resolver.js").TokenResolver} options.tokenResolver
28
+ * @param {import("./tenant-resolver.js").TenantResolver} options.tenantResolver
27
29
  * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
28
30
  */
29
31
  constructor({
@@ -34,6 +36,7 @@ export class Dispatcher {
34
36
  workflowFile,
35
37
  githubRepo,
36
38
  tokenResolver,
39
+ tenantResolver,
37
40
  clock = createDefaultClock(),
38
41
  }) {
39
42
  if (!callbacks) throw new Error("callbacks is required");
@@ -45,6 +48,7 @@ export class Dispatcher {
45
48
  if (!workflowFile) throw new Error("workflowFile is required");
46
49
  if (!githubRepo) throw new Error("githubRepo is required");
47
50
  if (!tokenResolver) throw new Error("tokenResolver is required");
51
+ if (!tenantResolver) throw new Error("tenantResolver is required");
48
52
  this.#callbacks = callbacks;
49
53
  this.#ack = ack;
50
54
  this.#store = store;
@@ -52,6 +56,7 @@ export class Dispatcher {
52
56
  this.#workflowFile = workflowFile;
53
57
  this.#githubRepo = githubRepo;
54
58
  this.#tokenResolver = tokenResolver;
59
+ this.#tenantResolver = tenantResolver;
55
60
  this.#clock = clock;
56
61
  }
57
62
 
@@ -80,12 +85,21 @@ export class Dispatcher {
80
85
  const auth = await this.#tokenResolver.resolve(ctx.channel, requester);
81
86
  if (auth.kind !== "token") return auth;
82
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
+
83
97
  const correlationId = randomUUID();
84
- const mergedMeta = { ...(callbackMeta ?? {}), requester };
98
+ const mergedMeta = { ...(callbackMeta ?? {}), requester, tenant_id };
85
99
  const token = this.#callbacks.register(correlationId, mergedMeta);
86
100
  ctx.pending_callbacks[token] = correlationId;
87
101
  ctx.active_requester = requester;
88
- const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${token}`;
102
+ const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${tenant_id}/${token}`;
89
103
  const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}`;
90
104
 
91
105
  if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
@@ -107,7 +121,7 @@ export class Dispatcher {
107
121
  return { kind: "dispatched", token, correlationId };
108
122
  } catch (err) {
109
123
  if (ackTarget !== undefined) await this.#ack.finish(token, ackTarget);
110
- this.#callbacks.consume(token);
124
+ this.#callbacks.consume(token, { tenant_id });
111
125
  delete ctx.pending_callbacks[token];
112
126
  ctx.active_requester = null;
113
127
  throw err;
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,
package/src/server.js CHANGED
@@ -1,13 +1,13 @@
1
- import { Hono } from "hono";
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 intake.
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: _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
- const app = new Hono();
51
-
52
- // Security headers standard hardening for a backend service.
53
- app.use("*", async (c, next) => {
54
- await next();
55
- c.header("X-Content-Type-Options", "nosniff");
56
- c.header("X-Frame-Options", "DENY");
57
- c.header("Cache-Control", "no-store");
58
- });
59
-
60
- // Request body size limit 1 MB is generous for JSON callback payloads.
61
- app.use("*", bodyLimit({ maxSize: 1024 * 1024 }));
62
-
63
- // Capture the raw POST body once, before downstream handlers parse it.
64
- // Channel adapters use this buffer to verify HMAC signatures.
65
- app.use("*", async (c, next) => {
66
- if (c.req.method === "POST") {
67
- const buf = Buffer.from(await c.req.raw.clone().arrayBuffer());
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
- app.post(webhookPath, async (c) => {
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
- app.post("/api/callback/:token", async (c) => {
85
- try {
86
- return await onCallback(c);
87
- } catch (err) {
88
- logger.error("bridge.callback", err);
89
- return c.json({ error: "Callback failure" }, 500);
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
- if (onLinkComplete) {
94
- app.get("/api/link-complete", async (c) => {
95
- try {
96
- return await onLinkComplete(c);
97
- } catch (err) {
98
- logger.error("bridge.link-complete", err);
99
- return c.json({ error: "Link completion failure" }, 500);
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
- if (onInbox) {
105
- app.get("/api/inbox/:correlationId", async (c) => {
106
- try {
107
- return await onInbox(c);
108
- } catch (err) {
109
- logger.error("bridge.inbox", err);
110
- return c.json({ error: "Inbox failure" }, 500);
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
- return {
118
- app,
119
- address() {
120
- if (!server || typeof server.address !== "function") return null;
121
- const addr = server.address();
122
- if (!addr || typeof addr === "string") return null;
123
- return { port: addr.port };
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
+ }