From e4b186909202fde28ec83348a7afc9b399366119 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:36:38 +0530 Subject: [PATCH] fix(stream): the subscription script ignored Stream's product scoping (#1134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied against the live app and it was refused: UpdateApp failed: invalid event types for hook 44a1d716-…: event types [message.flagged user.flagged] do not belong to product 'video' Stream partitions event types by product and a hook may only carry its own. The app has exactly one hook, scoped to `video`. The two chat-moderation events can never live on it. The script had no concept of `product`, so it reported all five unsubscribed events as plainly "missing" — which reads as one write away from fixed. Two of them were impossible. Worse, `updateAppSettings` rejects the whole payload atomically, so the single impossible event cost the three achievable ones in the same write and nothing landed at all. Now each hook is offered only what its product permits, and any handled event with no hook to carry it is reported as a configuration gap rather than a pending write. The script does not create the missing hook: that decides a public URL and starts real deliveries, so it belongs to a human in the dashboard, exactly as the existing "no webhook at all" branch already does. Verified against production. The video hook went 6 -> 9 events, so call.session_participant_joined / _left and call.session_started now deliver and MeetingAttendance can finally be written. Chat moderation stays dead until a 'chat' hook exists — which the script now says out loud instead of implying it is one run away. Part of #1134 --- scripts/stream/ensure-webhook-subscription.ts | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/scripts/stream/ensure-webhook-subscription.ts b/scripts/stream/ensure-webhook-subscription.ts index ad291372e..d9f30b3f9 100644 --- a/scripts/stream/ensure-webhook-subscription.ts +++ b/scripts/stream/ensure-webhook-subscription.ts @@ -69,6 +69,43 @@ const DESIRED_EVENT_TYPES = Array.from( */ type IdentifiedHook = EventHook & { id: string }; +/** + * Event types are partitioned by PRODUCT, and a hook may only carry events from + * its own. + * + * This is not a detail. The live app has exactly one hook, scoped to `video`, + * and `updateAppSettings` refuses the whole write — atomically, so nothing + * lands — if the payload gives it a chat event: + * + * invalid event types for hook 44a1d716-…: event types + * [message.flagged user.flagged] do not belong to product 'video' + * + * The first version of this script had no concept of `product`. It reported all + * five unsubscribed events as simply "missing", which read as one write away + * from fixed, when two of them could never live on that hook at all. Chat + * moderation would have stayed dead with the script reporting success. + */ +const CHAT_EVENT_PREFIXES = ["user.", "message.", "channel.", "member."]; + +function productFor(eventType: string): "chat" | "video" { + return CHAT_EVENT_PREFIXES.some((p) => eventType.startsWith(p)) + ? "chat" + : "video"; +} + +/** + * Whether a hook may carry an event type. + * + * A hook with no `product` is treated as unconstrained: the field is optional in + * the SDK, and refusing to widen a hook we cannot classify would be worse than + * letting Stream reject it with a precise message. + */ +function hookAccepts(hook: EventHook, eventType: string): boolean { + const product = (hook as { product?: string }).product; + if (!product || product === "all") return true; + return product === productFor(eventType); +} + export async function ensureWebhookSubscription(apply: boolean): Promise { if (!isStreamConfigured()) { console.error( @@ -100,21 +137,29 @@ export async function ensureWebhookSubscription(apply: boolean): Promise let changed = 0; /** hook id -> its widened event_types. Applied in ONE write after the loop. */ const widened = new Map(); + /** Events no hook on this app is allowed to carry. */ + const unplaceable = new Set(DESIRED_EVENT_TYPES); for (const hook of hooks) { const current = new Set(hook.event_types ?? []); // A hook subscribed to "*" already receives everything. const receivesAll = current.has("*"); - const missing = DESIRED_EVENT_TYPES.filter( - (t) => !receivesAll && !current.has(t), - ); - console.log(`\nhook ${hook.id} enabled=${hook.enabled}`); + // Only events this hook's product permits. Offering it anything else makes + // Stream refuse the ENTIRE update, so one impossible event silently costs + // every possible one in the same write. + const eligible = DESIRED_EVENT_TYPES.filter((t) => hookAccepts(hook, t)); + for (const t of eligible) unplaceable.delete(t); + + const missing = eligible.filter((t) => !receivesAll && !current.has(t)); + const product = (hook as { product?: string }).product ?? "unscoped"; + + console.log(`\nhook ${hook.id} enabled=${hook.enabled} product=${product}`); console.log(` url: ${hook.webhook_url}`); console.log(` subscribed: ${current.size}${receivesAll ? " (wildcard)" : ""}`); if (missing.length === 0) { - console.log(" ✅ already covers every handled event"); + console.log(` ✅ already covers every handled ${product} event`); continue; } @@ -132,6 +177,30 @@ export async function ensureWebhookSubscription(apply: boolean): Promise console.log(` → will widen to ${next.length} event types`); } + // Events with nowhere to go. This is a configuration gap the script cannot + // close: creating a hook decides a public URL and starts real deliveries, so + // it belongs to a human, in the dashboard, the same way the "no webhook at + // all" case above does. + if (unplaceable.size > 0) { + const byProduct = new Map(); + for (const t of unplaceable) { + const p = productFor(t); + byProduct.set(p, [...(byProduct.get(p) ?? []), t]); + } + console.error( + `\n⚠️ ${unplaceable.size} handled event(s) have NO hook that may carry them.`, + ); + for (const [product, types] of byProduct) { + console.error(`\n product '${product}' — no hook on this app is scoped to it:`); + for (const t of [...types].sort(byCodeUnit)) console.error(` · ${t}`); + console.error( + ` Create a '${product}' webhook in the Stream dashboard pointing at\n` + + ` /api/stream/webhooks, then re-run. Until then these events are\n` + + ` never delivered and the features behind them stay dead.`, + ); + } + } + // One write, carrying every hook the app has. Two things went wrong with the // per-hook write this replaces. It submitted `[oneHook]`, which replaces the // entire `event_hooks` array — so a second webhook, or an SQS or Pusher hook,