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