Source

server/hooks/utils.ts

import { OperationKeys } from "@decaf-ts/db-decorators";
import { createHmac, timingSafeEqual } from "crypto";

/**
 * @description Matches a concrete event topic against a webhook topic pattern
 * @summary Supports the enhanced `<model>.<action|*>.<item id/pk>` form: a bare model
 * name is treated as `<model>.*`, and `*` matches any single segment while a trailing
 * `*` swallows any remaining segments. The global catch-alls `*` and `*.*` match any
 * non-empty topic. A `*` mid-pattern is NOT greedy (it matches exactly one segment),
 * so patterns must align segment-by-segment.
 * @param {string} actual - The concrete `<model>.<action>.<id>` event topic
 * @param {string} pattern - The webhook subscription pattern to match against
 * @returns {boolean} Whether the actual topic matches the pattern
 * @function matchesTopic
 * @memberOf module:for-http.hooks
 * @mermaid
 * sequenceDiagram
 *   participant Caller
 *   participant matches as matchesTopic
 *   Caller->>matches: actual, pattern
 *   alt pattern is "*" or "*.*"
 *     matches-->>Caller: true
 *   else pattern has a single model segment
 *     matches->>matches: treat pattern as "<model>.*"
 *   end
 *   loop over pattern segments
 *     alt segment is trailing "*"
 *       matches-->>Caller: true
 *     else segment is mid "*"
 *       matches->>matches: match exactly one actual segment
 *     else
 *       matches->>matches: actual segment must equal pattern segment
 *     end
 *   end
 *   matches-->>Caller: equal lengths
 */
export function matchesTopic(actual: string, pattern: string): boolean {
  if (!actual || !pattern) return false;
  if (pattern === "*" || pattern === "*.*") return true;

  const actualParts = actual.split(".").filter(Boolean);
  let patternParts = pattern.split(".").filter(Boolean);

  // A bare model name is treated as "<model>.*" — all events for that table.
  if (patternParts.length === 1 && patternParts[0] !== "*") {
    patternParts = [patternParts[0], "*"];
  }

  if (!actualParts.length || !patternParts.length) return false;

  for (let i = 0; i < patternParts.length; i += 1) {
    const part = patternParts[i];
    if (part === "*") {
      // a trailing "*" swallows all remaining actual segments
      if (i === patternParts.length - 1) return true;
      if (actualParts.length <= i) return false;
      continue;
    }
    if (actualParts.length <= i || actualParts[i] !== part) return false;
  }

  return actualParts.length === patternParts.length;
}

export function signWebhookPayload(secret: string, rawBody: string): string {
  return createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
}

export function verifyWebhookSignature(
  secret: string,
  rawBody: string,
  signature: string
): boolean {
  const expected = Buffer.from(signWebhookPayload(secret, rawBody), "utf8");
  const received = Buffer.from(signature, "utf8");

  if (expected.length !== received.length) {
    return false;
  }

  return timingSafeEqual(expected, received);
}

export function computeNextAttempt(attempts: number): Date {
  // 30s, 1m, 2m, 4m, 8m, 16m...
  const delayMs = Math.min(
    30_000 * Math.pow(2, Math.max(attempts - 1, 0)),
    30 * 60_000
  );
  return new Date(Date.now() + delayMs);
}

export function keyToTopic(key: OperationKeys): string {
  return key.toLowerCase() + "d";
}

type BookmarkPaginator<T> = {
  page: (page?: number, bookmark?: any, ...args: any[]) => Promise<T[]>;
};

export async function collectPagedResults<T>(
  makePaginator: () => Promise<BookmarkPaginator<T>>,
  pageSize: number,
  ...args: any[]
): Promise<T[]> {
  const paginator = await makePaginator();
  const results: T[] = [];
  let bookmark: any = undefined;

  for (;;) {
    const page = await paginator.page(1, bookmark, ...args);
    results.push(...page);
    bookmark = (paginator as any)._bookmark;
    if (page.length < pageSize || !bookmark) break;
  }

  return results;
}