Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions scripts/stream/ensure-webhook-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,44 @@
*/
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<number> {

Check failure on line 109 in scripts/stream/ensure-webhook-subscription.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ_74VxBPrpwb32w9JjP&open=AZ_74VxBPrpwb32w9JjP&pullRequest=1153
if (!isStreamConfigured()) {
console.error(
"Stream is not configured — set STREAM_API_KEY and STREAM_API_SECRET",
Expand Down Expand Up @@ -100,21 +137,29 @@
let changed = 0;
/** hook id -> its widened event_types. Applied in ONE write after the loop. */
const widened = new Map<string, string[]>();
/** 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;
}

Expand All @@ -132,6 +177,30 @@
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<string, string[]>();
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` +
` <origin>/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,
Expand Down
Loading