Source

events-module/utils.ts

import type { Constructor } from "@decaf-ts/decoration";

/**
 * @description Normalizes an observer refresh payload for SSE delivery
 * @summary Maps the raw observer arguments — model reference, operation, id and
 * payload — into a serializable tuple by resolving the model name and serializing
 * the payload(s). Array payloads are serialized element-wise, dropping elements
 * that cannot be serialized.
 * @param {any[]} args - The raw observer arguments `[model, operation, id, payload]`
 * @returns {unknown[]} The tuple `[modelName, operation, id, serializedPayload]`
 * @function normalizeEventResponse
 * @mermaid
 * sequenceDiagram
 *   participant Caller
 *   participant normalize as normalizeEventResponse
 *   Caller->>normalize: args [model, operation, id, payload]
 *   normalize->>normalize: resolve modelName
 *   alt payload is an array
 *     normalize->>normalize: serialize each element
 *   else payload is serializable
 *     normalize->>normalize: payload.serialize()
 *   else
 *     normalize->>normalize: JSON.stringify(payload)
 *   end
 *   normalize-->>Caller: [modelName, operation, id, serializedPayload]
 * @memberOf module:for-nest.events
 */
export function normalizeEventResponse(args: any[]): unknown[] {
  const [modelConstr, operation, id, payload] = args;

  const modelName = modelConstr?.name ?? modelConstr;

  const serializedPayload = Array.isArray(payload)
    ? payload.map((e) => {
        try {
          if (typeof e.serialize === "function") return e.serialize();

          console.warn(
            `Payload item for ${modelName} does not have serialize method and is an ${typeof e}, attempting to stringify directly. Item: ${e}`
          );
          return typeof e === "string" ? e : JSON.stringify(e);
        } catch (err: unknown) {
          console.warn(`Failed to serialize payload for ${modelName}: ${err}`);
          return undefined;
        }
      })
    : payload && typeof payload.serialize === "function"
      ? payload.serialize()
      : typeof payload === "string"
        ? payload
        : JSON.stringify(payload);

  console.debug(
    `Normalized event response for model ${modelName}, operation ${operation}, id ${id}:`,
    serializedPayload
  );

  return [modelName, operation, id, serializedPayload];
}

/**
 * @description Resolves the name of a model
 * @summary Returns the model name when given a string, a class constructor (or an
 * instance thereof) or an object carrying a `name`/`constructor.name`.
 * @param {string|Constructor|object|undefined} model - The model to name
 * @returns {string} The model name, or an empty string when it cannot be resolved
 * @function nameOf
 * @memberOf module:for-nest.events
 */
export function nameOf(model: string | Constructor | object | undefined): string {
  if (typeof model === "string") return model;
  if (typeof model === "function" && model?.name) return model.name;
  if (typeof model === "object" && model) {
    const name = (model as any)?.name ?? (model as any)?.constructor?.name;
    return typeof name === "string" ? name : "";
  }
  return "";
}

/**
 * @description Builds the webhook topic for an observed event
 * @summary Builds the `<model>.<action>.<id>` topic consumed by the subscription
 * matcher, dropping the id segment when the id is an array, null or absent.
 * @param {string|Constructor|object|undefined} model - The model the event belongs to
 * @param {string} event - The operation key (e.g. `create`, `update`, `delete`)
 * @param {*} [id] - The event id; arrays are ignored
 * @returns {string} The string `<model>.<action>` or `<model>.<action>.<id>` topic
 * @function eventTopicFor
 * @memberOf module:for-nest.events
 */
export function eventTopicFor(
  model: string | Constructor | object | undefined,
  event: string,
  id?: any
): string {
  const modelName = nameOf(model);
  if (!modelName) return "";
  const segments = [modelName, event];
  if (id !== undefined && id !== null) {
    const scalar = Array.isArray(id) ? undefined : id;
    if (scalar !== undefined) segments.push(String(scalar));
  }
  return segments.filter(Boolean).join(".");
}

/**
 * @description Identity resolved for a requester for topic-scoped SSE
 * @summary Carries the value that identifies a requester, together with how that
 * value was resolved: an authenticated user, the `x-correlation-id` header, or a
 * fallback connection id.
 * @typedef {Object} RequesterFingerprint
 * @property {string} value - The resolved fingerprint value
 * @property {'user'|'correlationId'|'connection'} kind - How the fingerprint was resolved
 * @memberOf module:for-nest.events
 */
export type RequesterFingerprint = {
  value: string;
  kind: "user" | "correlationId" | "connection";
};

/**
 * @description Extracts a fingerprint from an authenticated user identity
 * @summary Returns the identity itself when it is a non-empty string, or the value
 * of the `id`/`uuid`/`UUID`/`user` property when the identity is an object. Returns
 * undefined for null, numeric or unidentifiable identities.
 * @param {unknown} identity - The authenticated user identity
 * @returns {string|undefined} The user fingerprint, if resolvable
 * @function fingerprintOfUser
 * @memberOf module:for-nest.events
 */
export function fingerprintOfUser(identity: unknown): string | undefined {
  if (identity === undefined || identity === null) return undefined;
  if (typeof identity === "string") {
    return identity.trim() ? identity : undefined;
  }
  if (typeof identity === "object") {
    const candidate = (identity as Record<string, unknown>);
    const value =
      candidate["id"] ?? candidate["uuid"] ?? candidate["UUID"] ?? candidate["user"];
    if (typeof value === "string" && value.trim()) return value;
    return undefined;
  }
  return undefined;
}

/**
 * @description Stable, logged-safe prefix of a requester fingerprint
 * @summary Truncates a fingerprint to its first eight characters so logs never
 * leak the full identifier; returns `<none>` for empty input.
 * @param {string} fingerprint - The full requester fingerprint
 * @returns {string} The truncated label, or `<none>`
 * @function fingerprintLabel
 * @memberOf module:for-nest.events
 */
export function fingerprintLabel(fingerprint: string): string {
  if (!fingerprint) return "<none>";
  return fingerprint.length <= 8 ? fingerprint : `${fingerprint.slice(0, 8)}...`;
}

const MAX_TOPIC_LENGTH = 512;
const MAX_TOPIC_SEGMENTS = 8;

/**
 * @description Sanitizes a set of requested webhook topics
 * @summary Trims each topic, drops empty topics, topics longer than 512 characters,
 * topics with more than 8 dot-separated segments and duplicate entries.
 * @param {Iterable<string>} topics - The raw requested topics
 * @returns {string[]} The deduplicated, validated topics
 * @function sanitizeTopics
 * @memberOf module:for-nest.events
 */
export function sanitizeTopics(topics: Iterable<string>): string[] {
  const seen = new Set<string>();
  const sanitized: string[] = [];
  for (const raw of topics ?? []) {
    const topic = (raw ?? "").trim();
    if (!topic || topic.length > MAX_TOPIC_LENGTH) continue;
    if (topic.split(".").length > MAX_TOPIC_SEGMENTS) continue;
    if (seen.has(topic)) continue;
    seen.add(topic);
    sanitized.push(topic);
  }
  return sanitized;
}

/**
 * @description Resolves the requester fingerprint for an incoming request
 * @summary Resolves the caller identity in priority order: an authenticated user,
 * then the `x-correlation-id` header, and finally the supplied fallback (typically
 * a freshly generated id) which is classified as a connection fingerprint.
 * @param {Object} context - The request context used for resolution
 * @param {function(string): unknown} [context.getOrUndefined] - Context lookup keyed by name (e.g. `user`)
 * @param {Object} [context.headers] - Raw request headers
 * @param {string} fallback - Fallback value when no identity nor correlation header exists
 * @returns {RequesterFingerprint} The resolved fingerprint, with its resolution kind
 * @function resolveRequesterFingerprint
 * @mermaid
 * sequenceDiagram
 *   participant Caller
 *   participant resolve as resolveRequesterFingerprint
 *   Caller->>resolve: context, fallback
 *   alt authenticated user present
 *     resolve-->>Caller: { kind: user }
 *   else x-correlation-id header present
 *     resolve-->>Caller: { kind: correlationId }
 *   else
 *     resolve-->>Caller: { kind: connection, value: fallback }
 *   end
 * @memberOf module:for-nest.events
 */
export function resolveRequesterFingerprint(
  context: {
    getOrUndefined?: (key: string) => unknown;
    headers?: Record<string, string | string[] | undefined> | undefined;
  },
  fallback: string
): RequesterFingerprint {
  const authenticated = context.getOrUndefined?.("user");
  const userFingerprint = fingerprintOfUser(authenticated);
  if (userFingerprint) {
    return { value: userFingerprint, kind: "user" };
  }

  const rawHeaders = context.headers ?? {};
  const header = rawHeaders["x-correlation-id"] ?? rawHeaders["X-Correlation-ID"];
  const correlationId = Array.isArray(header) ? header[0] : header;
  if (correlationId) {
    return { value: correlationId, kind: "correlationId" };
  }

  return { value: fallback, kind: "connection" };
}