@qnsp/tenant-sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@qnsp/tenant-sdk",
3
+ "version": "0.0.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "description": "TypeScript SDK client for the QNSP tenant-service API. Provides tenant lifecycle and subscription management.",
9
+ "exports": {
10
+ "./package.json": "./package.json",
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "dependencies": {
17
+ "@opentelemetry/api": "^1.9.0",
18
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.208.0",
19
+ "@opentelemetry/sdk-metrics": "^2.2.0",
20
+ "@qnsp/observability": "^0.0.1",
21
+ "undici": "^7.16.0",
22
+ "zod": "^4.1.12"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.13.14",
26
+ "tsx": "^4.20.6",
27
+ "vitest": "^4.0.10"
28
+ },
29
+ "engines": {
30
+ "node": "24.12.0"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc --project tsconfig.build.json",
34
+ "dev": "tsx watch src/index.ts",
35
+ "lint": "biome check .",
36
+ "test": "vitest run",
37
+ "typecheck": "tsc --project tsconfig.json --noEmit"
38
+ }
39
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { TenantClient } from "./index.js";
3
+
4
+ describe("TenantClient Security Tests", () => {
5
+ const mockFetch = vi.fn();
6
+ global.fetch = mockFetch;
7
+
8
+ describe("HTTPS Enforcement", () => {
9
+ it("should reject HTTP URLs in production", () => {
10
+ const originalEnv = process.env["NODE_ENV"];
11
+ process.env["NODE_ENV"] = "production";
12
+
13
+ expect(() => {
14
+ new TenantClient({
15
+ baseUrl: "http://example.com",
16
+ });
17
+ }).toThrow("baseUrl must use HTTPS in production");
18
+
19
+ process.env["NODE_ENV"] = originalEnv;
20
+ });
21
+
22
+ it("should allow HTTP localhost in development", () => {
23
+ const originalEnv = process.env["NODE_ENV"];
24
+ process.env["NODE_ENV"] = "development";
25
+
26
+ expect(() => {
27
+ new TenantClient({
28
+ baseUrl: "http://localhost:3000",
29
+ });
30
+ }).not.toThrow();
31
+
32
+ process.env["NODE_ENV"] = originalEnv;
33
+ });
34
+ });
35
+
36
+ describe("Input Validation", () => {
37
+ const client = new TenantClient({
38
+ baseUrl: "https://api.example.com",
39
+ });
40
+
41
+ it("should reject invalid UUIDs", async () => {
42
+ await expect(client.getTenant("not-a-uuid")).rejects.toThrow("Invalid id");
43
+ });
44
+
45
+ it("should reject SQL injection in tenantId", async () => {
46
+ await expect(client.getTenant("'; DROP TABLE tenants; --")).rejects.toThrow("Invalid id");
47
+ });
48
+
49
+ it("should reject path traversal in tenantId", async () => {
50
+ await expect(client.getTenant("../../etc/passwd")).rejects.toThrow("Invalid id");
51
+ });
52
+
53
+ it("should reject XSS attempts in tenantId", async () => {
54
+ await expect(client.getTenant("<script>alert('xss')</script>")).rejects.toThrow("Invalid id");
55
+ });
56
+ });
57
+
58
+ describe("Error Message Sanitization", () => {
59
+ const client = new TenantClient({
60
+ baseUrl: "https://api.example.com",
61
+ });
62
+
63
+ it("should not expose sensitive data in error messages", async () => {
64
+ mockFetch.mockResolvedValueOnce({
65
+ ok: false,
66
+ status: 500,
67
+ statusText: "Internal Server Error",
68
+ headers: new Headers(),
69
+ text: async () =>
70
+ JSON.stringify({
71
+ error: "Database connection failed",
72
+ stack: "at TenantService.getTenant()",
73
+ apiKey: "sk_live_1234567890",
74
+ password: "secret123",
75
+ }),
76
+ });
77
+
78
+ await expect(client.getTenant("123e4567-e89b-12d3-a456-426614174000")).rejects.toThrow(
79
+ "Tenant API error: 500 Internal Server Error",
80
+ );
81
+
82
+ const errorMessage = await (async () => {
83
+ try {
84
+ await client.getTenant("123e4567-e89b-12d3-a456-426614174000");
85
+ return "";
86
+ } catch (error) {
87
+ return error instanceof Error ? error.message : String(error);
88
+ }
89
+ })();
90
+
91
+ expect(errorMessage).not.toContain("sk_live_1234567890");
92
+ expect(errorMessage).not.toContain("secret123");
93
+ expect(errorMessage).not.toContain("Database connection failed");
94
+ expect(errorMessage).not.toContain("stack");
95
+ });
96
+ });
97
+
98
+ describe("Rate Limiting", () => {
99
+ const client = new TenantClient({
100
+ baseUrl: "https://api.example.com",
101
+ maxRetries: 2,
102
+ retryDelayMs: 10,
103
+ });
104
+
105
+ it("should retry on 429 with Retry-After header", async () => {
106
+ let attemptCount = 0;
107
+ mockFetch.mockImplementation(async () => {
108
+ attemptCount++;
109
+ if (attemptCount === 1) {
110
+ return {
111
+ ok: false,
112
+ status: 429,
113
+ statusText: "Too Many Requests",
114
+ headers: new Headers({ "Retry-After": "1" }),
115
+ text: async () => "",
116
+ };
117
+ }
118
+ return {
119
+ ok: true,
120
+ status: 200,
121
+ headers: new Headers(),
122
+ json: async () => ({
123
+ id: "123e4567-e89b-12d3-a456-426614174000",
124
+ name: "test-tenant",
125
+ slug: "test-tenant",
126
+ status: "active",
127
+ plan: "basic",
128
+ region: "us-east-1",
129
+ complianceTags: [],
130
+ metadata: {},
131
+ security: {
132
+ controlPlaneTokenSha256: null,
133
+ pqcSignatures: [],
134
+ hardwareProvider: null,
135
+ attestationStatus: null,
136
+ attestationProof: null,
137
+ },
138
+ domains: [],
139
+ createdAt: "2024-01-01T00:00:00Z",
140
+ updatedAt: "2024-01-01T00:00:00Z",
141
+ }),
142
+ };
143
+ });
144
+
145
+ const result = await client.getTenant("123e4567-e89b-12d3-a456-426614174000");
146
+
147
+ expect(result.id).toBe("123e4567-e89b-12d3-a456-426614174000");
148
+ expect(attemptCount).toBe(2);
149
+ });
150
+
151
+ it("should fail after max retries", async () => {
152
+ mockFetch.mockResolvedValue({
153
+ ok: false,
154
+ status: 429,
155
+ statusText: "Too Many Requests",
156
+ headers: new Headers(),
157
+ text: async () => "",
158
+ });
159
+
160
+ await expect(client.getTenant("123e4567-e89b-12d3-a456-426614174000")).rejects.toThrow(
161
+ "Rate limit exceeded after 2 retries",
162
+ );
163
+ });
164
+ });
165
+ });
package/src/index.ts ADDED
@@ -0,0 +1,359 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ import type {
4
+ TenantClientTelemetry,
5
+ TenantClientTelemetryConfig,
6
+ TenantClientTelemetryEvent,
7
+ } from "./observability.js";
8
+ import { createTenantClientTelemetry, isTenantClientTelemetry } from "./observability.js";
9
+ import { validateUUID } from "./validation.js";
10
+
11
+ /**
12
+ * @qnsp/tenant-sdk
13
+ *
14
+ * TypeScript SDK client for the QNSP tenant-service API.
15
+ * Provides a high-level interface for tenant lifecycle and subscription management.
16
+ */
17
+
18
+ export interface TenantClientConfig {
19
+ readonly baseUrl: string;
20
+ readonly apiKey?: string;
21
+ readonly timeoutMs?: number;
22
+ readonly telemetry?: TenantClientTelemetry | TenantClientTelemetryConfig;
23
+ readonly maxRetries?: number;
24
+ readonly retryDelayMs?: number;
25
+ }
26
+
27
+ type InternalTenantClientConfig = {
28
+ readonly baseUrl: string;
29
+ readonly apiKey: string;
30
+ readonly timeoutMs: number;
31
+ readonly maxRetries: number;
32
+ readonly retryDelayMs: number;
33
+ };
34
+
35
+ export type TenantStatus = "active" | "suspended" | "pending" | "deleted";
36
+
37
+ export interface TenantSecurityEnvelope {
38
+ readonly controlPlaneTokenSha256: string | null;
39
+ readonly pqcSignatures: readonly {
40
+ readonly provider: string;
41
+ readonly algorithm: string;
42
+ readonly value: string;
43
+ readonly publicKey: string;
44
+ }[];
45
+ readonly hardwareProvider: string | null;
46
+ readonly attestationStatus: string | null;
47
+ readonly attestationProof: string | null;
48
+ }
49
+
50
+ export interface TenantSignature {
51
+ readonly provider: string;
52
+ readonly algorithm: string;
53
+ readonly value: string;
54
+ readonly publicKey: string;
55
+ }
56
+
57
+ export interface TenantDomain {
58
+ readonly id: string;
59
+ readonly domain: string;
60
+ readonly verified: boolean;
61
+ readonly createdAt: string;
62
+ readonly updatedAt: string;
63
+ }
64
+
65
+ export interface Tenant {
66
+ readonly id: string;
67
+ readonly name: string;
68
+ readonly slug: string;
69
+ readonly status: TenantStatus;
70
+ readonly plan: string;
71
+ readonly region: string;
72
+ readonly complianceTags: readonly string[];
73
+ readonly metadata: Record<string, unknown>;
74
+ readonly security: TenantSecurityEnvelope;
75
+ readonly domains: readonly TenantDomain[];
76
+ readonly createdAt: string;
77
+ readonly updatedAt: string;
78
+ }
79
+
80
+ export interface CreateTenantRequest {
81
+ readonly name: string;
82
+ readonly slug: string;
83
+ readonly plan?: string;
84
+ readonly region?: string;
85
+ readonly complianceTags?: readonly string[];
86
+ readonly metadata?: Record<string, unknown>;
87
+ readonly domains?: readonly {
88
+ readonly domain: string;
89
+ readonly verified?: boolean;
90
+ }[];
91
+ readonly security: TenantSecurityEnvelope;
92
+ readonly signature?: TenantSignature;
93
+ }
94
+
95
+ export interface UpdateTenantRequest {
96
+ readonly plan?: string;
97
+ readonly status?: TenantStatus;
98
+ readonly complianceTags?: readonly string[];
99
+ readonly metadata?: Record<string, unknown>;
100
+ readonly security: TenantSecurityEnvelope;
101
+ readonly signature?: TenantSignature;
102
+ }
103
+
104
+ export interface ListTenantsResponse {
105
+ readonly items: readonly Tenant[];
106
+ readonly nextCursor: string | null;
107
+ }
108
+
109
+ interface RequestOptions {
110
+ readonly body?: unknown;
111
+ readonly headers?: Record<string, string>;
112
+ readonly signal?: AbortSignal;
113
+ readonly operation?: string;
114
+ readonly telemetryRoute?: string;
115
+ readonly telemetryTarget?: string;
116
+ }
117
+
118
+ export class TenantClient {
119
+ private readonly config: InternalTenantClientConfig;
120
+ private readonly telemetry: TenantClientTelemetry | null;
121
+ private readonly targetService: string;
122
+
123
+ constructor(config: TenantClientConfig) {
124
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
125
+
126
+ // Enforce HTTPS in production (allow HTTP only for localhost in development)
127
+ if (!baseUrl.startsWith("https://")) {
128
+ const isLocalhost =
129
+ baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1");
130
+ const isDevelopment =
131
+ process.env["NODE_ENV"] === "development" || process.env["NODE_ENV"] === "test";
132
+ if (!isLocalhost || !isDevelopment) {
133
+ throw new Error(
134
+ "baseUrl must use HTTPS in production. HTTP is only allowed for localhost in development.",
135
+ );
136
+ }
137
+ }
138
+
139
+ this.config = {
140
+ baseUrl,
141
+ apiKey: config.apiKey ?? "",
142
+ timeoutMs: config.timeoutMs ?? 30_000,
143
+ maxRetries: config.maxRetries ?? 3,
144
+ retryDelayMs: config.retryDelayMs ?? 1_000,
145
+ };
146
+
147
+ this.telemetry = config.telemetry
148
+ ? isTenantClientTelemetry(config.telemetry)
149
+ ? config.telemetry
150
+ : createTenantClientTelemetry(config.telemetry)
151
+ : null;
152
+
153
+ try {
154
+ this.targetService = new URL(this.config.baseUrl).host;
155
+ } catch {
156
+ this.targetService = "tenant-service";
157
+ }
158
+ }
159
+
160
+ private async request<T>(method: string, path: string, options?: RequestOptions): Promise<T> {
161
+ return this.requestWithRetry<T>(method, path, options, 0);
162
+ }
163
+
164
+ private async requestWithRetry<T>(
165
+ method: string,
166
+ path: string,
167
+ options: RequestOptions | undefined,
168
+ attempt: number,
169
+ ): Promise<T> {
170
+ const url = `${this.config.baseUrl}${path}`;
171
+ const headers: Record<string, string> = {
172
+ "Content-Type": "application/json",
173
+ ...options?.headers,
174
+ };
175
+
176
+ if (this.config.apiKey) {
177
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
178
+ }
179
+
180
+ const controller = new AbortController();
181
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
182
+ const signal = options?.signal ?? controller.signal;
183
+ const route = options?.telemetryRoute ?? new URL(path, this.config.baseUrl).pathname;
184
+ const target = options?.telemetryTarget ?? this.targetService;
185
+ const start = performance.now();
186
+ let status: "ok" | "error" = "ok";
187
+ let httpStatus: number | undefined;
188
+ let errorMessage: string | undefined;
189
+
190
+ try {
191
+ const init: RequestInit = {
192
+ method,
193
+ headers,
194
+ signal,
195
+ };
196
+
197
+ if (options?.body !== undefined) {
198
+ init.body = JSON.stringify(options.body);
199
+ }
200
+
201
+ const response = await fetch(url, init);
202
+
203
+ clearTimeout(timeoutId);
204
+ httpStatus = response.status;
205
+
206
+ // Handle rate limiting (429) with retry logic
207
+ if (response.status === 429) {
208
+ if (attempt < this.config.maxRetries) {
209
+ const retryAfterHeader = response.headers.get("Retry-After");
210
+ let delayMs = this.config.retryDelayMs;
211
+
212
+ if (retryAfterHeader) {
213
+ const retryAfterSeconds = Number.parseInt(retryAfterHeader, 10);
214
+ if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
215
+ delayMs = retryAfterSeconds * 1_000;
216
+ }
217
+ } else {
218
+ // Exponential backoff: 2^attempt * baseDelay, capped at 30 seconds
219
+ delayMs = Math.min(2 ** attempt * this.config.retryDelayMs, 30_000);
220
+ }
221
+
222
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
223
+ return this.requestWithRetry<T>(method, path, options, attempt + 1);
224
+ }
225
+
226
+ status = "error";
227
+ errorMessage = `HTTP ${response.status}`;
228
+ throw new Error(
229
+ `Tenant API error: Rate limit exceeded after ${this.config.maxRetries} retries`,
230
+ );
231
+ }
232
+
233
+ if (!response.ok) {
234
+ status = "error";
235
+ // Sanitize error message to prevent information disclosure
236
+ // Don't include full response text in error to avoid leaking sensitive data
237
+ errorMessage = `HTTP ${response.status}`;
238
+ throw new Error(`Tenant API error: ${response.status} ${response.statusText}`);
239
+ }
240
+
241
+ if (response.status === 204) {
242
+ return undefined as T;
243
+ }
244
+
245
+ return (await response.json()) as T;
246
+ } catch (error) {
247
+ clearTimeout(timeoutId);
248
+ status = "error";
249
+ if (!errorMessage && error instanceof Error) {
250
+ errorMessage = error.message;
251
+ }
252
+ if (error instanceof Error && error.name === "AbortError") {
253
+ errorMessage = `timeout after ${this.config.timeoutMs}ms`;
254
+ throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
255
+ }
256
+ throw error;
257
+ } finally {
258
+ const durationMs = performance.now() - start;
259
+ const event: TenantClientTelemetryEvent = {
260
+ operation: options?.operation ?? `${method} ${route}`,
261
+ method,
262
+ route,
263
+ target,
264
+ status,
265
+ durationMs,
266
+ ...(typeof httpStatus === "number" ? { httpStatus } : {}),
267
+ ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
268
+ };
269
+ this.recordTelemetryEvent(event);
270
+ }
271
+ }
272
+
273
+ private recordTelemetryEvent(event: TenantClientTelemetryEvent): void {
274
+ if (!this.telemetry) {
275
+ return;
276
+ }
277
+ this.telemetry.record(event);
278
+ }
279
+
280
+ /**
281
+ * Create a new tenant.
282
+ * Requires PQC-signed security envelope and optional signature.
283
+ */
284
+ async createTenant(request: CreateTenantRequest): Promise<Tenant> {
285
+ // Validation is handled by the service, but we validate format here for early feedback
286
+ return this.request<Tenant>("POST", "/tenant/v1/tenants", {
287
+ body: {
288
+ name: request.name,
289
+ slug: request.slug,
290
+ ...(request.plan !== undefined ? { plan: request.plan } : {}),
291
+ ...(request.region !== undefined ? { region: request.region } : {}),
292
+ ...(request.complianceTags !== undefined ? { complianceTags: request.complianceTags } : {}),
293
+ ...(request.metadata !== undefined ? { metadata: request.metadata } : {}),
294
+ ...(request.domains !== undefined ? { domains: request.domains } : {}),
295
+ security: request.security,
296
+ ...(request.signature !== undefined ? { signature: request.signature } : {}),
297
+ },
298
+ operation: "createTenant",
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Update a tenant's plan, status, compliance tags, or metadata.
304
+ * Requires PQC-signed security envelope and optional signature.
305
+ */
306
+ async updateTenant(id: string, request: UpdateTenantRequest): Promise<Tenant> {
307
+ validateUUID(id, "id");
308
+
309
+ return this.request<Tenant>("PATCH", `/tenant/v1/tenants/${id}`, {
310
+ body: {
311
+ ...(request.plan !== undefined ? { plan: request.plan } : {}),
312
+ ...(request.status !== undefined ? { status: request.status } : {}),
313
+ ...(request.complianceTags !== undefined ? { complianceTags: request.complianceTags } : {}),
314
+ ...(request.metadata !== undefined ? { metadata: request.metadata } : {}),
315
+ security: request.security,
316
+ ...(request.signature !== undefined ? { signature: request.signature } : {}),
317
+ },
318
+ operation: "updateTenant",
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Get a tenant by ID.
324
+ * Returns the tenant with domains and security envelope.
325
+ */
326
+ async getTenant(id: string): Promise<Tenant> {
327
+ validateUUID(id, "id");
328
+
329
+ return this.request<Tenant>("GET", `/tenant/v1/tenants/${id}`, {
330
+ operation: "getTenant",
331
+ });
332
+ }
333
+
334
+ /**
335
+ * List tenants with cursor-based pagination.
336
+ * Returns a list of tenants and an optional next cursor for pagination.
337
+ */
338
+ async listTenants(options?: {
339
+ readonly limit?: number;
340
+ readonly cursor?: string;
341
+ }): Promise<ListTenantsResponse> {
342
+ const params = new URLSearchParams();
343
+ if (options?.limit !== undefined) {
344
+ params.set("limit", String(options.limit));
345
+ }
346
+ if (options?.cursor !== undefined) {
347
+ params.set("cursor", options.cursor);
348
+ }
349
+ const queryString = params.toString();
350
+ const path = queryString ? `/tenant/v1/tenants?${queryString}` : "/tenant/v1/tenants";
351
+
352
+ return this.request<ListTenantsResponse>("GET", path, {
353
+ operation: "listTenants",
354
+ });
355
+ }
356
+ }
357
+
358
+ export * from "./observability.js";
359
+ export * from "./validation.js";
@@ -0,0 +1,115 @@
1
+ import type { Attributes } from "@opentelemetry/api";
2
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
3
+ import type { MetricReader } from "@opentelemetry/sdk-metrics";
4
+ import {
5
+ ConsoleMetricExporter,
6
+ createCounter,
7
+ createHistogram,
8
+ createMeterProvider,
9
+ PeriodicExportingMetricReader,
10
+ } from "@qnsp/observability";
11
+
12
+ export interface TenantClientTelemetryConfig {
13
+ readonly serviceName: string;
14
+ readonly serviceVersion?: string;
15
+ readonly environment?: string;
16
+ readonly otlpEndpoint?: string;
17
+ readonly metricsIntervalMs?: number;
18
+ readonly metricsTimeoutMs?: number;
19
+ readonly exporterFactory?: () => MetricReader;
20
+ }
21
+
22
+ export interface TenantClientTelemetryEvent {
23
+ readonly operation: string;
24
+ readonly method: string;
25
+ readonly route: string;
26
+ readonly status: "ok" | "error";
27
+ readonly durationMs: number;
28
+ readonly httpStatus?: number;
29
+ readonly target?: string;
30
+ readonly error?: string;
31
+ }
32
+
33
+ export interface TenantClientTelemetry {
34
+ record(event: TenantClientTelemetryEvent): void;
35
+ }
36
+
37
+ export function createTenantClientTelemetry(
38
+ config: TenantClientTelemetryConfig,
39
+ ): TenantClientTelemetry {
40
+ const interval = config.metricsIntervalMs ?? 60_000;
41
+ const timeout = config.metricsTimeoutMs ?? 15_000;
42
+ const readers: MetricReader[] = [];
43
+
44
+ if (typeof config.exporterFactory === "function") {
45
+ readers.push(config.exporterFactory());
46
+ } else if (config.otlpEndpoint) {
47
+ readers.push(
48
+ new PeriodicExportingMetricReader({
49
+ exporter: new OTLPMetricExporter({
50
+ url: config.otlpEndpoint,
51
+ }),
52
+ exportIntervalMillis: interval,
53
+ exportTimeoutMillis: timeout,
54
+ }),
55
+ );
56
+ } else if (process.env["NODE_ENV"] !== "test") {
57
+ readers.push(
58
+ new PeriodicExportingMetricReader({
59
+ exporter: new ConsoleMetricExporter(),
60
+ exportIntervalMillis: interval,
61
+ exportTimeoutMillis: timeout,
62
+ }),
63
+ );
64
+ }
65
+
66
+ const provider = createMeterProvider(
67
+ {
68
+ serviceName: config.serviceName,
69
+ serviceVersion: config.serviceVersion ?? "0.0.0",
70
+ environment: config.environment ?? process.env["NODE_ENV"] ?? "development",
71
+ },
72
+ readers,
73
+ );
74
+
75
+ const requestCounter = createCounter(provider, "tenant_sdk_requests_total", {
76
+ description: "Count of Tenant SDK HTTP requests",
77
+ });
78
+ const failureCounter = createCounter(provider, "tenant_sdk_request_failures_total", {
79
+ description: "Count of failed Tenant SDK HTTP requests",
80
+ });
81
+ const durationHistogram = createHistogram(provider, "tenant_sdk_request_duration_ms", {
82
+ description: "Latency of Tenant SDK HTTP requests",
83
+ unit: "ms",
84
+ });
85
+
86
+ return {
87
+ record(event) {
88
+ const baseAttributes: Attributes = {
89
+ service: config.serviceName,
90
+ operation: event.operation,
91
+ method: event.method,
92
+ route: event.route,
93
+ target: event.target ?? event.route,
94
+ status: event.status,
95
+ ...(event.httpStatus ? { http_status: event.httpStatus } : {}),
96
+ };
97
+
98
+ requestCounter.add(1, baseAttributes);
99
+ durationHistogram.record(event.durationMs, baseAttributes);
100
+
101
+ if (event.status === "error") {
102
+ failureCounter.add(1, {
103
+ ...baseAttributes,
104
+ error: event.error ?? "unknown",
105
+ });
106
+ }
107
+ },
108
+ };
109
+ }
110
+
111
+ export function isTenantClientTelemetry(
112
+ value: TenantClientTelemetry | TenantClientTelemetryConfig,
113
+ ): value is TenantClientTelemetry {
114
+ return typeof (value as TenantClientTelemetry)?.record === "function";
115
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Validation schemas for tenant-sdk inputs
5
+ */
6
+
7
+ export const uuidSchema = z.string().uuid("Invalid UUID format");
8
+
9
+ /**
10
+ * Validates a UUID string
11
+ */
12
+ export function validateUUID(value: string, fieldName: string): void {
13
+ try {
14
+ uuidSchema.parse(value);
15
+ } catch (error) {
16
+ if (error instanceof z.ZodError) {
17
+ throw new Error(`Invalid ${fieldName}: ${error.issues[0]?.message ?? "Invalid format"}`);
18
+ }
19
+ throw error;
20
+ }
21
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "sourceMap": true
8
+ },
9
+ "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "dist", "build"]
10
+ }