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 })— throwsEventBusError(INVALID_TOPIC) withcontext.reason ∈ "empty" | "exceeds-max-length" | "pattern-mismatch".scopeTopic(topic, tenantId?)— prefixes withtenant:<id>:when a tenant id is supplied.unscopeTopic(scoped, tenantId?)— inverse, returnsnullif the scoped string does not belong to the supplied tenant.topicRoot(topic)— derives the OTeltopic_rootlabel.DEFAULT_MAX_TOPIC_LENGTH(256) andTENANT_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
scopeTopicuse a stricter sub-regex (no:separator) — otherwise a tenant id of"t1:other"could emulatetenant:t1:other:topicfor another tenant. topicRoottruncates 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.