# `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::` 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 ```ts 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 ```ts 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::` 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.