Files
skills/docs/api-stack/api/api-plugin-event-bus/src/topic/topic.md
2026-05-29 08:20:56 +01:00

4.1 KiB

topic.ts — topic validation + tenant scoping

(no submodules)

Purpose

Pure functions for validating event-bus topics and projecting them into their tenant-scoped form. Every publish and subscribe going through eventBusService calls assertValidTopic at the boundary, and the scoped middleware handle uses scopeTopic / unscopeTopic to keep per-tenant traffic isolated. Pulling this logic into its own module keeps the service-layer code small and lets test code compute scoped topics without spinning up the full plugin.

Features

  • Canonical EVENT_TOPIC_PATTERN (/^[a-z0-9_\-:.]+$/) — single source of truth for topic syntax.
  • assertValidTopic(topic, { maxLength }) — throws EventBusError (INVALID_TOPIC) with context.reason ∈ "empty" | "exceeds-max-length" | "pattern-mismatch".
  • scopeTopic(topic, tenantId?) — prefixes with tenant:<id>: when a tenant id is supplied.
  • unscopeTopic(scoped, tenantId?) — inverse, returns null if the scoped string does not belong to the supplied tenant.
  • topicRoot(topic) — derives the OTel topic_root label.
  • DEFAULT_MAX_TOPIC_LENGTH (256) and TENANT_TOPIC_PREFIX (tenant:) constants for downstream reuse.

Usage: Basic

import {
    assertValidTopic,
    scopeTopic,
} from "@suaveplan/api-plugin-event-bus/topic/topic";

assertValidTopic("order.created"); // OK
assertValidTopic("Order Created"); // throws EventBusError(INVALID_TOPIC)

const scoped = scopeTopic("order.created", "acme");
// "tenant:acme:order.created"

Usage: Advanced

import {
    DEFAULT_MAX_TOPIC_LENGTH,
    scopeTopic,
    topicRoot,
    unscopeTopic,
} from "@suaveplan/api-plugin-event-bus/topic/topic";

function project(records: { topic: string }[], tenantId: string) {
    return records
        .map((r) => {
            const original = unscopeTopic(r.topic, tenantId);
            if (original === null) return null;
            return { topic: original, root: topicRoot(original) };
        })
        .filter((x): x is { topic: string; root: string } => x !== null);
}

// Use the constant when tuning per-app limits — e.g. an app whose
// service mesh truncates labels at 128 may pass `maxLength: 128` to
// `assertValidTopic`.
console.log(DEFAULT_MAX_TOPIC_LENGTH);

API Reference

EVENT_TOPIC_PATTERN: RegExp

Frozen regex; lowercase letters, digits, _, -, :, ..

DEFAULT_MAX_TOPIC_LENGTH: 256

TENANT_TOPIC_PREFIX: "tenant:"

assertValidTopic(topic: string, options?: { maxLength?: number }): void

Throws EventBusError(EVENT_BUS.INVALID_TOPIC) with a context.reason discriminator on failure. Returns void on success.

scopeTopic(topic: string, tenantId?: string): string

Returns tenant:<id>:<topic> when tenantId is non-empty; otherwise returns topic verbatim. Validates tenantId against a stricter regex (no : allowed) so callers cannot smuggle a foreign tenant prefix through the parameter — a malformed id throws EventBusError(EVENT_BUS.INVALID_TOPIC) with context.reason === "tenant-id-pattern-mismatch".

unscopeTopic(scoped: string, tenantId?: string): string | null

Returns the original topic if scoped matches the tenant prefix. Returns null if the prefix does not match (used by listSubscriptions to filter foreign records). When tenantId is absent the function is the identity.

topicRoot(topic: string): string

Returns the substring before the first . or :. Used as the topic_root label on event_bus.published_total.

Implementation Notes

  • The pattern intentionally excludes uppercase letters and whitespace so topics stay safe for use as URL path segments, OTel attribute values, and message-queue routing keys without further escaping.
  • Tenant ids carried through scopeTopic use a stricter sub-regex (no : separator) — otherwise a tenant id of "t1:other" could emulate tenant:t1:other:topic for another tenant.
  • topicRoot truncates at the first separator rather than producing high-cardinality labels — OTel limits the number of distinct label values per metric, so we keep this deliberately coarse.