@truststate/sdk 0.2.0

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/src/client.ts ADDED
@@ -0,0 +1,448 @@
1
+ /**
2
+ * TrustStateClient — async HTTP client for the TrustState compliance API.
3
+ *
4
+ * Uses native fetch (Node 18+) and crypto.randomUUID(). Zero runtime dependencies.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { TrustStateClient } from "@truststate/sdk";
9
+ *
10
+ * const client = new TrustStateClient({ apiKey: "your-key" });
11
+ * const result = await client.check("AgentResponse", { text: "Hello!", score: 0.95 });
12
+ * console.log(result.passed, result.recordId);
13
+ * ```
14
+ */
15
+
16
+ import { TrustStateError } from "./errors.js";
17
+ import type {
18
+ BatchResult,
19
+ CheckItem,
20
+ ComplianceResult,
21
+ EvidenceItem,
22
+ TrustStateClientOptions,
23
+ } from "./types.js";
24
+
25
+ const DEFAULT_BASE_URL = "https://truststate-api.apps.trustchainlabs.com";
26
+
27
+ export class TrustStateClient {
28
+ private readonly apiKey: string;
29
+ private readonly baseUrl: string;
30
+ private readonly defaultSchemaVersion: string;
31
+ private readonly defaultActorId: string;
32
+ private readonly mock: boolean;
33
+ private readonly mockPassRate: number;
34
+ private readonly timeoutMs: number;
35
+
36
+ constructor(options: TrustStateClientOptions) {
37
+ this.apiKey = options.apiKey;
38
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
39
+ this.defaultSchemaVersion = options.defaultSchemaVersion ?? "1.0";
40
+ this.defaultActorId = options.defaultActorId ?? "";
41
+ this.mock = options.mock ?? false;
42
+ this.mockPassRate = options.mockPassRate ?? 1.0;
43
+ this.timeoutMs = options.timeoutMs ?? 30_000;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Public API
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Submit a single record for compliance checking.
52
+ *
53
+ * Internally wraps the record in a one-item batch call (POST /v1/write/batch).
54
+ *
55
+ * @param entityType - Entity category (e.g. "AgentResponse").
56
+ * @param data - The record payload to validate.
57
+ * @param options - Optional overrides for action, entityId, schemaVersion, actorId.
58
+ * @returns ComplianceResult with pass/fail status and, if passed, a recordId.
59
+ * @throws TrustStateError on HTTP 4xx/5xx.
60
+ */
61
+ async check(
62
+ entityType: string,
63
+ data: Record<string, unknown>,
64
+ options: {
65
+ action?: string;
66
+ entityId?: string;
67
+ schemaVersion?: string;
68
+ actorId?: string;
69
+ } = {}
70
+ ): Promise<ComplianceResult> {
71
+ const entityId = options.entityId ?? crypto.randomUUID();
72
+
73
+ if (this.mock) {
74
+ return this.mockSingleResult(entityId);
75
+ }
76
+
77
+ const batchResult = await this.checkBatch(
78
+ [
79
+ {
80
+ entityType,
81
+ data,
82
+ action: options.action,
83
+ entityId,
84
+ schemaVersion: options.schemaVersion,
85
+ actorId: options.actorId,
86
+ },
87
+ ],
88
+ {
89
+ defaultSchemaVersion: options.schemaVersion,
90
+ defaultActorId: options.actorId,
91
+ }
92
+ );
93
+
94
+ return batchResult.results[0];
95
+ }
96
+
97
+ /**
98
+ * Submit multiple records for compliance checking in a single API call.
99
+ *
100
+ * @param items - Array of CheckItem objects.
101
+ * @param options - Optional default schemaVersion and actorId for items that omit them.
102
+ * @returns BatchResult with per-item results and aggregate counts.
103
+ * @throws TrustStateError on HTTP 4xx/5xx.
104
+ */
105
+ async checkBatch(
106
+ items: CheckItem[],
107
+ options: {
108
+ defaultSchemaVersion?: string;
109
+ defaultActorId?: string;
110
+ /** Label identifying this feed/source — echoed back on every item result. */
111
+ feedLabel?: string;
112
+ } = {}
113
+ ): Promise<BatchResult> {
114
+ const schemaVer = options.defaultSchemaVersion ?? this.defaultSchemaVersion;
115
+ const actor = options.defaultActorId ?? this.defaultActorId;
116
+
117
+ // Normalise items — assign missing entity IDs and fill defaults
118
+ const normalised = items.map((item) => {
119
+ const entry: Record<string, unknown> & { entityId: string } = {
120
+ entityType: item.entityType,
121
+ data: item.data,
122
+ action: item.action ?? "upsert",
123
+ entityId: item.entityId ?? crypto.randomUUID(),
124
+ actorId: item.actorId ?? actor ?? "sdk-writer",
125
+ };
126
+ const sv = item.schemaVersion ?? schemaVer;
127
+ if (sv) entry.schemaVersion = sv;
128
+ return entry;
129
+ });
130
+
131
+ if (this.mock) {
132
+ return this.mockBatchResult(normalised, options.feedLabel);
133
+ }
134
+
135
+ const payload: Record<string, unknown> = { items: normalised };
136
+ if (schemaVer) payload.defaultSchemaVersion = schemaVer;
137
+ if (actor) payload.defaultActorId = actor;
138
+ if (options.feedLabel) payload.feedLabel = options.feedLabel;
139
+
140
+ const responseJson = await this.post("/v1/write/batch", payload);
141
+ return this.parseBatchResponse(responseJson);
142
+ }
143
+
144
+ /**
145
+ * Retrieve an immutable compliance record from the ledger.
146
+ *
147
+ * @param recordId - The record ID returned by a previous check() that passed.
148
+ * @param bearerToken - A valid Bearer token for the TrustState API.
149
+ * @returns The full record object from the API.
150
+ * @throws TrustStateError on HTTP 4xx/5xx.
151
+ */
152
+ // ---------------------------------------------------------------------------
153
+ // BYOP Evidence fetch helpers
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Fetch an FX rate oracle evidence item.
157
+ * @example
158
+ * const fx = await client.fetchFxRate("MYR", "USD");
159
+ * const result = await client.checkWithEvidence("SukukBond", data, [fx]);
160
+ */
161
+ async fetchFxRate(
162
+ fromCurrency: string,
163
+ toCurrency: string,
164
+ providerId = "reuters-fx",
165
+ maxAgeSeconds = 300,
166
+ ): Promise<EvidenceItem> {
167
+ const subject = { from: fromCurrency, to: toCurrency };
168
+ if (this.mock) {
169
+ const stubs: Record<string, number> = { MYR_USD: 0.2119, USD_MYR: 4.72, EUR_USD: 1.085, GBP_USD: 1.267 };
170
+ return this.makeEvidenceItem(providerId, "fx_rate", subject, stubs[`${fromCurrency}_${toCurrency}`] ?? 1.0, maxAgeSeconds);
171
+ }
172
+ const data: any = await this.get(`/v1/oracle/fx-rate?from=${fromCurrency}&to=${toCurrency}&providerId=${providerId}`);
173
+ return this.parseEvidenceResponse(data, providerId, "fx_rate", subject, maxAgeSeconds);
174
+ }
175
+
176
+ /** Fetch a KYC status oracle evidence item. */
177
+ async fetchKycStatus(
178
+ subjectId: string,
179
+ providerId = "sumsub-kyc",
180
+ maxAgeSeconds = 86400,
181
+ ): Promise<EvidenceItem> {
182
+ const subject = { id: subjectId };
183
+ if (this.mock) {
184
+ return this.makeEvidenceItem(providerId, "kyc_status", subject, "PASS", maxAgeSeconds);
185
+ }
186
+ const data: any = await this.get(`/v1/oracle/kyc-status?subjectId=${subjectId}&providerId=${providerId}`);
187
+ return this.parseEvidenceResponse(data, providerId, "kyc_status", subject, maxAgeSeconds);
188
+ }
189
+
190
+ /** Fetch a credit score oracle evidence item. */
191
+ async fetchCreditScore(
192
+ subjectId: string,
193
+ providerId = "coface-credit",
194
+ maxAgeSeconds = 86400,
195
+ ): Promise<EvidenceItem> {
196
+ const subject = { id: subjectId };
197
+ if (this.mock) {
198
+ return this.makeEvidenceItem(providerId, "credit_score", subject, 720, maxAgeSeconds);
199
+ }
200
+ const data: any = await this.get(`/v1/oracle/credit-score?subjectId=${subjectId}&providerId=${providerId}`);
201
+ return this.parseEvidenceResponse(data, providerId, "credit_score", subject, maxAgeSeconds);
202
+ }
203
+
204
+ /** Fetch a sanctions screening oracle evidence item. */
205
+ async fetchSanctions(
206
+ subjectId: string,
207
+ providerId = "refinitiv-sanct",
208
+ maxAgeSeconds = 3600,
209
+ ): Promise<EvidenceItem> {
210
+ const subject = { id: subjectId };
211
+ if (this.mock) {
212
+ return this.makeEvidenceItem(providerId, "sanctions", subject, "CLEAR", maxAgeSeconds);
213
+ }
214
+ const data: any = await this.get(`/v1/oracle/sanctions?subjectId=${subjectId}&providerId=${providerId}`);
215
+ return this.parseEvidenceResponse(data, providerId, "sanctions", subject, maxAgeSeconds);
216
+ }
217
+
218
+ /** Submit a compliance check with oracle evidence attached.
219
+ * @example
220
+ * const fx = await client.fetchFxRate("MYR", "USD");
221
+ * const result = await client.checkWithEvidence("SukukBond", payload, [fx]);
222
+ */
223
+ async checkWithEvidence(
224
+ entityType: string,
225
+ data: Record<string, unknown>,
226
+ evidence: EvidenceItem[],
227
+ options: { action?: string; entityId?: string; schemaVersion?: string; actorId?: string } = {},
228
+ ): Promise<ComplianceResult> {
229
+ if (this.mock) return this.mockSingleResult(options.entityId ?? crypto.randomUUID());
230
+ const entityId = options.entityId ?? crypto.randomUUID();
231
+ const schemaVersion = options.schemaVersion ?? this.defaultSchemaVersion;
232
+ const actorId = options.actorId ?? this.defaultActorId;
233
+ const item: Record<string, unknown> = {
234
+ entityType,
235
+ data,
236
+ action: options.action ?? "upsert",
237
+ entityId,
238
+ actorId: actorId ?? "sdk-writer",
239
+ evidence,
240
+ };
241
+ if (schemaVersion) item.schemaVersion = schemaVersion;
242
+ const response = await this.post("/v1/write/batch", { items: [item] });
243
+ return this.parseBatchResponse(response).results[0];
244
+ }
245
+
246
+ private makeEvidenceItem(
247
+ providerId: string,
248
+ providerType: string,
249
+ subject: Record<string, unknown>,
250
+ observedValue: string | number,
251
+ maxAgeSeconds: number,
252
+ ): EvidenceItem {
253
+ const now = new Date().toISOString();
254
+ return {
255
+ evidenceId: crypto.randomUUID(),
256
+ providerId,
257
+ providerType,
258
+ subject,
259
+ observedValue,
260
+ observedAt: now,
261
+ retrievedAt: now,
262
+ maxAgeSeconds,
263
+ mock: true,
264
+ };
265
+ }
266
+
267
+ private parseEvidenceResponse(
268
+ data: any,
269
+ defaultProviderId: string,
270
+ providerType: string,
271
+ subject: Record<string, unknown>,
272
+ maxAgeSeconds: number,
273
+ ): EvidenceItem {
274
+ return {
275
+ evidenceId: crypto.randomUUID(),
276
+ providerId: data.providerId ?? defaultProviderId,
277
+ providerType,
278
+ subject,
279
+ observedValue: data.observedValue,
280
+ observedAt: data.observedAt,
281
+ retrievedAt: new Date().toISOString(),
282
+ maxAgeSeconds,
283
+ proofHash: data.proofHash,
284
+ rawProofUri: data.rawProofUri,
285
+ attestation: data.attestation,
286
+ };
287
+ }
288
+
289
+ async verify(recordId: string, bearerToken: string): Promise<unknown> {
290
+ const url = `${this.baseUrl}/v1/records/${recordId}`;
291
+ const controller = new AbortController();
292
+ const timerId = setTimeout(() => controller.abort(), this.timeoutMs);
293
+
294
+ let response: Response;
295
+ try {
296
+ response = await fetch(url, {
297
+ method: "GET",
298
+ headers: { Authorization: `Bearer ${bearerToken}` },
299
+ signal: controller.signal,
300
+ });
301
+ } catch (err) {
302
+ throw new TrustStateError(`Network error: ${(err as Error).message}`);
303
+ } finally {
304
+ clearTimeout(timerId);
305
+ }
306
+
307
+ if (!response.ok) {
308
+ const body = await response.text().catch(() => "");
309
+ throw new TrustStateError(
310
+ `API error ${response.status}: ${body}`,
311
+ response.status
312
+ );
313
+ }
314
+
315
+ return response.json();
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Internal helpers
320
+ // ---------------------------------------------------------------------------
321
+
322
+ private async get(path: string): Promise<Record<string, unknown>> {
323
+ const url = `${this.baseUrl}${path}`;
324
+ const controller = new AbortController();
325
+ const timerId = setTimeout(() => controller.abort(), this.timeoutMs);
326
+ let response: Response;
327
+ try {
328
+ response = await fetch(url, {
329
+ method: "GET",
330
+ headers: { "X-API-Key": this.apiKey },
331
+ signal: controller.signal,
332
+ });
333
+ } catch (err) {
334
+ throw new TrustStateError(`Network error: ${(err as Error).message}`);
335
+ } finally {
336
+ clearTimeout(timerId);
337
+ }
338
+ if (!response.ok) {
339
+ const body = await response.text().catch(() => "");
340
+ throw new TrustStateError(`API error ${response.status}: ${body}`, response.status);
341
+ }
342
+ return response.json() as Promise<Record<string, unknown>>;
343
+ }
344
+
345
+ private async post(
346
+ path: string,
347
+ payload: Record<string, unknown>
348
+ ): Promise<Record<string, unknown>> {
349
+ const url = `${this.baseUrl}${path}`;
350
+ const controller = new AbortController();
351
+ const timerId = setTimeout(() => controller.abort(), this.timeoutMs);
352
+
353
+ let response: Response;
354
+ try {
355
+ response = await fetch(url, {
356
+ method: "POST",
357
+ headers: {
358
+ "Content-Type": "application/json",
359
+ "X-API-Key": this.apiKey,
360
+ },
361
+ body: JSON.stringify(payload),
362
+ signal: controller.signal,
363
+ });
364
+ } catch (err) {
365
+ throw new TrustStateError(`Network error: ${(err as Error).message}`);
366
+ } finally {
367
+ clearTimeout(timerId);
368
+ }
369
+
370
+ if (!response.ok) {
371
+ const body = await response.text().catch(() => "");
372
+ throw new TrustStateError(
373
+ `API error ${response.status}: ${body}`,
374
+ response.status
375
+ );
376
+ }
377
+
378
+ return response.json() as Promise<Record<string, unknown>>;
379
+ }
380
+
381
+ private parseBatchResponse(data: Record<string, unknown>): BatchResult {
382
+ const rawResults = (data.results as Record<string, unknown>[]) ?? [];
383
+ const results: ComplianceResult[] = rawResults.map((r) => {
384
+ // Batch API returns status: 'accepted' | 'rejected', or passed: true/false directly
385
+ const passed = r.status === "accepted" || r.passed === true;
386
+ return {
387
+ passed,
388
+ recordId: r.recordId as string | undefined,
389
+ requestId: (r.requestId as string) ?? "",
390
+ entityId: (r.entityId as string) ?? "",
391
+ failReason: r.failReason as string | undefined,
392
+ failedStep: r.failedStep as number | undefined,
393
+ feedLabel: (r.feedLabel as string | null) ?? null,
394
+ mock: false,
395
+ };
396
+ });
397
+
398
+ const accepted = results.filter((r) => r.passed).length;
399
+
400
+ return {
401
+ batchId: (data.batchId as string) ?? crypto.randomUUID(),
402
+ total: (data.total as number) ?? results.length,
403
+ accepted: (data.accepted as number) ?? accepted,
404
+ rejected: (data.rejected as number) ?? results.length - accepted,
405
+ results,
406
+ feedLabel: (data.feedLabel as string | null) ?? null,
407
+ mock: false,
408
+ };
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Mock helpers (zero network calls)
413
+ // ---------------------------------------------------------------------------
414
+
415
+ private mockSingleResult(entityId: string): ComplianceResult {
416
+ const passed = Math.random() < this.mockPassRate;
417
+ return {
418
+ passed,
419
+ recordId: passed ? `mock-rec-${crypto.randomUUID()}` : undefined,
420
+ requestId: `mock-req-${crypto.randomUUID()}`,
421
+ entityId,
422
+ failReason: passed ? undefined : "Mock: simulated policy failure",
423
+ failedStep: passed ? undefined : 9,
424
+ mock: true,
425
+ };
426
+ }
427
+
428
+ private mockBatchResult(
429
+ normalisedItems: Array<{ entityId: string }>,
430
+ feedLabel?: string
431
+ ): BatchResult {
432
+ const results = normalisedItems.map((item) => ({
433
+ ...this.mockSingleResult(item.entityId),
434
+ feedLabel: feedLabel ?? null,
435
+ }));
436
+ const accepted = results.filter((r) => r.passed).length;
437
+
438
+ return {
439
+ batchId: `mock-batch-${crypto.randomUUID()}`,
440
+ total: results.length,
441
+ accepted,
442
+ rejected: results.length - accepted,
443
+ results,
444
+ feedLabel: feedLabel ?? null,
445
+ mock: true,
446
+ };
447
+ }
448
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Custom error class for TrustState API failures.
3
+ */
4
+ export class TrustStateError extends Error {
5
+ /** HTTP status code returned by the API (0 if not an HTTP error). */
6
+ public readonly statusCode: number;
7
+
8
+ constructor(message: string, statusCode = 0) {
9
+ super(message);
10
+ this.name = "TrustStateError";
11
+ this.statusCode = statusCode;
12
+ // Maintain proper prototype chain in transpiled ES5
13
+ Object.setPrototypeOf(this, TrustStateError.prototype);
14
+ }
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @truststate/sdk — TypeScript/JavaScript SDK for TrustState compliance validation.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { TrustStateClient } from "@truststate/sdk";
7
+ *
8
+ * const client = new TrustStateClient({ apiKey: "your-key" });
9
+ * const result = await client.check("AgentResponse", { text: "Hello!", score: 0.95 });
10
+ * ```
11
+ */
12
+
13
+ export { TrustStateClient } from "./client.js";
14
+ export { TrustStateError } from "./errors.js";
15
+ export { trustStateMiddleware, withCompliance } from "./middleware.js";
16
+ export type {
17
+ BatchResult,
18
+ CheckItem,
19
+ ComplianceResult,
20
+ EvidenceItem,
21
+ TrustStateClientOptions,
22
+ } from "./types.js";
23
+ export type { TrustStateMiddlewareOptions } from "./middleware.js";
@@ -0,0 +1,224 @@
1
+ /**
2
+ * TrustState middleware for Express and Next.js.
3
+ *
4
+ * @example Express
5
+ * ```typescript
6
+ * import express from "express";
7
+ * import { TrustStateClient, trustStateMiddleware } from "@truststate/sdk";
8
+ *
9
+ * const app = express();
10
+ * const client = new TrustStateClient({ apiKey: "your-key" });
11
+ * app.use(express.json());
12
+ * app.use(trustStateMiddleware(client));
13
+ * ```
14
+ *
15
+ * @example Next.js API route
16
+ * ```typescript
17
+ * import { withCompliance } from "@truststate/sdk";
18
+ * import { client } from "@/lib/truststate";
19
+ *
20
+ * export default withCompliance(client, "AgentResponse", async (req, res) => {
21
+ * res.json({ message: "Compliant response" });
22
+ * });
23
+ * ```
24
+ */
25
+
26
+ import { TrustStateClient } from "./client.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Express types (kept minimal to avoid adding @types/express as a dependency)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface ExpressRequest {
33
+ method: string;
34
+ headers: Record<string, string | string[] | undefined>;
35
+ body: unknown;
36
+ }
37
+
38
+ interface ExpressResponse {
39
+ status(code: number): ExpressResponse;
40
+ json(body: unknown): void;
41
+ setHeader(name: string, value: string): void;
42
+ }
43
+
44
+ type NextFunction = (err?: unknown) => void;
45
+ type RequestHandler = (
46
+ req: ExpressRequest,
47
+ res: ExpressResponse,
48
+ next: NextFunction
49
+ ) => void | Promise<void>;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Next.js types (minimal)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ interface NextApiRequest {
56
+ method?: string;
57
+ headers: Record<string, string | string[] | undefined>;
58
+ body: unknown;
59
+ }
60
+
61
+ interface NextApiResponse {
62
+ status(code: number): NextApiResponse;
63
+ json(body: unknown): void;
64
+ setHeader(name: string, value: string): void;
65
+ }
66
+
67
+ type NextApiHandler = (req: NextApiRequest, res: NextApiResponse) => void | Promise<void>;
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Express middleware options
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface TrustStateMiddlewareOptions {
74
+ /**
75
+ * Default entity type to use when X-Compliance-Entity-Type header is absent.
76
+ * If neither the header nor this option is set, the request passes through.
77
+ */
78
+ defaultEntityType?: string;
79
+ /**
80
+ * Default action to use when X-Compliance-Action header is absent.
81
+ * @default "CREATE"
82
+ */
83
+ defaultAction?: string;
84
+ /**
85
+ * Header that carries a stable entity ID for this request.
86
+ * @default "X-Compliance-Entity-Id"
87
+ */
88
+ entityIdHeader?: string;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Express middleware
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Express middleware that gates requests on TrustState compliance.
97
+ *
98
+ * Reads X-Compliance-Entity-Type and X-Compliance-Action headers.
99
+ * If X-Compliance-Entity-Type is present (or defaultEntityType is set),
100
+ * the request body is submitted to TrustState before the request proceeds.
101
+ *
102
+ * Failed checks return HTTP 422. Passed checks attach X-Compliance-Record-Id
103
+ * to the response headers.
104
+ */
105
+ export function trustStateMiddleware(
106
+ client: TrustStateClient,
107
+ options: TrustStateMiddlewareOptions = {}
108
+ ): RequestHandler {
109
+ const {
110
+ defaultEntityType,
111
+ defaultAction = "CREATE",
112
+ entityIdHeader = "X-Compliance-Entity-Id",
113
+ } = options;
114
+
115
+ return async function (
116
+ req: ExpressRequest,
117
+ res: ExpressResponse,
118
+ next: NextFunction
119
+ ): Promise<void> {
120
+ const entityType =
121
+ (req.headers["x-compliance-entity-type"] as string | undefined) ??
122
+ defaultEntityType;
123
+
124
+ // Pass through if no entity type configured
125
+ if (!entityType) {
126
+ next();
127
+ return;
128
+ }
129
+
130
+ const action =
131
+ (req.headers["x-compliance-action"] as string | undefined) ?? defaultAction;
132
+ const entityId = req.headers[entityIdHeader.toLowerCase()] as string | undefined;
133
+ const data = (req.body as Record<string, unknown>) ?? {};
134
+
135
+ try {
136
+ const result = await client.check(entityType, data, {
137
+ action,
138
+ entityId: entityId ?? undefined,
139
+ });
140
+
141
+ if (!result.passed) {
142
+ res.status(422).json({
143
+ error: "Compliance check failed",
144
+ failReason: result.failReason,
145
+ failedStep: result.failedStep,
146
+ entityId: result.entityId,
147
+ });
148
+ return;
149
+ }
150
+
151
+ // Attach record ID to response for downstream use
152
+ if (result.recordId) {
153
+ res.setHeader("X-Compliance-Record-Id", result.recordId);
154
+ }
155
+
156
+ next();
157
+ } catch (err) {
158
+ res.status(503).json({
159
+ error: "Compliance service unavailable",
160
+ detail: (err as Error).message,
161
+ });
162
+ }
163
+ };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Next.js route handler wrapper
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Wraps a Next.js API route handler with TrustState compliance checking.
172
+ *
173
+ * The request body is submitted to TrustState before the handler runs.
174
+ * Returns 422 if the check fails.
175
+ *
176
+ * @param client - TrustStateClient instance.
177
+ * @param entityType - The TrustState entity type to validate against.
178
+ * @param handler - The Next.js route handler to wrap.
179
+ * @param options - Optional action and entityIdHeader overrides.
180
+ */
181
+ export function withCompliance(
182
+ client: TrustStateClient,
183
+ entityType: string,
184
+ handler: NextApiHandler,
185
+ options: {
186
+ action?: string;
187
+ entityIdHeader?: string;
188
+ } = {}
189
+ ): NextApiHandler {
190
+ const { action = "CREATE", entityIdHeader = "x-compliance-entity-id" } = options;
191
+
192
+ return async function (req: NextApiRequest, res: NextApiResponse): Promise<void> {
193
+ const entityId = req.headers[entityIdHeader] as string | undefined;
194
+ const data = (req.body as Record<string, unknown>) ?? {};
195
+
196
+ try {
197
+ const result = await client.check(entityType, data, {
198
+ action,
199
+ entityId: entityId ?? undefined,
200
+ });
201
+
202
+ if (!result.passed) {
203
+ res.status(422).json({
204
+ error: "Compliance check failed",
205
+ failReason: result.failReason,
206
+ failedStep: result.failedStep,
207
+ entityId: result.entityId,
208
+ });
209
+ return;
210
+ }
211
+
212
+ if (result.recordId) {
213
+ res.setHeader("X-Compliance-Record-Id", result.recordId);
214
+ }
215
+
216
+ await handler(req, res);
217
+ } catch (err) {
218
+ res.status(503).json({
219
+ error: "Compliance service unavailable",
220
+ detail: (err as Error).message,
221
+ });
222
+ }
223
+ };
224
+ }