@moda-labs/bobi-events-core 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/chat-sdk-slack.d.ts +18 -0
  4. package/dist/adapters/chat-sdk-slack.d.ts.map +1 -0
  5. package/dist/adapters/chat-sdk-slack.js +201 -0
  6. package/dist/adapters/chat-sdk-slack.js.map +1 -0
  7. package/dist/adapters/github.d.ts +3 -0
  8. package/dist/adapters/github.d.ts.map +1 -0
  9. package/dist/adapters/github.js +114 -0
  10. package/dist/adapters/github.js.map +1 -0
  11. package/dist/adapters/linear.d.ts +3 -0
  12. package/dist/adapters/linear.d.ts.map +1 -0
  13. package/dist/adapters/linear.js +47 -0
  14. package/dist/adapters/linear.js.map +1 -0
  15. package/dist/adapters/whatsapp.d.ts +25 -0
  16. package/dist/adapters/whatsapp.d.ts.map +1 -0
  17. package/dist/adapters/whatsapp.js +96 -0
  18. package/dist/adapters/whatsapp.js.map +1 -0
  19. package/dist/channels.d.ts +80 -0
  20. package/dist/channels.d.ts.map +1 -0
  21. package/dist/channels.js +499 -0
  22. package/dist/channels.js.map +1 -0
  23. package/dist/circuit-breaker.d.ts +79 -0
  24. package/dist/circuit-breaker.d.ts.map +1 -0
  25. package/dist/circuit-breaker.js +288 -0
  26. package/dist/circuit-breaker.js.map +1 -0
  27. package/dist/conversation.d.ts +29 -0
  28. package/dist/conversation.d.ts.map +1 -0
  29. package/dist/conversation.js +86 -0
  30. package/dist/conversation.js.map +1 -0
  31. package/dist/core.d.ts +254 -0
  32. package/dist/core.d.ts.map +1 -0
  33. package/dist/core.js +1775 -0
  34. package/dist/core.js.map +1 -0
  35. package/package.json +40 -0
  36. package/src/adapters/chat-sdk-slack.ts +209 -0
  37. package/src/adapters/github.ts +111 -0
  38. package/src/adapters/linear.ts +53 -0
  39. package/src/adapters/whatsapp.ts +120 -0
  40. package/src/channels.ts +600 -0
  41. package/src/circuit-breaker.ts +337 -0
  42. package/src/conversation.ts +96 -0
  43. package/src/core.ts +2413 -0
package/dist/core.js ADDED
@@ -0,0 +1,1775 @@
1
+ /**
2
+ * Resolve the per-bot record for an inbound event's api_app_id, with
3
+ * read-migration so pre-multi-bot single-bot records still resolve.
4
+ */
5
+ export function getSlackBotForApp(ws, apiAppId) {
6
+ if (!ws)
7
+ return null;
8
+ if (apiAppId && ws.bots && ws.bots[apiAppId])
9
+ return ws.bots[apiAppId];
10
+ // Single registered bot: use it even if api_app_id is absent/unmatched
11
+ // (defensive — a client may not have sourced api_app_id).
12
+ if (ws.bots) {
13
+ const vals = Object.values(ws.bots);
14
+ if (vals.length === 1)
15
+ return vals[0];
16
+ }
17
+ // Legacy single-bot fields.
18
+ if (ws.bot_token || ws.bot_id) {
19
+ return { bot_token: ws.bot_token ?? "", bot_id: ws.bot_id };
20
+ }
21
+ return null;
22
+ }
23
+ /**
24
+ * Every bot id we own in this workspace, across ALL registered apps (+ legacy).
25
+ * The self-reply filter skips a message authored by ANY of these — not just the
26
+ * RECEIVING app's bot. When two of our apps share a channel, Slack delivers each
27
+ * app's events to BOTH apps' webhooks, so one bot's message arrives via the
28
+ * other bot's webhook (a different api_app_id); keying "self" to the receiving
29
+ * app alone let that message through and it looped.
30
+ */
31
+ export function workspaceBotIds(ws) {
32
+ const ids = new Set();
33
+ if (!ws)
34
+ return ids;
35
+ if (ws.bots)
36
+ for (const b of Object.values(ws.bots))
37
+ if (b.bot_id)
38
+ ids.add(b.bot_id);
39
+ if (ws.bot_id)
40
+ ids.add(ws.bot_id);
41
+ return ids;
42
+ }
43
+ /**
44
+ * Slack user ids for bot users belonging to the receiving app. Slack emits a
45
+ * channel mention as both app_mention and message.*, and only the app_mention
46
+ * should reach chat delivery; otherwise the drain posts two placeholders for
47
+ * one ts. Self-authorship filtering is workspace-wide; mention dedupe must stay
48
+ * app-scoped so one bot does not swallow thread replies mentioning another.
49
+ */
50
+ export function workspaceBotUserIds(ws, apiAppId) {
51
+ const ids = new Set();
52
+ if (!ws?.bots)
53
+ return ids;
54
+ if (apiAppId && ws.bots[apiAppId]?.bot_user_id) {
55
+ ids.add(ws.bots[apiAppId].bot_user_id);
56
+ return ids;
57
+ }
58
+ const bots = Object.values(ws.bots);
59
+ if (bots.length === 1 && bots[0].bot_user_id) {
60
+ ids.add(bots[0].bot_user_id);
61
+ }
62
+ return ids;
63
+ }
64
+ /**
65
+ * The signing secret to verify an inbound Slack webhook with: the authoring
66
+ * app's per-app secret if registered, else the global fallback (legacy
67
+ * single-app deployments). Keeps single-app working without a redeploy.
68
+ */
69
+ export function resolveSlackSigningSecret(ws, payload, fallback) {
70
+ const apiAppId = payload.api_app_id || "";
71
+ const rec = getSlackBotForApp(ws, apiAppId);
72
+ return rec?.signing_secret || fallback || "";
73
+ }
74
+ /** Storage-aware convenience: load the workspace then resolve the signing secret. */
75
+ export async function slackSigningSecretFor(storage, payload, fallback) {
76
+ const teamId = payload.team_id || "";
77
+ const ws = teamId ? await storage.getSlackWorkspace(teamId) : null;
78
+ return resolveSlackSigningSecret(ws, payload, fallback);
79
+ }
80
+ // The bare topic plus its source-qualified spelling (e.g. "monitor/support.email")
81
+ // so subscriptions written either way match (#235). The single helper both
82
+ // createTopicEvent and createIngestEvent route through, so the two spellings
83
+ // can never drift between the signed-publish and token-ingest paths.
84
+ export function sourceQualifiedTopics(topic, source) {
85
+ const topics = [topic];
86
+ if (source && !topic.startsWith(`${source}/`)) {
87
+ topics.push(`${source}/${topic}`);
88
+ }
89
+ return topics;
90
+ }
91
+ export function createTopicEvent(topic, body, bubbleId) {
92
+ const payload = body.payload || body;
93
+ // Build topics from any routing fields in the body
94
+ const topics = [];
95
+ if (body.repo)
96
+ topics.push(`github:${body.repo}`);
97
+ if (body.team_key)
98
+ topics.push(`linear:${body.team_key}`);
99
+ if (body.workspace)
100
+ topics.push(`slack:${body.workspace}`);
101
+ // Fallback: the topic path itself acts as the subscription key, plus the
102
+ // source-qualified form (e.g. "monitor/support.email") so subscriptions
103
+ // written as the full event string match too. Publishers strip the source
104
+ // to the body when POSTing (see the Python event publisher) — without
105
+ // this, "source/type" subscriptions silently never match (#235).
106
+ if (topics.length === 0) {
107
+ topics.push(...sourceQualifiedTopics(topic, body.source));
108
+ }
109
+ const text = body.text || "";
110
+ return {
111
+ v: 2,
112
+ id: body.id || crypto.randomUUID(),
113
+ source: body.source || "custom",
114
+ type: topic,
115
+ timestamp: new Date().toISOString(),
116
+ topics,
117
+ delivery: body.delivery || "bulk",
118
+ text,
119
+ // Reply address survives publish/forward: an agent re-publishing a chat
120
+ // event must not strip the field the receiver needs for `bobi reply`.
121
+ ...(typeof body.conversation === "string" && body.conversation
122
+ ? { conversation: body.conversation }
123
+ : {}),
124
+ fields: body.fields,
125
+ run_key: body.run_key,
126
+ payload,
127
+ bubble_id: bubbleId,
128
+ };
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Adapters — canonical implementations live in adapters/*.ts.
132
+ // Re-exported here so existing imports from core continue to work.
133
+ // ---------------------------------------------------------------------------
134
+ import { normalizeGitHubWebhook } from "./adapters/github.js";
135
+ import { normalizeLinearWebhook } from "./adapters/linear.js";
136
+ import { bridgeSlackWebhook } from "./adapters/chat-sdk-slack.js";
137
+ import { normalizeWhatsAppWebhook } from "./adapters/whatsapp.js";
138
+ import { chunkForChannel, getChannelAdapter, slackApiUrl, truncateForChannel, whatsappApi } from "./channels.js";
139
+ import { parseConversation } from "./conversation.js";
140
+ export { normalizeGitHubWebhook as normalizeGitHubPayload } from "./adapters/github.js";
141
+ export { normalizeLinearWebhook as normalizeLinearPayload } from "./adapters/linear.js";
142
+ // ---------------------------------------------------------------------------
143
+ // Routing — topics-based (v2)
144
+ // ---------------------------------------------------------------------------
145
+ // Webhook resource topics that stay GLOBAL (cross-bubble) in v1. Inbound
146
+ // webhooks fan out to every subscribing bubble regardless of bubble — an
147
+ // accepted cross-tenant read hole, to be closed by #239 (inbound subscription
148
+ // auth). Slack inbound rides this path, so it keeps working. Everything else
149
+ // (inbox/*, reply/*, monitor/*, agent/*, custom topics) is bubble-scoped.
150
+ const GLOBAL_TOPIC_PREFIXES = ["github:", "linear:", "slack:", "whatsapp:"];
151
+ export function isGlobalTopic(key) {
152
+ return GLOBAL_TOPIC_PREFIXES.some((p) => key.startsWith(p));
153
+ }
154
+ // Normalize a resource string so the grant key and the topic never diverge on
155
+ // case/alias (#488 §3.3). github `owner/repo` is lowercased and a trailing
156
+ // `.git` stripped (matching the git-remote slug the adapter produces); linear
157
+ // team keys and slack team ids are left verbatim (case-significant upstream).
158
+ // Applied IDENTICALLY at authorize time and at topic parsing.
159
+ export function normalizeResource(service, resource) {
160
+ let r = resource.trim();
161
+ if (service === "github") {
162
+ r = r.toLowerCase();
163
+ if (r.endsWith(".git"))
164
+ r = r.slice(0, -4);
165
+ }
166
+ return r;
167
+ }
168
+ // Parse a GLOBAL topic key into its {service, resource} for a grant lookup —
169
+ // the inverse of how topics are built, and the single helper both enforcement
170
+ // layers (registration + delivery) use so they can never disagree. Split on the
171
+ // FIRST `:` (github `owner/repo` and linear keys never contain a `:`). Slack
172
+ // topics may be `slack:{team}` OR `slack:{team}:{channel}`; the grant is keyed
173
+ // on the TEAM id, so the channel segment is dropped here. Returns null for a
174
+ // non-global or malformed key.
175
+ export function parseGlobalTopic(key) {
176
+ if (!isGlobalTopic(key))
177
+ return null;
178
+ const idx = key.indexOf(":");
179
+ if (idx <= 0)
180
+ return null;
181
+ const service = key.slice(0, idx);
182
+ let resource = key.slice(idx + 1);
183
+ if (!resource)
184
+ return null;
185
+ // Slack channel-scoped topic — the grant gates the whole team, so reduce
186
+ // `team:channel` to `team` before normalizing.
187
+ if (service === "slack") {
188
+ const c = resource.indexOf(":");
189
+ if (c >= 0)
190
+ resource = resource.slice(0, c);
191
+ }
192
+ resource = normalizeResource(service, resource);
193
+ if (!resource)
194
+ return null;
195
+ return { service, resource };
196
+ }
197
+ // The single source of truth for bubble namespacing — used identically when
198
+ // REGISTERING a subscription and when computing an event's delivery keys, so a
199
+ // publish and a subscription can only ever match within the same bubble.
200
+ // Global webhook topics are never namespaced. A non-global key with no bubble
201
+ // context (e.g. an unauthenticated publish) is returned bare — it then matches
202
+ // no bubble-namespaced subscription, so it silently reaches nobody.
203
+ export function namespaceSubKey(bubbleId, key) {
204
+ if (isGlobalTopic(key))
205
+ return key;
206
+ if (!bubbleId)
207
+ return key;
208
+ return `${bubbleId}:${key}`;
209
+ }
210
+ export function subscriptionKeysForEvent(event) {
211
+ const topics = event.topics?.length ? event.topics : [event.type];
212
+ return topics.map((t) => namespaceSubKey(event.bubble_id, t));
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // Signature verification
216
+ // ---------------------------------------------------------------------------
217
+ function bytesToHex(buffer) {
218
+ return Array.from(new Uint8Array(buffer))
219
+ .map((b) => b.toString(16).padStart(2, "0"))
220
+ .join("");
221
+ }
222
+ async function hmacSha256Hex(secret, data) {
223
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
224
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
225
+ // Cast: BufferSource wants an ArrayBuffer-backed view; the buffer is
226
+ // never a SharedArrayBuffer here, but Uint8Array's default type argument
227
+ // (ArrayBufferLike) can't prove that across lib variants.
228
+ return bytesToHex(await crypto.subtle.sign("HMAC", key, bytes));
229
+ }
230
+ // SHA-256 hex digest — the one-way transform between a plaintext ingest token
231
+ // (on the wire) and the stored token_hash. Lookup by digest also gives a
232
+ // constant-time-safe comparison for free: the attacker-controlled token is
233
+ // hashed before any store access, so no stored byte is ever compared
234
+ // positionally against attacker input.
235
+ export async function sha256Hex(data) {
236
+ return bytesToHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)));
237
+ }
238
+ // Constant-time string compare. Portable across Node and the Cloudflare
239
+ // Worker runtime — `crypto.subtle.timingSafeEqual` does NOT exist (Node's
240
+ // is `node:crypto.timingSafeEqual`, Workers expose neither uniformly), so we
241
+ // do a length-check then XOR-accumulate over char codes. A length mismatch
242
+ // returns fast, which is fine: the compared values here are fixed-length hex
243
+ // digests, so length never leaks the secret — only that the attacker sent the
244
+ // wrong size. Never feed attacker-variable-length input expecting secrecy of
245
+ // length; always compare equal-length digests.
246
+ export function constantTimeEqual(a, b) {
247
+ if (a.length !== b.length)
248
+ return false;
249
+ let diff = 0;
250
+ for (let i = 0; i < a.length; i++) {
251
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
252
+ }
253
+ return diff === 0;
254
+ }
255
+ // Allowed signature algorithms (the `x-moda-algo` header). The field is
256
+ // reserved so Ed25519 can slot in later (epic auth-v1); for now the server
257
+ // rejects anything else rather than trusting the client's choice.
258
+ const ALLOWED_BUBBLE_ALGOS = new Set(["hmac-sha256"]);
259
+ // Canonical string signed by every authenticated bubble request. The nonce
260
+ // field is included NOW so the wire format is forward-compatible with
261
+ // server-side replay dedup (deferred to a hardening follow-up — #240 does not
262
+ // yet maintain the seen-set). `path` is the exact bytes on the wire
263
+ // (pathname + search); `body` is the exact transmitted bytes (the client
264
+ // signs what it sends, never a re-serialization). timestamp is epoch SECONDS.
265
+ export function bubbleCanonicalString(timestamp, nonce, method, path, body) {
266
+ return `${timestamp}\n${nonce}\n${method.toUpperCase()}\n${path}\n${body}`;
267
+ }
268
+ export async function buildBubbleSignature(secret, timestamp, nonce, method, path, body) {
269
+ return hmacSha256Hex(secret, bubbleCanonicalString(timestamp, nonce, method, path, body));
270
+ }
271
+ // Verify a bubble-signed request. Mirrors the CONSTRUCTION of
272
+ // verifySlackSignature (timestamp window + HMAC-SHA256) but uses a
273
+ // constant-time comparison. Rejects unknown algorithms and stale timestamps
274
+ // (±300s replay window). Returns false on any failure — callers respond with
275
+ // an opaque 403 and should perform a dummy HMAC on bubble-miss so that
276
+ // miss and signature-mismatch are timing-indistinguishable (no bubble_id
277
+ // enumeration).
278
+ export async function verifyBubbleSignature(input) {
279
+ const { secret, algo, timestamp, nonce, method, path, body, signature } = input;
280
+ if (!timestamp || !nonce || !signature)
281
+ return false;
282
+ if (!ALLOWED_BUBBLE_ALGOS.has(algo))
283
+ return false;
284
+ const ts = parseInt(timestamp, 10);
285
+ if (!Number.isFinite(ts))
286
+ return false;
287
+ const age = Math.abs(Date.now() / 1000 - ts);
288
+ if (age > 300)
289
+ return false;
290
+ const expected = await buildBubbleSignature(secret, timestamp, nonce, method, path, body);
291
+ return constantTimeEqual(expected, signature);
292
+ }
293
+ export async function verifySlackSignature(secret, timestamp, body, signature) {
294
+ if (!timestamp || !signature)
295
+ return false;
296
+ const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
297
+ if (age > 300)
298
+ return false;
299
+ const hexSig = "v0=" + (await hmacSha256Hex(secret, `v0:${timestamp}:${body}`));
300
+ return constantTimeEqual(hexSig, signature);
301
+ }
302
+ export async function verifyGitHubSignature(secret, body, signatureHeader) {
303
+ if (!signatureHeader)
304
+ return false;
305
+ const expected = "sha256=" + (await hmacSha256Hex(secret, body));
306
+ return constantTimeEqual(expected, signatureHeader);
307
+ }
308
+ // Linear replay window for the signed webhookTimestamp (ms since epoch),
309
+ // matching the ±300s the slack and bubble verifiers use.
310
+ const LINEAR_REPLAY_WINDOW_MS = 300_000;
311
+ // Linear signs the raw body with HMAC-SHA256 (hex) in the `linear-signature`
312
+ // header. `webhookTimestamp` is the payload's signed timestamp field; like
313
+ // verifySlackSignature, the freshness window lives INSIDE the verifier so a
314
+ // direct caller cannot get signature validation without replay protection.
315
+ // FAIL CLOSED on a missing or non-numeric timestamp: the signature covers the
316
+ // body, so its absence in a signed payload still leaves the request replayable
317
+ // forever if admitted.
318
+ export async function verifyLinearSignature(secret, body, signatureHeader, webhookTimestamp) {
319
+ if (!signatureHeader)
320
+ return false;
321
+ if (typeof webhookTimestamp !== "number" ||
322
+ Math.abs(Date.now() - webhookTimestamp) > LINEAR_REPLAY_WINDOW_MS) {
323
+ return false;
324
+ }
325
+ const expected = await hmacSha256Hex(secret, body);
326
+ return constantTimeEqual(expected, signatureHeader);
327
+ }
328
+ export function readBubbleAuthHeaders(get, method, path, rawBody) {
329
+ return {
330
+ bubbleId: get("x-moda-bubble") || "",
331
+ algo: get("x-moda-algo") || "",
332
+ timestamp: get("x-moda-timestamp") || "",
333
+ nonce: get("x-moda-nonce") || "",
334
+ signature: get("x-moda-signature") || "",
335
+ method,
336
+ path,
337
+ rawBody,
338
+ };
339
+ }
340
+ export function hasBubbleSignature(ctx) {
341
+ return !!(ctx.bubbleId && ctx.signature && ctx.timestamp && ctx.nonce);
342
+ }
343
+ // True when SOME but not all signing headers are present — a malformed request
344
+ // (e.g. a proxy stripped a header). Registration must reject these rather than
345
+ // silently falling back to MINT, which would fork the session into a new
346
+ // bubble. A genuine mint carries NO signing headers.
347
+ export function hasPartialBubbleSignature(ctx) {
348
+ const any = !!(ctx.bubbleId || ctx.signature || ctx.timestamp || ctx.nonce || ctx.algo);
349
+ return any && !hasBubbleSignature(ctx);
350
+ }
351
+ // A fixed dummy key used to run a constant-cost HMAC when the claimed bubble
352
+ // does not exist, so a bubble-miss and a signature-mismatch take the same time
353
+ // — an attacker cannot enumerate valid bubble_ids by timing.
354
+ const DUMMY_BUBBLE_KEY = "bkey_0000000000000000000000000000000000000000000000000000000000000000";
355
+ const _rejectionCounters = {
356
+ bad_signature: 0,
357
+ stale_timestamp: 0,
358
+ unknown_bubble: 0,
359
+ webhook_unverified: 0,
360
+ webhook_bad_signature: 0,
361
+ };
362
+ export function getAuthRejectionCounters() {
363
+ return { ..._rejectionCounters };
364
+ }
365
+ export function resetAuthRejectionCounters() {
366
+ _rejectionCounters.bad_signature = 0;
367
+ _rejectionCounters.stale_timestamp = 0;
368
+ _rejectionCounters.unknown_bubble = 0;
369
+ _rejectionCounters.webhook_unverified = 0;
370
+ _rejectionCounters.webhook_bad_signature = 0;
371
+ }
372
+ // Resolve and verify the bubble that signed a request. Returns the bubble on a
373
+ // valid signature, else null (callers respond with an opaque 403). Always
374
+ // performs an HMAC even on bubble-miss to keep timing uniform. Increments
375
+ // rejection counters on failure so /health can surface misconfigured clients.
376
+ export async function authenticateBubble(storage, ctx) {
377
+ const bubble = ctx.bubbleId ? await storage.getBubble(ctx.bubbleId) : null;
378
+ const secret = bubble?.key ?? DUMMY_BUBBLE_KEY;
379
+ // Check for stale timestamp before HMAC — the verifier rejects it anyway,
380
+ // but we want to classify the rejection reason for observability.
381
+ const ts = parseInt(ctx.timestamp, 10);
382
+ const isStale = Number.isFinite(ts) && Math.abs(Date.now() / 1000 - ts) > 300;
383
+ const ok = await verifyBubbleSignature({
384
+ secret,
385
+ algo: ctx.algo,
386
+ timestamp: ctx.timestamp,
387
+ nonce: ctx.nonce,
388
+ method: ctx.method,
389
+ path: ctx.path,
390
+ body: ctx.rawBody,
391
+ signature: ctx.signature,
392
+ });
393
+ if (!ok || !bubble) {
394
+ // Classify the rejection for the counter.
395
+ if (isStale) {
396
+ _rejectionCounters.stale_timestamp++;
397
+ }
398
+ else if (!bubble && ctx.bubbleId) {
399
+ _rejectionCounters.unknown_bubble++;
400
+ }
401
+ else {
402
+ _rejectionCounters.bad_signature++;
403
+ }
404
+ return null;
405
+ }
406
+ return bubble;
407
+ }
408
+ function randomToken(prefix) {
409
+ return `${prefix}_${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
410
+ }
411
+ // ---------------------------------------------------------------------------
412
+ // Transport-agnostic handlers
413
+ // ---------------------------------------------------------------------------
414
+ export async function authenticateDeployment(storage, apiKey, deploymentId) {
415
+ const deployment = await storage.getDeploymentByApiKey(apiKey);
416
+ if (!deployment || deployment.id !== deploymentId)
417
+ return null;
418
+ return deployment;
419
+ }
420
+ export async function handleGitHubWebhook(storage, eventHeader, deliveryId, payload) {
421
+ const event = normalizeGitHubWebhook(eventHeader, deliveryId, payload);
422
+ if (!event)
423
+ return { status: 400, body: { error: "no repository in payload" } };
424
+ const delivered = await storage.deliver(event);
425
+ return { status: 200, body: { delivered_to: delivered } };
426
+ }
427
+ export async function handleLinearWebhook(storage, payload, deliveryId = "", waitUntil) {
428
+ const deliveryKey = deliveryId ? await sha256Hex(deliveryId) : "";
429
+ if (deliveryId) {
430
+ const existing = await storage.getWebhookDelivery("linear", deliveryKey);
431
+ if (existing)
432
+ return { status: 200, body: { ok: true } };
433
+ }
434
+ const event = normalizeLinearWebhook(payload, deliveryId);
435
+ const delivery = storage.deliver(event).then(async (delivered) => {
436
+ if (deliveryId) {
437
+ await storage.putWebhookDelivery({
438
+ source: "linear",
439
+ delivery_id: deliveryId,
440
+ delivery_key: deliveryKey,
441
+ received_at: new Date().toISOString(),
442
+ });
443
+ }
444
+ return delivered;
445
+ });
446
+ if (waitUntil) {
447
+ waitUntil(delivery.then(() => undefined).catch((err) => {
448
+ console.error("linear webhook delivery failed", err);
449
+ }));
450
+ return { status: 200, body: { queued: true } };
451
+ }
452
+ const delivered = await delivery;
453
+ return { status: 200, body: { delivered_to: delivered } };
454
+ }
455
+ // `body` is the raw webhook JSON string; `payload` its parsed form (the
456
+ // pipeline parses once before verification and passes both). Normalization
457
+ // runs through the Chat SDK bridge (#628) — the hand-rolled
458
+ // normalizeSlackWebhook remains only as the golden parity reference until
459
+ // the bridge has soaked (#629).
460
+ export async function handleSlackWebhook(storage, body, payload) {
461
+ const teamId = payload.team_id || "";
462
+ const apiAppId = payload.api_app_id || "";
463
+ let selfBotIds;
464
+ let selfBotUserIds;
465
+ if (teamId) {
466
+ const ws = await storage.getSlackWorkspace(teamId);
467
+ // Skip messages authored by ANY of our bots in this workspace — not just
468
+ // the bot of the app that received this webhook. Two of our apps in one
469
+ // channel each receive the other's messages (with their own api_app_id),
470
+ // so a per-receiving-app "self" id let one bot's message loop in via the
471
+ // other's webhook.
472
+ selfBotIds = workspaceBotIds(ws);
473
+ selfBotUserIds = workspaceBotUserIds(ws, apiAppId);
474
+ }
475
+ const result = bridgeSlackWebhook(body, selfBotIds, selfBotUserIds, payload);
476
+ if (result.challenge !== undefined) {
477
+ return { status: 200, body: { challenge: result.challenge } };
478
+ }
479
+ if (result.skip || !result.event) {
480
+ return { status: 200, body: { ok: true } };
481
+ }
482
+ const delivered = await storage.deliver(result.event);
483
+ return { status: 200, body: { delivered_to: delivered } };
484
+ }
485
+ const INVALID_SIGNATURE = { status: 401, body: { error: "invalid signature" } };
486
+ const INVALID_JSON = { status: 400, body: { error: "invalid JSON" } };
487
+ // Ingest rejections are an opaque 403 (per #640 acceptance): missing, unknown,
488
+ // revoked, and wrong-topic tokens are indistinguishable to the caller.
489
+ const INGEST_FORBIDDEN = { status: 403, body: { error: "forbidden" } };
490
+ // Ingest request-size cap. Generic publishes have no explicit byte cap today
491
+ // (they are bubble-signed, so the publisher is trusted); ingest is the first
492
+ // surface where the sender holds only a topic-scoped credential, so it gets
493
+ // an explicit one. Sized for alerting/CI/SaaS webhook payloads with headroom.
494
+ export const INGEST_MAX_BODY_BYTES = 256 * 1024;
495
+ // Per-token fixed-window rate limit. In-memory: authoritative on the local
496
+ // server (single process); per-isolate on the Worker, where it still bounds
497
+ // abuse per point of presence — a shared Durable Object limiter is deliberate
498
+ // non-scope for #640.
499
+ export const INGEST_RATE_LIMIT = 60;
500
+ export const INGEST_RATE_WINDOW_MS = 60_000;
501
+ const _ingestRateWindows = new Map();
502
+ // Entries for tokens that stop sending (revoked, retired CI tokens) would
503
+ // otherwise accrete forever in this long-lived process; sweep expired windows
504
+ // once the map is large. O(n) only when the sweep fires.
505
+ const INGEST_RATE_SWEEP_THRESHOLD = 1024;
506
+ function ingestRateAllow(tokenId) {
507
+ const now = Date.now();
508
+ if (_ingestRateWindows.size > INGEST_RATE_SWEEP_THRESHOLD) {
509
+ for (const [id, w] of _ingestRateWindows) {
510
+ if (now - w.start >= INGEST_RATE_WINDOW_MS)
511
+ _ingestRateWindows.delete(id);
512
+ }
513
+ }
514
+ const win = _ingestRateWindows.get(tokenId);
515
+ if (!win || now - win.start >= INGEST_RATE_WINDOW_MS) {
516
+ _ingestRateWindows.set(tokenId, { start: now, count: 1 });
517
+ return true;
518
+ }
519
+ win.count++;
520
+ return win.count <= INGEST_RATE_LIMIT;
521
+ }
522
+ export function resetIngestRateLimiter() {
523
+ _ingestRateWindows.clear();
524
+ }
525
+ // Build the event an ingest request delivers: the raw JSON body rides as the
526
+ // payload with its top-level primitives mirrored into `fields`, on exactly the
527
+ // token's bound topic (bubble-scoped). Deliberately NOT createTopicEvent: that
528
+ // helper trusts routing fields in the body (repo/team_key/workspace → global
529
+ // topics), and an ingest body is attacker-adjacent external input that must
530
+ // never influence routing beyond the token's binding.
531
+ export function createIngestEvent(topic, body, bubbleId) {
532
+ const fields = {};
533
+ for (const [k, v] of Object.entries(body)) {
534
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
535
+ fields[k] = v;
536
+ }
537
+ }
538
+ return {
539
+ v: 2,
540
+ id: crypto.randomUUID(),
541
+ source: "ingest",
542
+ type: topic,
543
+ timestamp: new Date().toISOString(),
544
+ // Bare topic plus the source-qualified form, mirroring createTopicEvent's
545
+ // fallback (#235), so both subscription spellings match.
546
+ topics: [topic, `ingest/${topic}`],
547
+ delivery: "bulk",
548
+ text: typeof body.text === "string" ? body.text : "",
549
+ fields,
550
+ payload: body,
551
+ bubble_id: bubbleId,
552
+ };
553
+ }
554
+ // Serve a provider's GET verification handshake, if it defines one. Returns
555
+ // null when the source has no handshake (or is unregistered) - the transport
556
+ // falls through to its native 404.
557
+ export function handleWebhookHandshake(source, query, secrets) {
558
+ const def = WEBHOOK_SOURCES[source];
559
+ if (!def?.handshake)
560
+ return null;
561
+ return def.handshake(query, secrets);
562
+ }
563
+ const WEBHOOK_SOURCES = {
564
+ github: {
565
+ async verify(_storage, req, _payload, secret) {
566
+ if (!secret)
567
+ return unverifiedAdmission();
568
+ const valid = await verifyGitHubSignature(secret, new TextEncoder().encode(req.rawBody), req.header("x-hub-signature-256"));
569
+ return valid ? null : INVALID_SIGNATURE;
570
+ },
571
+ handle(storage, req, payload) {
572
+ return handleGitHubWebhook(storage, req.header("x-github-event") || "unknown", req.header("x-github-delivery") || crypto.randomUUID(), payload);
573
+ },
574
+ },
575
+ linear: {
576
+ async verify(_storage, req, payload, secret) {
577
+ if (!secret)
578
+ return unverifiedAdmission();
579
+ const valid = await verifyLinearSignature(secret, req.rawBody, req.header("linear-signature"), payload.webhookTimestamp);
580
+ return valid ? null : INVALID_SIGNATURE;
581
+ },
582
+ handle(storage, req, payload, ctx) {
583
+ return handleLinearWebhook(storage, payload, req.header("linear-delivery"), ctx.waitUntil);
584
+ },
585
+ },
586
+ slack: {
587
+ preVerify(req, payload) {
588
+ // url_verification must run before BOTH the retry short-circuit and the
589
+ // signature check: it carries no signing headers, and Slack retries a
590
+ // failed handshake with x-slack-retry-num set — swallowing retries here
591
+ // would leave the request URL permanently unverified.
592
+ if (payload.type === "url_verification") {
593
+ return { status: 200, body: { challenge: payload.challenge } };
594
+ }
595
+ // Dedup retried EVENT deliveries so the agent doesn't double-process.
596
+ if (req.header("x-slack-retry-num")) {
597
+ return { status: 200, body: { ok: true } };
598
+ }
599
+ return null;
600
+ },
601
+ async verify(storage, req, payload, secret) {
602
+ // Verify against the AUTHORING app's signing secret (resolved by
603
+ // api_app_id), falling back to the global secret for legacy single-app
604
+ // deployments. A second app in the workspace signs with its OWN secret;
605
+ // validating only the global one 401'd it (and dropped its login DM).
606
+ const signingSecret = await slackSigningSecretFor(storage, payload, secret);
607
+ if (!signingSecret)
608
+ return unverifiedAdmission();
609
+ const valid = await verifySlackSignature(signingSecret, req.header("x-slack-request-timestamp"), req.rawBody, req.header("x-slack-signature"));
610
+ return valid ? null : INVALID_SIGNATURE;
611
+ },
612
+ handle(storage, req, payload) {
613
+ return handleSlackWebhook(storage, req.rawBody, payload);
614
+ },
615
+ },
616
+ whatsapp: {
617
+ // Meta verifies the webhook URL with a GET subscribe handshake; the
618
+ // challenge must echo back as raw text. An unset verify token rejects:
619
+ // echoing for anyone would let a third party bind our URL to their app.
620
+ handshake(query, secrets) {
621
+ const token = secrets.whatsappVerifyToken || "";
622
+ if (token
623
+ && query("hub.mode") === "subscribe"
624
+ && constantTimeEqual(token, query("hub.verify_token"))) {
625
+ return { status: 200, text: query("hub.challenge") };
626
+ }
627
+ return { status: 403, text: "forbidden" };
628
+ },
629
+ async verify(_storage, req, _payload, secret) {
630
+ // FAIL CLOSED, unlike github/slack: an unverified WhatsApp event is
631
+ // not a read-only notification - it injects a chat message that
632
+ // drives an outbound reply through the operator's real number and
633
+ // opens the conversation's 24h send window. Meta always signs, and
634
+ // there is no legacy-unverified deployment base to stay compatible
635
+ // with, so a missing secret rejects instead of admitting.
636
+ if (!secret) {
637
+ return {
638
+ status: 401,
639
+ body: { error: "whatsapp webhook verification not configured (set the app secret)" },
640
+ };
641
+ }
642
+ // Meta signs exactly like GitHub: HMAC-SHA256 over the raw body in
643
+ // x-hub-signature-256 - one verifier serves both.
644
+ const valid = await verifyGitHubSignature(secret, new TextEncoder().encode(req.rawBody), req.header("x-hub-signature-256"));
645
+ return valid ? null : INVALID_SIGNATURE;
646
+ },
647
+ handle(storage, _req, payload) {
648
+ return handleWhatsAppWebhook(storage, payload);
649
+ },
650
+ },
651
+ // Generic ingress (#640): POST /webhooks/ingest/<topic> with
652
+ // `Authorization: Bearer <ingest token>`. The verify slot is the token
653
+ // check itself — existence, topic binding, rate limit — so the structural
654
+ // guarantee (#639) holds: this route cannot exist unverified. The body
655
+ // size cap is declared here but enforced by the pipeline BEFORE the body
656
+ // is parsed.
657
+ ingest: {
658
+ topicRoute: true,
659
+ maxBodyBytes: INGEST_MAX_BODY_BYTES,
660
+ async verify(storage, req, _payload, _secret, ctx) {
661
+ // RFC 7235: the auth scheme is case-insensitive; several webhook
662
+ // senders emit "bearer".
663
+ const m = req.header("authorization").match(/^Bearer\s+(\S+)\s*$/i);
664
+ if (!m)
665
+ return INGEST_FORBIDDEN;
666
+ const record = await storage.getIngestTokenByHash(await sha256Hex(m[1]));
667
+ // One opaque 403 for unknown/revoked AND wrong-topic: a topic-A token
668
+ // probing topic B learns nothing beyond "not authorized here".
669
+ if (!record || record.topic !== req.subpath)
670
+ return INGEST_FORBIDDEN;
671
+ if (!ingestRateAllow(record.id)) {
672
+ return { status: 429, body: { error: "rate limited" } };
673
+ }
674
+ ctx.ingestToken = record;
675
+ return null;
676
+ },
677
+ async handle(storage, _req, payload, ctx) {
678
+ // The verified token is the ONLY routing input — topic and bubble come
679
+ // from its binding, never from the path or body. The verify slot set
680
+ // it; a missing record here is a pipeline invariant violation, not a
681
+ // client error.
682
+ const record = ctx.ingestToken;
683
+ if (!record)
684
+ return { status: 500, body: { error: "internal error" } };
685
+ const event = createIngestEvent(record.topic, payload, record.bubble_id);
686
+ const delivered = await storage.deliver(event);
687
+ return { status: 200, body: { delivered_to: delivered } };
688
+ },
689
+ },
690
+ };
691
+ // Storage key for a conversation's message-window bookkeeping (#656). Written
692
+ // on every inbound message; read by handleChannelsSend when the channel
693
+ // declares a messageWindow. Channel-generic so future windowed channels reuse
694
+ // it.
695
+ export function channelWindowKey(source, scope, chatId) {
696
+ return `window:${source}:${scope}:${chatId}`;
697
+ }
698
+ export async function handleWhatsAppWebhook(storage, payload) {
699
+ const { events } = normalizeWhatsAppWebhook(payload);
700
+ // Record the customer-service window: each inbound message re-opens 24h of
701
+ // free-form replies for its conversation (checked at send time). Stamp the
702
+ // message's OWN timestamp (newest per conversation) and never regress an
703
+ // existing record - Meta redelivers failed webhooks for days, and a stale
704
+ // redelivery must not falsely reopen a closed window.
705
+ const latest = new Map();
706
+ for (const event of events) {
707
+ const ref = event.conversation;
708
+ if (!ref)
709
+ continue;
710
+ const ts = Date.parse(String(event.fields?.message_timestamp ?? ""));
711
+ const stamp = Number.isNaN(ts) ? Date.now() : ts;
712
+ if (stamp > (latest.get(ref) ?? 0))
713
+ latest.set(ref, stamp);
714
+ }
715
+ for (const [ref, stamp] of latest) {
716
+ const conv = parseConversation(ref);
717
+ if (!conv)
718
+ continue;
719
+ const key = channelWindowKey(conv.source, conv.scope, conv.chatId);
720
+ const existing = await storage.getChannelState(key);
721
+ const prev = Date.parse(existing?.last_inbound || "");
722
+ if (!Number.isNaN(prev) && prev >= stamp)
723
+ continue;
724
+ await storage.putChannelState(key, { last_inbound: new Date(stamp).toISOString() });
725
+ }
726
+ let delivered = 0;
727
+ for (const event of events) {
728
+ delivered += await storage.deliver(event);
729
+ }
730
+ return { status: 200, body: { ok: true, delivered_to: delivered } };
731
+ }
732
+ // An admit-without-verification, counted so /health surfaces a provider
733
+ // running unverified (the misconfiguration class this pipeline exists to
734
+ // close). Returns null — the pipeline admits the request.
735
+ function unverifiedAdmission() {
736
+ _rejectionCounters.webhook_unverified++;
737
+ return null;
738
+ }
739
+ // Match a request path against the registered webhook routes — the single
740
+ // route grammar both transports use; they gate the body read on this, so an
741
+ // unregistered path 404s without ever consuming the request body. Exact
742
+ // sources match `/webhooks/<source>` only (AT MOST one trailing slash, the
743
+ // pre-#640 grammar — `/webhooks/github//` stays a 404); topic sources REQUIRE
744
+ // a non-empty remainder, which may itself contain slashes, again with at most
745
+ // one trailing slash tolerated.
746
+ export function matchWebhookSource(path) {
747
+ const m = path.match(/^\/webhooks\/([^/]+)(?:\/(.*))?$/);
748
+ if (!m)
749
+ return null;
750
+ const source = m[1];
751
+ const def = WEBHOOK_SOURCES[source];
752
+ if (!def)
753
+ return null;
754
+ if (def.topicRoute) {
755
+ // A remainder that still ends in "/" after stripping one had multiple
756
+ // trailing slashes — malformed, and no mintable topic could match it.
757
+ const rest = (m[2] ?? "").replace(/\/$/, "");
758
+ return rest && !rest.endsWith("/") ? { source, subpath: rest } : null;
759
+ }
760
+ // Exact route: the remainder must be absent (`/webhooks/github`) or empty
761
+ // (`/webhooks/github/`) on the wire — anything else, including `//`, 404s.
762
+ return m[2] === undefined || m[2] === "" ? { source, subpath: "" } : null;
763
+ }
764
+ // Run an inbound webhook through the pipeline. Returns null for an
765
+ // unregistered source (the transport falls through to its native 404;
766
+ // transports that gate on matchWebhookSource never hit this).
767
+ export async function handleWebhookRequest(storage, source, req, secrets, options = {}) {
768
+ const def = WEBHOOK_SOURCES[source];
769
+ if (!def)
770
+ return null;
771
+ // Size gate BEFORE parse: for a capped source, an oversize body must never
772
+ // reach JSON.parse — the cap exists to bound work done for an untrusted
773
+ // sender, and parsing is the work.
774
+ if (def.maxBodyBytes && req.rawBody.length > def.maxBodyBytes) {
775
+ return { status: 413, body: { error: "payload too large" } };
776
+ }
777
+ let payload;
778
+ try {
779
+ payload = JSON.parse(req.rawBody);
780
+ }
781
+ catch {
782
+ return INVALID_JSON;
783
+ }
784
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
785
+ return INVALID_JSON;
786
+ }
787
+ const body = payload;
788
+ const early = def.preVerify?.(req, body);
789
+ if (early)
790
+ return early;
791
+ const ctx = { waitUntil: options.waitUntil };
792
+ const secret = secrets[source] || "";
793
+ const rejected = await def.verify(storage, req, body, secret, ctx);
794
+ if (rejected) {
795
+ // Only auth failures feed the /health signature counter — policy
796
+ // rejections from a VALID credential (429 rate limit) must not read as
797
+ // a provider secret misconfiguration.
798
+ if (rejected.status === 401 || rejected.status === 403) {
799
+ _rejectionCounters.webhook_bad_signature++;
800
+ }
801
+ return rejected;
802
+ }
803
+ return def.handle(storage, req, body, ctx);
804
+ }
805
+ // Register a deployment into a bubble — MINT or JOIN.
806
+ // MINT (no bubble-signing headers): server generates a fresh bubble + key,
807
+ // returns the key ONCE (over TLS). Used only by named agent start's
808
+ // one-time bootstrap.
809
+ // JOIN (signed with an existing bubble's key): server verifies the signature
810
+ // against THAT bubble's stored key and attaches the deployment to it. Every
811
+ // session of a running instance joins the bubble minted at start.
812
+ // Subscriptions are stored under bubble-namespaced keys so a deployment only
813
+ // ever receives its own bubble's events (plus global webhook topics).
814
+ export async function handleRegisterDeployment(storage, body, ctx) {
815
+ const name = body.name;
816
+ const subscriptions = body.subscriptions;
817
+ if (!name || !subscriptions?.length) {
818
+ return { status: 400, body: { error: "name and subscriptions[] required" } };
819
+ }
820
+ // A request with incomplete signing headers is malformed — reject it
821
+ // rather than treating it as an (unsigned) MINT, which would silently fork
822
+ // the caller into a brand-new bubble.
823
+ if (hasPartialBubbleSignature(ctx)) {
824
+ return { status: 403, body: { error: "forbidden" } };
825
+ }
826
+ let bubble;
827
+ const minting = !hasBubbleSignature(ctx);
828
+ if (minting) {
829
+ bubble = {
830
+ id: randomToken("bub"),
831
+ key: randomToken("bkey"),
832
+ created_at: new Date().toISOString(),
833
+ };
834
+ await storage.putBubble(bubble);
835
+ }
836
+ else {
837
+ const authed = await authenticateBubble(storage, ctx);
838
+ if (!authed)
839
+ return { status: 403, body: { error: "forbidden" } };
840
+ bubble = authed;
841
+ }
842
+ // Enforcement layer 1 (#488): every GLOBAL resource topic in the request must
843
+ // have a matching grant for this bubble. Hard-reject the WHOLE request (no
844
+ // partial write) so the client surfaces a configuration error rather than
845
+ // silently running degraded (reviewer decision Q2). Delivery (§3.5) remains
846
+ // the fail-closed boundary regardless. Non-global topics are never gated, so
847
+ // the bootstrap MINT (`_bootstrap`) is unaffected.
848
+ const unauthorized = await unauthorizedGlobalTopics(storage, bubble.id, subscriptions);
849
+ if (unauthorized.length) {
850
+ return { status: 400, body: { error: "unauthorized_topics", topics: unauthorized } };
851
+ }
852
+ // Supersede any prior deployment with the same name in this bubble — a
853
+ // re-register (e.g. after losing deployment_state.json or a --fresh start)
854
+ // must not leave a stale deployment in the subscription index, otherwise
855
+ // directed events (inbox/<name>) get delivered twice (#278 bug 1).
856
+ const prior = await storage.getDeploymentByName(name, bubble.id);
857
+ if (prior) {
858
+ for (const sub of prior.subscriptions) {
859
+ await storage.removeSubscription(namespaceSubKey(bubble.id, sub), prior.id);
860
+ }
861
+ await storage.removeDeployment(prior);
862
+ }
863
+ const deploymentId = crypto.randomUUID();
864
+ const apiKey = `moda_${crypto.randomUUID().replace(/-/g, "")}`;
865
+ const deployment = {
866
+ id: deploymentId,
867
+ name,
868
+ api_key: apiKey,
869
+ bubble_id: bubble.id,
870
+ subscriptions,
871
+ created_at: new Date().toISOString(),
872
+ };
873
+ await storage.putDeployment(deployment);
874
+ for (const sub of subscriptions) {
875
+ await storage.addSubscription(namespaceSubKey(bubble.id, sub), deploymentId);
876
+ }
877
+ await storage.initDeploymentSession(deploymentId, subscriptions);
878
+ const resp = {
879
+ deployment_id: deploymentId,
880
+ api_key: apiKey,
881
+ bubble_id: bubble.id,
882
+ };
883
+ // The bubble key transits exactly once, at mint, over TLS. Never on join.
884
+ if (minting)
885
+ resp.bubble_key = bubble.key;
886
+ return { status: 201, body: resp };
887
+ }
888
+ export async function handleUpdateSubscriptions(storage, deploymentId, apiKey, body) {
889
+ const deployment = await authenticateDeployment(storage, apiKey, deploymentId);
890
+ if (!deployment)
891
+ return { status: 403, body: { error: "unauthorized" } };
892
+ const replaceSubs = body.replace;
893
+ const addSubs = body.add;
894
+ if (replaceSubs !== undefined && !replaceSubs.length) {
895
+ return { status: 400, body: { error: "replace[] must not be empty" } };
896
+ }
897
+ if (replaceSubs === undefined && !addSubs?.length) {
898
+ return { status: 400, body: { error: "add[] or replace[] required" } };
899
+ }
900
+ const newSubs = replaceSubs !== undefined ? replaceSubs : addSubs;
901
+ // Enforcement layer 1 (#488): same grant gate as registration — a global
902
+ // resource topic added here needs a matching grant for the deployment's
903
+ // bubble. Reject the whole update (no partial write) on any ungranted topic.
904
+ const unauthorized = await unauthorizedGlobalTopics(storage, deployment.bubble_id, newSubs);
905
+ if (unauthorized.length) {
906
+ return { status: 400, body: { error: "unauthorized_topics", topics: unauthorized } };
907
+ }
908
+ // Namespace from the AUTHENTICATED deployment record's bubble — never from a
909
+ // client-supplied bubble_id — so update-subscriptions can't escape the
910
+ // deployment's bubble. Same keying as registration (namespaceSubKey).
911
+ if (replaceSubs !== undefined) {
912
+ const desired = [...new Set(replaceSubs)];
913
+ const desiredSet = new Set(desired);
914
+ let removed = 0;
915
+ let added = 0;
916
+ for (const sub of deployment.subscriptions) {
917
+ if (!desiredSet.has(sub)) {
918
+ await storage.removeSubscription(namespaceSubKey(deployment.bubble_id, sub), deploymentId);
919
+ removed++;
920
+ }
921
+ }
922
+ for (const sub of desired) {
923
+ if (!deployment.subscriptions.includes(sub))
924
+ added++;
925
+ await storage.addSubscription(namespaceSubKey(deployment.bubble_id, sub), deploymentId);
926
+ }
927
+ deployment.subscriptions = desired;
928
+ await storage.putDeployment(deployment);
929
+ return { status: 200, body: { subscriptions: deployment.subscriptions, added, removed } };
930
+ }
931
+ let added = 0;
932
+ for (const sub of addSubs) {
933
+ if (!deployment.subscriptions.includes(sub)) {
934
+ deployment.subscriptions.push(sub);
935
+ added++;
936
+ }
937
+ await storage.addSubscription(namespaceSubKey(deployment.bubble_id, sub), deploymentId);
938
+ }
939
+ await storage.putDeployment(deployment);
940
+ return { status: 200, body: { subscriptions: deployment.subscriptions, added } };
941
+ }
942
+ export async function handleDeregisterDeployment(storage, deploymentId, apiKey) {
943
+ const deployment = await authenticateDeployment(storage, apiKey, deploymentId);
944
+ if (!deployment) {
945
+ return { status: 403, body: { error: "unauthorized" } };
946
+ }
947
+ for (const sub of deployment.subscriptions) {
948
+ await storage.removeSubscription(namespaceSubKey(deployment.bubble_id, sub), deploymentId);
949
+ }
950
+ await storage.removeDeployment(deployment);
951
+ return { status: 200, body: { ok: true } };
952
+ }
953
+ // Publish to a generic topic. The publisher MUST sign with its bubble key —
954
+ // the authenticated bubble stamps the event so it routes only within that
955
+ // bubble (global webhook topics excepted). An unsigned/invalid publish is
956
+ // rejected; without this an attacker could inject into any bubble by naming a
957
+ // topic, since namespacing alone is not authentication.
958
+ export async function handleTopicEvent(storage, topic, body, ctx) {
959
+ const bubble = await authenticateBubble(storage, ctx);
960
+ if (!bubble)
961
+ return { status: 403, body: { error: "forbidden" } };
962
+ if (body.repo || body.team_key || body.workspace) {
963
+ return { status: 400, body: { error: "global routing fields are webhook-only" } };
964
+ }
965
+ const event = createTopicEvent(topic, body, bubble.id);
966
+ if (event.topics.some((key) => isGlobalTopic(key))) {
967
+ return { status: 400, body: { error: "global topics are webhook-only" } };
968
+ }
969
+ const delivered = await storage.deliver(event);
970
+ return { status: 200, body: { delivered_to: delivered } };
971
+ }
972
+ // ---------------------------------------------------------------------------
973
+ // Scoped ingest tokens (#640) — mint / list / revoke. Every handler takes the
974
+ // AUTHENTICATED bubble id (entry files reject unsigned or bad-signature
975
+ // requests with an opaque 403 before calling these, mirroring
976
+ // /resources/authorize), so a token is only ever visible to — and only ever
977
+ // binds — the bubble that minted it.
978
+ // ---------------------------------------------------------------------------
979
+ // An ingest topic in the token store: `source/type` form like a CLI publish
980
+ // topic (e.g. "alert/firing"), from a charset that never URL-encodes so the
981
+ // wire path and the stored binding compare byte-for-byte. The first segment
982
+ // must not impersonate a webhook provider, and the `:` exclusion structurally
983
+ // rules out every global (github:/linear:/slack:) routing key.
984
+ const INGEST_TOPIC_SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
985
+ const INGEST_TOPIC_MAX_LENGTH = 200;
986
+ const INGEST_RESERVED_SOURCES = new Set(["github", "linear", "slack"]);
987
+ export function validateIngestTopic(topic) {
988
+ if (!topic || topic.length > INGEST_TOPIC_MAX_LENGTH) {
989
+ return "topic must be 1-200 characters";
990
+ }
991
+ const segments = topic.split("/");
992
+ if (segments.length < 2) {
993
+ return "topic must use source/type form, e.g. alert/firing";
994
+ }
995
+ if (!segments.every((s) => INGEST_TOPIC_SEGMENT_RE.test(s))) {
996
+ return "topic segments must be non-empty [A-Za-z0-9_.-]";
997
+ }
998
+ if (INGEST_RESERVED_SOURCES.has(segments[0])) {
999
+ return "github, linear, and slack sources are reserved for webhooks";
1000
+ }
1001
+ return null;
1002
+ }
1003
+ export async function envIngestTokenRecords(envValue, bubbleId, now = new Date().toISOString()) {
1004
+ const records = [];
1005
+ for (const rawBinding of envValue.split(",")) {
1006
+ const binding = rawBinding.trim();
1007
+ if (!binding)
1008
+ continue;
1009
+ const eq = binding.indexOf("=");
1010
+ if (eq <= 0 || eq === binding.length - 1) {
1011
+ throw new Error("BOBI_ES_INGEST_TOKENS entries must use topic=token");
1012
+ }
1013
+ const topic = binding.slice(0, eq).trim();
1014
+ const token = binding.slice(eq + 1).trim();
1015
+ const invalid = validateIngestTopic(topic);
1016
+ if (invalid) {
1017
+ throw new Error(`BOBI_ES_INGEST_TOKENS invalid topic ${JSON.stringify(topic)}: ${invalid}`);
1018
+ }
1019
+ if (!token) {
1020
+ throw new Error(`BOBI_ES_INGEST_TOKENS token for ${JSON.stringify(topic)} must not be empty`);
1021
+ }
1022
+ const tokenHash = await sha256Hex(token);
1023
+ records.push({
1024
+ id: crypto.randomUUID(),
1025
+ bubble_id: bubbleId,
1026
+ topic,
1027
+ token_hash: tokenHash,
1028
+ name: "BOBI_ES_INGEST_TOKENS",
1029
+ env_managed: true,
1030
+ created_at: now,
1031
+ });
1032
+ }
1033
+ return records;
1034
+ }
1035
+ export async function handleIngestTokenCreate(storage, body, bubbleId) {
1036
+ const topic = typeof body.topic === "string" ? body.topic.trim() : "";
1037
+ const name = typeof body.name === "string" && body.name ? body.name : undefined;
1038
+ const invalid = validateIngestTopic(topic);
1039
+ if (invalid)
1040
+ return { status: 400, body: { error: invalid } };
1041
+ const token = randomToken("ingt");
1042
+ const record = {
1043
+ id: crypto.randomUUID(),
1044
+ bubble_id: bubbleId,
1045
+ topic,
1046
+ token_hash: await sha256Hex(token),
1047
+ ...(name ? { name } : {}),
1048
+ created_at: new Date().toISOString(),
1049
+ };
1050
+ await storage.putIngestToken(record);
1051
+ // The plaintext token appears here and nowhere else — it is never stored
1052
+ // and never recoverable from list.
1053
+ return {
1054
+ status: 201,
1055
+ body: {
1056
+ id: record.id,
1057
+ topic: record.topic,
1058
+ ...(name ? { name } : {}),
1059
+ token,
1060
+ created_at: record.created_at,
1061
+ },
1062
+ };
1063
+ }
1064
+ export async function handleIngestTokenList(storage, bubbleId) {
1065
+ const records = await storage.listIngestTokens(bubbleId);
1066
+ return {
1067
+ status: 200,
1068
+ body: {
1069
+ tokens: records.map((r) => ({
1070
+ id: r.id,
1071
+ topic: r.topic,
1072
+ ...(r.name ? { name: r.name } : {}),
1073
+ ...(r.env_managed ? { env_managed: true } : {}),
1074
+ created_at: r.created_at,
1075
+ })),
1076
+ },
1077
+ };
1078
+ }
1079
+ export async function handleIngestTokenRevoke(storage, tokenId, bubbleId) {
1080
+ // Resolve the id inside the caller's OWN token list — another bubble's
1081
+ // token id yields the same 404 as a nonexistent one.
1082
+ const records = await storage.listIngestTokens(bubbleId);
1083
+ const record = records.find((r) => r.id === tokenId);
1084
+ if (!record)
1085
+ return { status: 404, body: { error: "not found" } };
1086
+ if (record.env_managed) {
1087
+ return {
1088
+ status: 400,
1089
+ body: {
1090
+ error: "env-managed ingest tokens are rotated by changing BOBI_ES_INGEST_TOKENS and restarting",
1091
+ },
1092
+ };
1093
+ }
1094
+ await storage.deleteIngestToken(record);
1095
+ return { status: 200, body: { ok: true } };
1096
+ }
1097
+ // ---------------------------------------------------------------------------
1098
+ // Resource grants (#488) — verify an upstream credential ONCE, store a grant.
1099
+ // ---------------------------------------------------------------------------
1100
+ // Upstream hosts are fixed constants, NEVER client-supplied (SSRF guard).
1101
+ const GITHUB_API = "https://api.github.com";
1102
+ const LINEAR_API = "https://api.linear.app/graphql";
1103
+ // GitHub bar (resolved, Q4): the MINIMUM read probe — `GET /repos/{owner}/{repo}`
1104
+ // returns 2xx means the token can read the repo, which is all webhook delivery
1105
+ // needs. We deliberately do NOT parse `private`/`permissions` (the Rev-2
1106
+ // tightening was dropped); only the 2xx/non-2xx distinction gates the grant.
1107
+ async function verifyGitHubAccess(resource, credential) {
1108
+ const resp = await fetch(`${GITHUB_API}/repos/${resource}`, {
1109
+ headers: {
1110
+ Authorization: `Bearer ${credential}`,
1111
+ "User-Agent": "bobi-event-server",
1112
+ Accept: "application/vnd.github+json",
1113
+ },
1114
+ });
1115
+ return { ok: resp.ok };
1116
+ }
1117
+ // Linear (Q3): keep TEAM-KEY granularity — confirm the credential can see the
1118
+ // SPECIFIC team it claims, not merely that the token is valid org-wide. Records
1119
+ // the team's organization id for the future disambiguation (§4).
1120
+ async function verifyLinearAccess(resource, credential) {
1121
+ // `resource` is validated against a strict charset by the caller before this
1122
+ // runs, so it cannot break out of the GraphQL string literal.
1123
+ const query = `{ teams(filter:{key:{eq:"${resource}"}}){ nodes { id key organization { id } } } }`;
1124
+ const resp = await fetch(LINEAR_API, {
1125
+ method: "POST",
1126
+ headers: { Authorization: credential, "Content-Type": "application/json" },
1127
+ body: JSON.stringify({ query }),
1128
+ });
1129
+ if (!resp.ok)
1130
+ return { ok: false };
1131
+ const data = (await resp.json());
1132
+ const nodes = data?.data?.teams?.nodes ?? [];
1133
+ const match = Array.isArray(nodes) ? nodes.find((n) => n?.key === resource) : undefined;
1134
+ if (!match)
1135
+ return { ok: false };
1136
+ return { ok: true, organizationId: match.organization?.id ?? null };
1137
+ }
1138
+ // A linear team key as it appears in a topic — uppercase alnum plus `-`/`_`.
1139
+ // Anchored so a malicious `resource` can never inject into the GraphQL string.
1140
+ const LINEAR_KEY_RE = /^[A-Za-z0-9_-]+$/;
1141
+ // owner/repo shape (each segment non-empty, no extra slashes).
1142
+ const GITHUB_SLUG_RE = /^[^/\s]+\/[^/\s]+$/;
1143
+ // POST /resources/authorize handler. `bubbleId` is the AUTHENTICATED bubble -
1144
+ // the entry file rejects an unsigned / partial / bad-signature request with an
1145
+ // opaque 403 BEFORE calling this. Verifies the upstream credential once, then
1146
+ // stores ONLY a grant.
1147
+ //
1148
+ // The credential is NEVER logged and NEVER stored: the route is excluded from
1149
+ // body logging, and a verification failure logs `{service, reason}` only.
1150
+ export async function handleAuthorizeResource(storage, body, bubbleId) {
1151
+ const service = typeof body.service === "string" ? body.service : "";
1152
+ const rawResource = typeof body.resource === "string" ? body.resource.trim() : "";
1153
+ const credential = typeof body.credential === "string" ? body.credential : "";
1154
+ // Slack authorizes via the bubble-signed /slack/workspaces registration
1155
+ // (§6) — there is no separate verify call — so only github/linear reach here.
1156
+ if ((service !== "github" && service !== "linear") || !rawResource || !credential) {
1157
+ return { status: 400, body: { error: "invalid_request" } };
1158
+ }
1159
+ const resource = normalizeResource(service, rawResource);
1160
+ if (service === "github" && !GITHUB_SLUG_RE.test(resource)) {
1161
+ return { status: 400, body: { error: "invalid_request" } };
1162
+ }
1163
+ if (service === "linear" && !LINEAR_KEY_RE.test(resource)) {
1164
+ return { status: 400, body: { error: "invalid_request" } };
1165
+ }
1166
+ let result;
1167
+ try {
1168
+ result = service === "github"
1169
+ ? await verifyGitHubAccess(resource, credential)
1170
+ : await verifyLinearAccess(resource, credential);
1171
+ }
1172
+ catch (err) {
1173
+ // Reason only — NEVER the credential or the raw body.
1174
+ console.warn(`resource authorize error: service=${service} reason=${String(err)}`);
1175
+ return { status: 403, body: { error: "forbidden" } };
1176
+ }
1177
+ if (!result.ok) {
1178
+ console.warn(`resource authorize denied: service=${service}`);
1179
+ return { status: 403, body: { error: "forbidden" } };
1180
+ }
1181
+ const grant = {
1182
+ id: `${service}:${resource}:${bubbleId}`,
1183
+ account_id: null,
1184
+ bubble_id: bubbleId,
1185
+ service,
1186
+ resource,
1187
+ granted_by: "upstream_token_verification",
1188
+ organization_id: result.organizationId ?? null,
1189
+ created_at: new Date().toISOString(),
1190
+ expires_at: null,
1191
+ };
1192
+ await storage.putResourceGrant(grant);
1193
+ return { status: 200, body: { ok: true } };
1194
+ }
1195
+ export async function handleTestSeedResourceGrants(storage, body, bubbleId) {
1196
+ const grants = Array.isArray(body.grants) ? body.grants : [];
1197
+ if (!bubbleId || grants.length === 0) {
1198
+ return { status: 400, body: { error: "invalid_request" } };
1199
+ }
1200
+ for (const raw of grants) {
1201
+ if (!raw || typeof raw !== "object") {
1202
+ return { status: 400, body: { error: "invalid_request" } };
1203
+ }
1204
+ const grant = raw;
1205
+ const service = typeof grant.service === "string" ? grant.service : "";
1206
+ const rawResource = typeof grant.resource === "string" ? grant.resource.trim() : "";
1207
+ if ((service !== "github" && service !== "linear" && service !== "slack"
1208
+ && service !== "whatsapp") || !rawResource) {
1209
+ return { status: 400, body: { error: "invalid_request" } };
1210
+ }
1211
+ const resource = normalizeResource(service, rawResource);
1212
+ await storage.putResourceGrant({
1213
+ id: `${service}:${resource}:${bubbleId}`,
1214
+ account_id: null,
1215
+ bubble_id: bubbleId,
1216
+ service,
1217
+ resource,
1218
+ granted_by: "test_seed",
1219
+ organization_id: null,
1220
+ created_at: new Date().toISOString(),
1221
+ expires_at: null,
1222
+ });
1223
+ }
1224
+ return { status: 200, body: { ok: true, grants: grants.length } };
1225
+ }
1226
+ // The global resource topics in `subs` that the bubble does NOT currently hold a
1227
+ // grant for (#488 enforcement layer 1). A malformed global key (no parseable
1228
+ // service/resource) counts as unauthorized. Non-global topics are ignored.
1229
+ export async function unauthorizedGlobalTopics(storage, bubbleId, subs) {
1230
+ const bad = [];
1231
+ for (const sub of subs) {
1232
+ if (!isGlobalTopic(sub))
1233
+ continue;
1234
+ const parsed = parseGlobalTopic(sub);
1235
+ if (!parsed) {
1236
+ bad.push(sub);
1237
+ continue;
1238
+ }
1239
+ if (!(await storage.hasResourceGrant(parsed.service, parsed.resource, bubbleId))) {
1240
+ bad.push(sub);
1241
+ }
1242
+ }
1243
+ return bad;
1244
+ }
1245
+ // Resolve the deployment IDs an event should fan out to, applying the #488
1246
+ // resource-grant filter to GLOBAL topics. `subscribersForKey` returns the
1247
+ // subscriber ids the runtime has indexed for a subscription key. A non-global
1248
+ // key admits every subscriber (the common bubble-scoped path pays nothing); a
1249
+ // global key admits a subscriber ONLY if its bubble currently holds a matching
1250
+ // grant — so a stale index entry for a revoked / never-granted bubble is dropped
1251
+ // (fail-closed; delivery is the authoritative boundary, §3.5).
1252
+ export async function admittedDeploymentIds(storage, event, subscribersForKey) {
1253
+ const admitted = new Set();
1254
+ for (const key of subscriptionKeysForEvent(event)) {
1255
+ const ids = await subscribersForKey(key);
1256
+ if (isGlobalTopic(key)) {
1257
+ const parsed = parseGlobalTopic(key);
1258
+ if (!parsed)
1259
+ continue;
1260
+ for (const id of ids) {
1261
+ if (admitted.has(id))
1262
+ continue;
1263
+ const dep = await storage.getDeploymentById(id);
1264
+ if (dep && (await storage.hasResourceGrant(parsed.service, parsed.resource, dep.bubble_id))) {
1265
+ admitted.add(id);
1266
+ }
1267
+ }
1268
+ }
1269
+ else {
1270
+ for (const id of ids)
1271
+ admitted.add(id);
1272
+ }
1273
+ }
1274
+ return admitted;
1275
+ }
1276
+ // Storage key for a bubble-scoped Slack workspace registration. Outbound
1277
+ // channel sends read ONLY this key - never the bare global `slack_workspace:<id>`
1278
+ // that drives inbound self-reply - so one bubble can never send through a
1279
+ // workspace another bubble registered. With the KV adapter's `slack_workspace:`
1280
+ // prefix this yields `slack_workspace:${bubbleId}:${workspaceId}`.
1281
+ export function bubbleScopedWorkspaceKey(bubbleId, workspaceId) {
1282
+ return `${bubbleId}:${workspaceId}`;
1283
+ }
1284
+ // Resolve the sending bot's token for an outbound Slack send. Bubble-scoped
1285
+ // lookup ONLY - no fallback to the global workspace store. A workspace
1286
+ // registered by another bubble (or only inbound-registered) is absent here and
1287
+ // yields the same empty result as a truly unknown workspace, so an attacker
1288
+ // can't probe which workspaces another bubble registered.
1289
+ //
1290
+ // Pick the sending bot's token: by app_id, then bot_id, then the single
1291
+ // registered bot, then the legacy workspace token. With several bots in a
1292
+ // workspace, "the workspace's bot_token" is ambiguous.
1293
+ async function resolveSlackSendToken(storage, bubbleId, workspaceId, appId, botId) {
1294
+ if (!workspaceId)
1295
+ return "";
1296
+ const ws = await storage.getSlackWorkspace(bubbleScopedWorkspaceKey(bubbleId, workspaceId));
1297
+ if (!ws)
1298
+ return "";
1299
+ let botToken = ws.bot_token || "";
1300
+ if (appId && ws.bots?.[appId]) {
1301
+ botToken = ws.bots[appId].bot_token;
1302
+ }
1303
+ else if (botId && ws.bots) {
1304
+ const match = Object.values(ws.bots).find((b) => b.bot_id === botId);
1305
+ if (match)
1306
+ botToken = match.bot_token;
1307
+ }
1308
+ else if (!botToken && ws.bots) {
1309
+ const vals = Object.values(ws.bots);
1310
+ if (vals.length >= 1)
1311
+ botToken = vals[0].bot_token;
1312
+ }
1313
+ return botToken;
1314
+ }
1315
+ // Resolve the sending credential for a conversation's channel, scoped to the
1316
+ // AUTHENTICATED bubble. The switch is the single place Phase 3 extends when a
1317
+ // new channel brings its own credential store.
1318
+ async function resolveChannelSendToken(storage, bubbleId, conv, body) {
1319
+ if (conv.source === "slack") {
1320
+ return resolveSlackSendToken(storage, bubbleId, conv.scope, body.app_id || "", body.bot_id || "");
1321
+ }
1322
+ if (conv.source === "whatsapp") {
1323
+ // Bubble-scoped lookup only, same tenancy boundary as Slack: a number
1324
+ // registered by another bubble is indistinguishable from an unknown one.
1325
+ const rec = await storage.getChannelState(whatsappNumberKey(bubbleId, conv.scope));
1326
+ return rec?.access_token || "";
1327
+ }
1328
+ return "";
1329
+ }
1330
+ // Storage key for a bubble-scoped WhatsApp number registration (#656). There
1331
+ // is no global record: unlike Slack, our own sends never come back as inbound
1332
+ // message webhooks, so no self-reply loop prevention is needed.
1333
+ export function whatsappNumberKey(bubbleId, phoneNumberId) {
1334
+ return `whatsapp_number:${bubbleId}:${phoneNumberId}`;
1335
+ }
1336
+ // POST /whatsapp/numbers - registration mirror of handleSlackWorkspaceRegister,
1337
+ // but signed-only: there is no unsigned (global) use case, so an
1338
+ // unauthenticated registration is rejected outright. Verifies the access token
1339
+ // against the Graph API (the phone-number node must be readable with it), then
1340
+ // stores the bubble-scoped send credential and writes the whatsapp resource
1341
+ // grant that lets this bubble subscribe to `whatsapp:<pnid>` (#488).
1342
+ export async function handleWhatsAppNumberRegister(storage, body, bubbleId) {
1343
+ const phoneNumberId = body.phone_number_id;
1344
+ const accessToken = body.access_token;
1345
+ if (!phoneNumberId || !accessToken) {
1346
+ return { status: 400, body: { error: "phone_number_id and access_token required" } };
1347
+ }
1348
+ if (!bubbleId) {
1349
+ return { status: 403, body: { error: "forbidden" } };
1350
+ }
1351
+ try {
1352
+ const data = await whatsappApi(accessToken, `${encodeURIComponent(phoneNumberId)}?fields=id`, { method: "GET" });
1353
+ if (String(data.id ?? "") !== phoneNumberId) {
1354
+ return { status: 403, body: { error: "forbidden" } };
1355
+ }
1356
+ }
1357
+ catch {
1358
+ return { status: 403, body: { error: "forbidden" } };
1359
+ }
1360
+ await storage.putChannelState(whatsappNumberKey(bubbleId, phoneNumberId), {
1361
+ access_token: accessToken,
1362
+ });
1363
+ // Upstream token verification IS the proof of access, same convergence as
1364
+ // Slack (#488 §6): the grant gates inbound `whatsapp:<pnid>` delivery.
1365
+ await storage.putResourceGrant({
1366
+ id: `whatsapp:${phoneNumberId}:${bubbleId}`,
1367
+ account_id: null,
1368
+ bubble_id: bubbleId,
1369
+ service: "whatsapp",
1370
+ resource: phoneNumberId,
1371
+ granted_by: "upstream_token_verification",
1372
+ organization_id: null,
1373
+ created_at: new Date().toISOString(),
1374
+ expires_at: null,
1375
+ });
1376
+ return { status: 200, body: { ok: true, phone_number_id: phoneNumberId } };
1377
+ }
1378
+ // One outbound file on /channels/send: { name, content_b64, title? }.
1379
+ function decodeOutboundFiles(raw) {
1380
+ if (raw === undefined)
1381
+ return [];
1382
+ if (!Array.isArray(raw))
1383
+ return null;
1384
+ const files = [];
1385
+ for (const item of raw) {
1386
+ if (!item || typeof item !== "object")
1387
+ return null;
1388
+ const f = item;
1389
+ if (typeof f.name !== "string" || !f.name || typeof f.content_b64 !== "string" || !f.content_b64) {
1390
+ return null;
1391
+ }
1392
+ let data;
1393
+ try {
1394
+ data = Uint8Array.from(atob(f.content_b64), (c) => c.charCodeAt(0));
1395
+ }
1396
+ catch {
1397
+ return null;
1398
+ }
1399
+ files.push({
1400
+ name: f.name,
1401
+ data,
1402
+ ...(typeof f.title === "string" && f.title ? { title: f.title } : {}),
1403
+ });
1404
+ }
1405
+ return files;
1406
+ }
1407
+ // Channel-agnostic outbound send (#618, #629):
1408
+ // { conversation, text?, mode?, edit_ref?, files? }
1409
+ // Parses the conversation reference and delegates to the channel's adapter.
1410
+ // `bubbleId` is the authenticated bubble; credentials resolve only under its
1411
+ // scope.
1412
+ //
1413
+ // Text arrives as raw markdown — formatting is the gateway's job (the Slack
1414
+ // adapter delivers it natively via markdown_text). Modes:
1415
+ // post post a new message
1416
+ // update edit the message named by edit_ref (e.g. a placeholder)
1417
+ // final resolve the response context: edit edit_ref when given, else
1418
+ // post; then clear the typing indicator either way
1419
+ // Capability degradation happens here: update on a channel without edit
1420
+ // support becomes a follow-up post; typing on a channel without indicators
1421
+ // is a silent no-op.
1422
+ // Spacing between follow-up chunk posts (#651): chunked sends fire several
1423
+ // posts into one channel back-to-back, and chat platforms rate-limit sustained
1424
+ // per-channel posting (Slack: ~1/sec with burst allowance).
1425
+ const CHUNK_SPACING_MS = 300;
1426
+ export async function handleChannelsSend(storage, body, bubbleId) {
1427
+ const ref = body.conversation;
1428
+ const text = body.text;
1429
+ // Explicit string checks: a non-string conversation (array, number) must
1430
+ // be a 400, not a TypeError escaping the handler as a 500.
1431
+ if (typeof ref !== "string" || !ref) {
1432
+ return { status: 400, body: { error: "conversation required" } };
1433
+ }
1434
+ const files = decodeOutboundFiles(body.files);
1435
+ if (files === null) {
1436
+ return { status: 400, body: { error: "invalid files: expected [{name, content_b64, title?}]" } };
1437
+ }
1438
+ // Text is required unless files carry the payload (it then becomes the
1439
+ // upload comment). A present-but-non-string text is always a 400.
1440
+ if (text !== undefined && typeof text !== "string") {
1441
+ return { status: 400, body: { error: "text must be a string" } };
1442
+ }
1443
+ if (!text && files.length === 0) {
1444
+ return { status: 400, body: { error: "text or files required" } };
1445
+ }
1446
+ const conv = parseConversation(ref);
1447
+ if (!conv) {
1448
+ return { status: 400, body: { error: "invalid conversation reference" } };
1449
+ }
1450
+ const adapter = getChannelAdapter(conv.source);
1451
+ if (!adapter) {
1452
+ return { status: 400, body: { error: `unsupported channel: ${conv.source}` } };
1453
+ }
1454
+ const mode = body.mode || "post";
1455
+ if (mode !== "post" && mode !== "update" && mode !== "final") {
1456
+ return { status: 400, body: { error: `invalid mode: ${mode}` } };
1457
+ }
1458
+ const editRef = body.edit_ref || "";
1459
+ if (mode === "update" && !editRef) {
1460
+ return { status: 400, body: { error: "edit_ref required for mode: update" } };
1461
+ }
1462
+ const caps = adapter.descriptor.capabilities;
1463
+ // The credential and window-state reads are independent; start both
1464
+ // together and keep the credential error's precedence.
1465
+ const [botToken, windowState] = await Promise.all([
1466
+ resolveChannelSendToken(storage, bubbleId, conv, body),
1467
+ caps.messageWindow
1468
+ ? storage.getChannelState(channelWindowKey(conv.source, conv.scope, conv.chatId))
1469
+ : Promise.resolve(null),
1470
+ ]);
1471
+ if (!botToken) {
1472
+ return { status: 400, body: { error: `no send credential registered for ${conv.source}:${conv.scope}` } };
1473
+ }
1474
+ // Message-window enforcement (#656): a windowed channel (WhatsApp's 24h
1475
+ // customer-service window) only accepts free-form replies for N hours
1476
+ // after the user's last inbound message. A KNOWN-stale window fails with
1477
+ // a TYPED error so the agent can report the situation instead of a
1478
+ // mystery platform rejection. A MISSING record passes through: it can
1479
+ // mean KV replication lag right after a fresh inbound (Workers), so
1480
+ // rejecting would break the most common flow - the platform itself is
1481
+ // the authoritative enforcer and its rejection surfaces as a send error.
1482
+ // Template messaging (the outside-window escape hatch) is a follow-up.
1483
+ if (caps.messageWindow) {
1484
+ const state = windowState;
1485
+ const last = Date.parse(state?.last_inbound || "");
1486
+ if (!Number.isNaN(last)
1487
+ && Date.now() - last > caps.messageWindow.hours * 3600_000) {
1488
+ return {
1489
+ status: 400,
1490
+ body: {
1491
+ error: "outside_message_window",
1492
+ detail: `no inbound message from this user in the last `
1493
+ + `${caps.messageWindow.hours}h; free-form replies are closed`
1494
+ + (caps.messageWindow.outsideWindow === "template"
1495
+ ? " (template messages are not supported yet)"
1496
+ : ""),
1497
+ },
1498
+ };
1499
+ }
1500
+ }
1501
+ const rawText = text || "";
1502
+ let result;
1503
+ try {
1504
+ if (files.length > 0) {
1505
+ // File sends stay single-message: the text is a comment or a
1506
+ // placeholder replacement, so over-budget text truncates.
1507
+ const outText = truncateForChannel(rawText, caps);
1508
+ if (!adapter.uploadFiles || !caps.files) {
1509
+ return { status: 400, body: { error: `channel ${conv.source} does not support files` } };
1510
+ }
1511
+ // A file reply that resolves a placeholder: a message cannot be
1512
+ // edited INTO a file share, so replace the placeholder text first,
1513
+ // then attach the file without a duplicate comment.
1514
+ if (editRef && (mode === "update" || mode === "final")) {
1515
+ if (!outText) {
1516
+ return { status: 400, body: { error: "text required when edit_ref is combined with files" } };
1517
+ }
1518
+ if (adapter.update && caps.edit) {
1519
+ const edited = await adapter.update(botToken, conv, editRef, outText);
1520
+ if (!edited.ok) {
1521
+ return { status: 502, body: { ok: false, error: edited.error } };
1522
+ }
1523
+ }
1524
+ result = await adapter.uploadFiles(botToken, conv, files);
1525
+ }
1526
+ else {
1527
+ result = await adapter.uploadFiles(botToken, conv, files, outText || undefined);
1528
+ }
1529
+ }
1530
+ else {
1531
+ // Editing a placeholder degrades to a follow-up post on a channel
1532
+ // without edit support - one degradation rule for update and final.
1533
+ // mode "post" always posts, even if a stray edit_ref is present.
1534
+ const wantsEdit = Boolean(editRef) && (mode === "update" || mode === "final");
1535
+ const editOrSend = (txt) => wantsEdit && adapter.update && caps.edit
1536
+ ? adapter.update(botToken, conv, editRef, txt)
1537
+ : adapter.send(botToken, conv, txt);
1538
+ if (editRef && mode === "update") {
1539
+ // Streaming rewrite of one message: chunking would post a new
1540
+ // message on every tick, so the budget stays truncation-enforced
1541
+ // until the final send.
1542
+ result = await editOrSend(truncateForChannel(rawText, caps));
1543
+ }
1544
+ else {
1545
+ // Terminal send (post, or final resolving a placeholder):
1546
+ // over-budget text goes out whole as natural-boundary chunks.
1547
+ // The first chunk carries the message identity (placeholder
1548
+ // edit, returned ts); later chunks are follow-up posts in the
1549
+ // same conversation, lightly paced for channel rate limits.
1550
+ const chunks = chunkForChannel(rawText, caps);
1551
+ result = await editOrSend(chunks[0]);
1552
+ for (let i = 1; result.ok && i < chunks.length; i++) {
1553
+ await new Promise((r) => setTimeout(r, CHUNK_SPACING_MS));
1554
+ const follow = await adapter.send(botToken, conv, chunks[i]);
1555
+ if (!follow.ok) {
1556
+ // Chunks 1..i are already visible: clear the typing
1557
+ // indicator (the reply IS partially delivered) and
1558
+ // surface an error a caller will not blindly retry.
1559
+ if ((mode === "update" || mode === "final") && adapter.typing && caps.typing) {
1560
+ await adapter.typing(botToken, conv, false);
1561
+ }
1562
+ return {
1563
+ status: 502,
1564
+ body: {
1565
+ ok: false,
1566
+ ts: result.ts,
1567
+ error: `chunk ${i + 1}/${chunks.length} failed after partial delivery `
1568
+ + `(do not resend; the reply is partially visible): ${follow.error}`,
1569
+ },
1570
+ };
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ }
1576
+ catch (err) {
1577
+ return { status: 502, body: { ok: false, error: String(err) } };
1578
+ }
1579
+ if (!result.ok) {
1580
+ return { status: 502, body: { ok: false, error: result.error } };
1581
+ }
1582
+ // Resolving a response context (placeholder edit or final reply) also
1583
+ // clears the "is thinking..." indicator the placeholder flow set.
1584
+ if ((mode === "update" || mode === "final") && adapter.typing && caps.typing) {
1585
+ await adapter.typing(botToken, conv, false);
1586
+ }
1587
+ return { status: 200, body: { ok: true, ts: result.ts } };
1588
+ }
1589
+ // POST /channels/typing (#629): { conversation, on }. Sets or clears the
1590
+ // channel's thinking/typing indicator. A channel without typing support is a
1591
+ // silent no-op (200, supported: false) — capability degradation is the
1592
+ // gateway's job, not the caller's.
1593
+ export async function handleChannelsTyping(storage, body, bubbleId) {
1594
+ const ref = body.conversation;
1595
+ if (typeof ref !== "string" || !ref || typeof body.on !== "boolean") {
1596
+ return { status: 400, body: { error: "conversation and on required" } };
1597
+ }
1598
+ const conv = parseConversation(ref);
1599
+ if (!conv) {
1600
+ return { status: 400, body: { error: "invalid conversation reference" } };
1601
+ }
1602
+ const adapter = getChannelAdapter(conv.source);
1603
+ if (!adapter) {
1604
+ return { status: 400, body: { error: `unsupported channel: ${conv.source}` } };
1605
+ }
1606
+ if (!adapter.typing || !adapter.descriptor.capabilities.typing) {
1607
+ return { status: 200, body: { ok: true, supported: false } };
1608
+ }
1609
+ const botToken = await resolveChannelSendToken(storage, bubbleId, conv, body);
1610
+ if (!botToken) {
1611
+ return { status: 400, body: { error: `no send credential registered for ${conv.source}:${conv.scope}` } };
1612
+ }
1613
+ await adapter.typing(botToken, conv, body.on);
1614
+ return { status: 200, body: { ok: true, supported: true } };
1615
+ }
1616
+ // GET /channels/history (#629): ?conversation=...&limit=... — read the
1617
+ // messages of a conversation (Slack: the thread the ref anchors). Returns
1618
+ // { ok, messages: [{user, text, ts, files?}] } oldest-first.
1619
+ export async function handleChannelsHistory(storage, conversationRef, limit, bubbleId) {
1620
+ if (!conversationRef) {
1621
+ return { status: 400, body: { error: "conversation required" } };
1622
+ }
1623
+ const conv = parseConversation(conversationRef);
1624
+ if (!conv) {
1625
+ return { status: 400, body: { error: "invalid conversation reference" } };
1626
+ }
1627
+ const adapter = getChannelAdapter(conv.source);
1628
+ if (!adapter) {
1629
+ return { status: 400, body: { error: `unsupported channel: ${conv.source}` } };
1630
+ }
1631
+ if (!adapter.fetchConversation) {
1632
+ return { status: 400, body: { error: `channel ${conv.source} does not support history` } };
1633
+ }
1634
+ const botToken = await resolveChannelSendToken(storage, bubbleId, conv, {});
1635
+ if (!botToken) {
1636
+ return { status: 400, body: { error: `no send credential registered for ${conv.source}:${conv.scope}` } };
1637
+ }
1638
+ const capped = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 1000) : 100;
1639
+ let messages;
1640
+ try {
1641
+ messages = await adapter.fetchConversation(botToken, conv, capped);
1642
+ }
1643
+ catch (err) {
1644
+ return { status: 502, body: { ok: false, error: String(err) } };
1645
+ }
1646
+ return { status: 200, body: { ok: true, messages } };
1647
+ }
1648
+ // `bubbleId`, when present, is the AUTHENTICATED bubble that signed the
1649
+ // registration. The global record (bare workspaceId) is ALWAYS written so
1650
+ // inbound webhook self-reply loop prevention keeps working for any client -
1651
+ // signed or not. The bubble-scoped record is written ONLY for an authenticated
1652
+ // bubble, and is the only store outbound channel sends read.
1653
+ export async function handleSlackWorkspaceRegister(storage, body, bubbleId) {
1654
+ const workspaceId = body.workspace_id;
1655
+ const botToken = body.bot_token;
1656
+ if (!workspaceId || !botToken) {
1657
+ return { status: 400, body: { error: "workspace_id and bot_token required" } };
1658
+ }
1659
+ // Accept explicit bot_id/app_id when the caller already resolved them (e.g.
1660
+ // tests, or a Python client). Fall back to auth.test (bot_id) and
1661
+ // bots.info (app_id) — auth.test does NOT return app_id.
1662
+ let botId = body.bot_id || undefined;
1663
+ let botUserId = body.bot_user_id || undefined;
1664
+ let appId = body.app_id || undefined;
1665
+ const signingSecret = body.signing_secret || undefined;
1666
+ if (bubbleId) {
1667
+ try {
1668
+ const resp = await fetch(`${slackApiUrl()}auth.test`, {
1669
+ headers: { Authorization: `Bearer ${botToken}` },
1670
+ });
1671
+ const data = (await resp.json());
1672
+ if (!data.ok || data.team_id !== workspaceId) {
1673
+ return { status: 403, body: { error: "forbidden" } };
1674
+ }
1675
+ if (data.bot_id)
1676
+ botId = data.bot_id;
1677
+ }
1678
+ catch {
1679
+ return { status: 403, body: { error: "forbidden" } };
1680
+ }
1681
+ }
1682
+ else if (!botId) {
1683
+ try {
1684
+ const resp = await fetch(`${slackApiUrl()}auth.test`, {
1685
+ headers: { Authorization: `Bearer ${botToken}` },
1686
+ });
1687
+ const data = (await resp.json());
1688
+ if (data.ok) {
1689
+ botId = data.bot_id;
1690
+ botUserId = data.user_id || botUserId;
1691
+ }
1692
+ }
1693
+ catch {
1694
+ // best-effort — self-loop filtering degrades gracefully without bot_id
1695
+ }
1696
+ }
1697
+ if (!appId && botId) {
1698
+ try {
1699
+ const resp = await fetch(`${slackApiUrl()}bots.info?bot=${encodeURIComponent(botId)}`, { headers: { Authorization: `Bearer ${botToken}` } });
1700
+ const data = (await resp.json());
1701
+ if (data.ok)
1702
+ appId = data.bot?.app_id;
1703
+ }
1704
+ catch {
1705
+ // best-effort — falls back to bot_id-keyed storage below
1706
+ }
1707
+ }
1708
+ // UPSERT one entry into the per-app map — never overwrite the whole record,
1709
+ // so a second bot registering the same workspace doesn't clobber the first.
1710
+ // Pure: applied INDEPENDENTLY to the global and the bubble-scoped record so
1711
+ // the two stores accrete in parallel and never alias.
1712
+ const mergeBot = (existing) => {
1713
+ const bots = { ...(existing?.bots ?? {}) };
1714
+ // Migrate a pre-existing legacy single-bot record into the map.
1715
+ if (existing?.bot_id && existing.bot_token && !bots[existing.bot_id]) {
1716
+ bots[existing.bot_id] = { bot_token: existing.bot_token, bot_id: existing.bot_id };
1717
+ }
1718
+ // Key by api_app_id; fall back to bot_id when app_id couldn't be resolved
1719
+ // (still unique per bot within a workspace, just not loop-safe across two
1720
+ // bots that share a bot_id — which never happens).
1721
+ const key = appId || botId || "default";
1722
+ // MERGE, don't replace: a registration that omits a field (e.g. an older
1723
+ // client that doesn't send signing_secret) must NOT wipe a value a previous
1724
+ // registration set — otherwise every restart of such a client drops the
1725
+ // per-app signing secret and the app's events fall back to the global secret
1726
+ // and 401. Always refresh the bot_token (it may rotate); preserve the rest.
1727
+ const prev = bots[key] ?? {};
1728
+ bots[key] = {
1729
+ bot_token: botToken,
1730
+ bot_id: botId ?? prev.bot_id,
1731
+ bot_user_id: botUserId ?? prev.bot_user_id,
1732
+ signing_secret: signingSecret ?? prev.signing_secret,
1733
+ app_id: appId ?? prev.app_id,
1734
+ };
1735
+ return {
1736
+ // Keep legacy fields reflecting the just-registered bot for back-compat
1737
+ // readers; `bots` is authoritative.
1738
+ bot_token: botToken,
1739
+ bot_id: botId,
1740
+ bots,
1741
+ };
1742
+ };
1743
+ // Global record - drives inbound webhook self-reply loop prevention. ALWAYS
1744
+ // written so a client that doesn't (yet) sign keeps loop prevention.
1745
+ const globalExisting = await storage.getSlackWorkspace(workspaceId);
1746
+ await storage.putSlackWorkspace(workspaceId, mergeBot(globalExisting));
1747
+ // Bubble-scoped record - the ONLY store outbound channel sends read. Written
1748
+ // only for an authenticated bubble, so that bubble (and no other) can send
1749
+ // through this workspace.
1750
+ if (bubbleId) {
1751
+ const scopedKey = bubbleScopedWorkspaceKey(bubbleId, workspaceId);
1752
+ const scopedExisting = await storage.getSlackWorkspace(scopedKey);
1753
+ await storage.putSlackWorkspace(scopedKey, mergeBot(scopedExisting));
1754
+ // Slack convergence (#488 §6): the signed registration — proving
1755
+ // possession of the bot token + signing secret — IS the proof of access,
1756
+ // so it doubles as the slack resource grant. `slack:{teamId}` inbound
1757
+ // delivery is then grant-filtered exactly like github/linear. Idempotent.
1758
+ await storage.putResourceGrant({
1759
+ id: `slack:${workspaceId}:${bubbleId}`,
1760
+ account_id: null,
1761
+ bubble_id: bubbleId,
1762
+ service: "slack",
1763
+ resource: workspaceId,
1764
+ granted_by: "upstream_token_verification",
1765
+ organization_id: null,
1766
+ created_at: new Date().toISOString(),
1767
+ expires_at: null,
1768
+ });
1769
+ }
1770
+ return {
1771
+ status: 200,
1772
+ body: { ok: true, workspace_id: workspaceId, bot_id: botId, bot_user_id: botUserId, app_id: appId },
1773
+ };
1774
+ }
1775
+ //# sourceMappingURL=core.js.map