@opengeni/db 0.7.3 → 0.9.3

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.
Files changed (59) hide show
  1. package/dist/{chunk-B22X3IEZ.js → chunk-4LG5NBTC.js} +512 -32
  2. package/dist/chunk-4LG5NBTC.js.map +1 -0
  3. package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
  4. package/dist/chunk-BMFDXFPA.js.map +1 -0
  5. package/dist/chunk-KW526IJA.js +127 -0
  6. package/dist/chunk-KW526IJA.js.map +1 -0
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +6068 -2090
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrate.d.ts +29 -4
  11. package/dist/migrate.js +3 -1
  12. package/dist/provision-roles.d.ts +721 -46
  13. package/dist/provision-roles.js +1 -1
  14. package/dist/{schema-BN5mB9xZ.d.ts → schema-CdPGTHlD.d.ts} +2118 -119
  15. package/dist/schema.d.ts +1 -1
  16. package/dist/schema.js +17 -1
  17. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
  18. package/drizzle/0065_session_attempt_quiescence.sql +26 -0
  19. package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
  20. package/drizzle/0067_session_event_payload_bounds.sql +209 -0
  21. package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
  22. package/drizzle/0069_session_event_history_backfill.sql +32 -0
  23. package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
  24. package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
  25. package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
  26. package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
  27. package/drizzle/0074_session_activity_revisions.sql +72 -0
  28. package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
  29. package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
  30. package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
  31. package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
  32. package/drizzle/0095_github_existing_installations.sql +84 -0
  33. package/drizzle/0096_session_turn_initiators.sql +97 -0
  34. package/drizzle/0097_host_export_outbox.sql +1220 -0
  35. package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
  36. package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
  37. package/drizzle/0100_session_human_input_requests.sql +89 -0
  38. package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
  39. package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
  40. package/drizzle/0103_host_export_root_session.sql +166 -0
  41. package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
  42. package/drizzle/0105_session_turn_instructions.sql +9 -0
  43. package/package.json +4 -4
  44. package/src/connection-token-resolver.ts +287 -1
  45. package/src/event-payload-sanitizer.ts +57 -19
  46. package/src/index.ts +5377 -1125
  47. package/src/memory-domain.ts +1 -1
  48. package/src/migrate.ts +86 -23
  49. package/src/persistence-errors.ts +252 -0
  50. package/src/provision-roles.ts +42 -0
  51. package/src/schema.ts +552 -34
  52. package/src/session-control.ts +518 -38
  53. package/src/session-queue-commands.ts +308 -57
  54. package/src/session-tool-call-settlement.ts +58 -7
  55. package/src/turn-initiator.ts +155 -0
  56. package/dist/chunk-7LDU7F5P.js +0 -80
  57. package/dist/chunk-7LDU7F5P.js.map +0 -1
  58. package/dist/chunk-B22X3IEZ.js.map +0 -1
  59. package/dist/chunk-YFQ7SGE4.js.map +0 -1
@@ -3,6 +3,14 @@ import {
3
3
  type McpServerConnectionRef,
4
4
  type Settings,
5
5
  } from "@opengeni/config";
6
+ import type {
7
+ ConnectionCredentialsPort,
8
+ McpConnectionResourceScope,
9
+ McpCredentialAuthNeededReason,
10
+ McpCredentialsRequest,
11
+ TurnInitiator,
12
+ TurnInitiatorContext,
13
+ } from "@opengeni/contracts";
6
14
  import { Buffer } from "node:buffer";
7
15
  import { lookup } from "node:dns/promises";
8
16
  import { isIP } from "node:net";
@@ -20,11 +28,13 @@ export type ResolveConnectionCredentialResult =
20
28
  | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
21
29
  | {
22
30
  status: "auth_needed";
23
- reason: "missing_connection" | "expired" | "insufficient_scope" | "refresh_failed";
31
+ reason: McpCredentialAuthNeededReason;
24
32
  providerDomain: string;
33
+ provider?: string;
25
34
  connectionId?: string;
26
35
  scopes?: string[];
27
36
  resource?: string;
37
+ selectedResources?: McpConnectionResourceScope[];
28
38
  authorizationUrl?: string;
29
39
  };
30
40
  type AuthNeededReason = Extract<
@@ -36,11 +46,266 @@ export type ResolveConnectionCredentialInput = {
36
46
  workspaceId: string;
37
47
  subjectId?: string;
38
48
  serverId: string;
49
+ toolName?: string;
50
+ /** @deprecated Use toolName. Retained for the API's pre-existing broker call shape. */
39
51
  toolId?: string;
40
52
  connectionRef: McpServerConnectionRef;
41
53
  forceRefresh?: boolean;
42
54
  };
43
55
 
56
+ export type HostMcpCredentialResolverContext = {
57
+ accountId: string;
58
+ workspaceId: string;
59
+ sessionId: string;
60
+ rootSessionId: string;
61
+ turnId: string;
62
+ attemptId: string | null;
63
+ executionGeneration: number;
64
+ initiator: TurnInitiator;
65
+ initiatorContext: TurnInitiatorContext;
66
+ surface: McpCredentialsRequest["surface"];
67
+ };
68
+
69
+ export class HostMcpCredentialScopeError extends Error {
70
+ constructor(field: "accountId" | "workspaceId" | "sessionId") {
71
+ super(`host MCP credential ${field} scope mismatch`);
72
+ this.name = "HostMcpCredentialScopeError";
73
+ }
74
+ }
75
+
76
+ export class HostMcpCredentialBindingError extends Error {
77
+ constructor(
78
+ field:
79
+ | "provider"
80
+ | "providerDomain"
81
+ | "connectionId"
82
+ | "scopes"
83
+ | "resource"
84
+ | "selectedResources",
85
+ ) {
86
+ super(`host MCP credential ${field} binding mismatch`);
87
+ this.name = "HostMcpCredentialBindingError";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Adapts the public embedding credential port to the runtime's connection
93
+ * resolver contract. Scope echoes are checked before credential headers can
94
+ * reach a request; the returned object is a fresh copy so a host cannot mutate
95
+ * headers after resolution.
96
+ */
97
+ export function buildHostConnectionTokenResolver(
98
+ resolve: NonNullable<ConnectionCredentialsPort["mcpCredentials"]>,
99
+ context: HostMcpCredentialResolverContext,
100
+ ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
101
+ return async (input) => {
102
+ if (input.workspaceId !== context.workspaceId) {
103
+ throw new HostMcpCredentialScopeError("workspaceId");
104
+ }
105
+ const toolName = input.toolName ?? input.toolId;
106
+ const request: McpCredentialsRequest = {
107
+ accountId: context.accountId,
108
+ workspaceId: context.workspaceId,
109
+ sessionId: context.sessionId,
110
+ rootSessionId: context.rootSessionId,
111
+ turnId: context.turnId,
112
+ attemptId: context.attemptId,
113
+ executionGeneration: context.executionGeneration,
114
+ initiator: context.initiator,
115
+ initiatorContext: { ...context.initiatorContext },
116
+ surface: context.surface,
117
+ serverId: input.serverId,
118
+ connectionRef: {
119
+ providerDomain: input.connectionRef.providerDomain,
120
+ ...(input.connectionRef.provider ? { provider: input.connectionRef.provider } : {}),
121
+ ...(input.connectionRef.connectionId
122
+ ? { connectionId: input.connectionRef.connectionId }
123
+ : {}),
124
+ ...(input.connectionRef.kind ? { kind: input.connectionRef.kind } : {}),
125
+ ...(input.connectionRef.scopes ? { scopes: [...input.connectionRef.scopes] } : {}),
126
+ ...(input.connectionRef.resource ? { resource: input.connectionRef.resource } : {}),
127
+ ...(input.connectionRef.selectedResources
128
+ ? { selectedResources: copySelectedResources(input.connectionRef.selectedResources) }
129
+ : {}),
130
+ ...(input.connectionRef.subjectScope
131
+ ? { subjectScope: input.connectionRef.subjectScope }
132
+ : {}),
133
+ },
134
+ forceRefresh: input.forceRefresh === true,
135
+ ...(toolName ? { toolName } : {}),
136
+ ...(input.subjectId ? { callerSubjectId: input.subjectId } : {}),
137
+ };
138
+ const result = await resolve(request);
139
+ assertHostMcpCredentialScope(result, context);
140
+ assertHostMcpCredentialBinding(result, input.connectionRef);
141
+ if (result.status === "auth_needed") {
142
+ const authorizationUrl = normalizedAuthorizationUrl(result.authorizationUrl);
143
+ return {
144
+ status: "auth_needed",
145
+ reason: result.reason,
146
+ providerDomain: result.providerDomain,
147
+ ...(result.provider ? { provider: result.provider } : {}),
148
+ ...(result.connectionId ? { connectionId: result.connectionId } : {}),
149
+ ...(result.scopes ? { scopes: [...result.scopes] } : {}),
150
+ ...(result.resource ? { resource: result.resource } : {}),
151
+ ...(result.selectedResources
152
+ ? { selectedResources: copySelectedResources(result.selectedResources) }
153
+ : {}),
154
+ ...(authorizationUrl ? { authorizationUrl } : {}),
155
+ };
156
+ }
157
+ if (result.connectionId.length === 0) {
158
+ throw new Error("host MCP credential returned an empty connectionId");
159
+ }
160
+ const expiresAt = parseHostCredentialExpiry(result.expiresAt);
161
+ return {
162
+ status: "ok",
163
+ headers: normalizedHostCredentialHeaders(result.headers),
164
+ connectionId: result.connectionId,
165
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
166
+ };
167
+ };
168
+ }
169
+
170
+ function assertHostMcpCredentialBinding(
171
+ result: Awaited<ReturnType<NonNullable<ConnectionCredentialsPort["mcpCredentials"]>>>,
172
+ requested: McpServerConnectionRef,
173
+ ): void {
174
+ if (result.providerDomain !== requested.providerDomain) {
175
+ throw new HostMcpCredentialBindingError("providerDomain");
176
+ }
177
+ if (result.provider !== requested.provider) {
178
+ throw new HostMcpCredentialBindingError("provider");
179
+ }
180
+ if (requested.connectionId && result.connectionId !== requested.connectionId) {
181
+ throw new HostMcpCredentialBindingError("connectionId");
182
+ }
183
+ if (!sameSelectedResources(result.selectedResources, requested.selectedResources)) {
184
+ throw new HostMcpCredentialBindingError("selectedResources");
185
+ }
186
+ if (result.status === "ok") {
187
+ if (!sameStringSet(result.scopes, requested.scopes)) {
188
+ throw new HostMcpCredentialBindingError("scopes");
189
+ }
190
+ if (result.resource !== requested.resource) {
191
+ throw new HostMcpCredentialBindingError("resource");
192
+ }
193
+ }
194
+ }
195
+
196
+ function sameStringSet(left: string[] | undefined, right: string[] | undefined): boolean {
197
+ if (left === undefined || right === undefined) return left === right;
198
+ if (left.length !== right.length) return false;
199
+ const sortedLeft = [...left].sort();
200
+ const sortedRight = [...right].sort();
201
+ return sortedLeft.every((value, index) => value === sortedRight[index]);
202
+ }
203
+
204
+ function sameSelectedResources(
205
+ left: McpConnectionResourceScope[] | undefined,
206
+ right: McpConnectionResourceScope[] | undefined,
207
+ ): boolean {
208
+ if (left === undefined || right === undefined) return left === right;
209
+ const leftKeys = copySelectedResources(left)
210
+ .map((resource) => `${resource.kind}\0${resource.id}`)
211
+ .sort();
212
+ const rightKeys = copySelectedResources(right)
213
+ .map((resource) => `${resource.kind}\0${resource.id}`)
214
+ .sort();
215
+ return (
216
+ leftKeys.length === rightKeys.length &&
217
+ leftKeys.every((value, index) => value === rightKeys[index])
218
+ );
219
+ }
220
+
221
+ function copySelectedResources(
222
+ resources: McpConnectionResourceScope[],
223
+ ): McpConnectionResourceScope[] {
224
+ if (resources.length === 0 || resources.length > 256) {
225
+ throw new Error("host MCP credential returned an invalid selected resource count");
226
+ }
227
+ const seen = new Set<string>();
228
+ return resources.map((resource) => {
229
+ if (
230
+ resource.kind !== "repository" ||
231
+ typeof resource.id !== "string" ||
232
+ resource.id.length === 0 ||
233
+ resource.id.length > 512
234
+ ) {
235
+ throw new Error("host MCP credential returned an invalid selected resource");
236
+ }
237
+ const key = `${resource.kind}\0${resource.id}`;
238
+ if (seen.has(key)) {
239
+ throw new Error("host MCP credential returned duplicate selected resources");
240
+ }
241
+ seen.add(key);
242
+ return { kind: resource.kind, id: resource.id };
243
+ });
244
+ }
245
+
246
+ function assertHostMcpCredentialScope(
247
+ result: { accountId: string; workspaceId: string; sessionId: string },
248
+ context: HostMcpCredentialResolverContext,
249
+ ): void {
250
+ for (const field of ["accountId", "workspaceId", "sessionId"] as const) {
251
+ if (result[field] !== context[field]) {
252
+ throw new HostMcpCredentialScopeError(field);
253
+ }
254
+ }
255
+ }
256
+
257
+ function normalizedHostCredentialHeaders(headers: Record<string, string>): Record<string, string> {
258
+ const entries = Object.entries(headers);
259
+ if (entries.length === 0 || entries.length > 32) {
260
+ throw new Error("host MCP credential returned an invalid header count");
261
+ }
262
+ const normalized: Record<string, string> = {};
263
+ for (const [name, value] of entries) {
264
+ if (
265
+ name.length > 256 ||
266
+ !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name) ||
267
+ value.length === 0 ||
268
+ value.length > 16_384 ||
269
+ /[\r\n\0]/.test(value)
270
+ ) {
271
+ throw new Error("host MCP credential returned an invalid header");
272
+ }
273
+ normalized[name] = value;
274
+ }
275
+ return normalized;
276
+ }
277
+
278
+ function normalizedAuthorizationUrl(value: string | undefined): string | undefined {
279
+ if (!value) return undefined;
280
+ let url: URL;
281
+ try {
282
+ url = new URL(value);
283
+ } catch {
284
+ throw new Error("host MCP credential returned an invalid authorizationUrl");
285
+ }
286
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) {
287
+ throw new Error("host MCP credential returned an invalid authorizationUrl");
288
+ }
289
+ return url.toString();
290
+ }
291
+
292
+ function isLoopbackHost(hostname: string): boolean {
293
+ if (hostname === "localhost" || hostname === "::1") return true;
294
+ if (isIP(hostname) !== 4) return false;
295
+ const [first] = hostname.split(".");
296
+ return first === "127";
297
+ }
298
+
299
+ function parseHostCredentialExpiry(value: string | null | undefined): Date | null | undefined {
300
+ if (value === undefined) return undefined;
301
+ if (value === null) return null;
302
+ const parsed = new Date(value);
303
+ if (!Number.isFinite(parsed.getTime())) {
304
+ throw new Error("host MCP credential returned an invalid expiresAt");
305
+ }
306
+ return parsed;
307
+ }
308
+
44
309
  export type ConnectionBrokerDeps = {
45
310
  loadCredential: typeof loadConnectionCredentialForBroker;
46
311
  recordRefresh: typeof recordConnectionTokenRefresh;
@@ -65,6 +330,7 @@ const defaultDeps: ConnectionBrokerDeps = {
65
330
 
66
331
  const inflight = new Map<string, Promise<ConnectionCredentialForBroker>>();
67
332
  const REFRESH_WINDOW_MS = 60_000;
333
+ const CONNECTION_REFRESH_TIMEOUT_MS = 10_000;
68
334
 
69
335
  export function buildConnectionTokenResolver(
70
336
  db: Database,
@@ -105,9 +371,13 @@ export function buildConnectionTokenResolver(
105
371
  status: "auth_needed",
106
372
  reason: "insufficient_scope",
107
373
  providerDomain: ref.providerDomain,
374
+ ...(ref.provider ? { provider: ref.provider } : {}),
108
375
  connectionId: cred.id,
109
376
  scopes: missingScopes,
110
377
  ...(ref.resource ? { resource: ref.resource } : {}),
378
+ ...(ref.selectedResources
379
+ ? { selectedResources: copySelectedResources(ref.selectedResources) }
380
+ : {}),
111
381
  };
112
382
  }
113
383
  const headers = headersForCredential(cred);
@@ -116,9 +386,13 @@ export function buildConnectionTokenResolver(
116
386
  status: "auth_needed",
117
387
  reason: "refresh_failed",
118
388
  providerDomain: ref.providerDomain,
389
+ ...(ref.provider ? { provider: ref.provider } : {}),
119
390
  connectionId: cred.id,
120
391
  ...(ref.scopes ? { scopes: ref.scopes } : {}),
121
392
  ...(ref.resource ? { resource: ref.resource } : {}),
393
+ ...(ref.selectedResources
394
+ ? { selectedResources: copySelectedResources(ref.selectedResources) }
395
+ : {}),
122
396
  };
123
397
  }
124
398
  await deps.recordUsed(db, cred.workspaceId, cred.id);
@@ -192,6 +466,13 @@ export function buildConnectionTokenResolver(
192
466
 
193
467
  return async (input) => {
194
468
  const ref = input.connectionRef;
469
+ // Repository-scoped provider bindings require a broker that can prove the
470
+ // selected-resource boundary. The generic standalone credential store has
471
+ // no provider-specific containment adapter, so it must fail closed instead
472
+ // of handing an account-wide token to the configured endpoint.
473
+ if (ref.selectedResources) {
474
+ return authNeeded(ref, "resource_scope_unavailable", ref.connectionId);
475
+ }
195
476
  let cred: ConnectionCredentialForBroker | null;
196
477
  try {
197
478
  cred = await load(input);
@@ -276,9 +557,13 @@ function authNeeded(
276
557
  status: "auth_needed",
277
558
  reason,
278
559
  providerDomain: ref.providerDomain,
560
+ ...(ref.provider ? { provider: ref.provider } : {}),
279
561
  ...(connectionId ? { connectionId } : {}),
280
562
  ...(ref.scopes ? { scopes: ref.scopes } : {}),
281
563
  ...(ref.resource ? { resource: ref.resource } : {}),
564
+ ...(ref.selectedResources
565
+ ? { selectedResources: copySelectedResources(ref.selectedResources) }
566
+ : {}),
282
567
  };
283
568
  }
284
569
 
@@ -394,6 +679,7 @@ export async function refreshOAuthConnectionCredential(
394
679
  headers,
395
680
  body,
396
681
  redirect: "manual",
682
+ signal: AbortSignal.timeout(CONNECTION_REFRESH_TIMEOUT_MS),
397
683
  });
398
684
  if (response.status >= 300 && response.status < 400) {
399
685
  throw new ConnectionRefreshHttpError(response.status);
@@ -1,3 +1,5 @@
1
+ import { boundSessionEventPayload } from "@opengeni/contracts";
2
+
1
3
  /**
2
4
  * Last line of defense against a session event crashing a whole turn.
3
5
  *
@@ -8,12 +10,10 @@
8
10
  * such a payload reaches `INSERT INTO session_events`, the driver rejects it
9
11
  * ("Failed query: insert into session_events") and the turn dies.
10
12
  *
11
- * `sanitizeEventPayload` deep-walks any payload value (objects, arrays, nested)
12
- * and, for every string, strips NUL and rewrites invalid/lone UTF-16 surrogates
13
- * to the Unicode replacement char (U+FFFD), so the result is always valid UTF-8
14
- * that jsonb can store. It is cheap and total: only strings are touched, and only
15
- * the two disallowed classes of code unit -- no meaningful text is lost, no
16
- * truncation (truncation is handled elsewhere).
13
+ * `sanitizeEventPayload` deep-walks any payload value (objects, arrays, nested),
14
+ * repairs every string, redacts sensitive fields, then applies the canonical
15
+ * byte-bounded human/audit preview. Conversation truth uses
16
+ * `sanitizeModelPayload` below and remains a separate representation.
17
17
  */
18
18
 
19
19
  const REPLACEMENT = "�";
@@ -89,11 +89,22 @@ export function sanitizeEventString(value: string): string {
89
89
  * keys are sanitized too -- they are jsonb-constrained the same as values.
90
90
  */
91
91
  export function sanitizeEventPayload<T>(payload: T): T {
92
+ // Bound first. The preview walker caps depth/container fan-out and replaces
93
+ // inline media before this sanitizer allocates a deep clone. Reversing this
94
+ // order lets a cyclic, deeply nested, or multi-megabyte tool result exhaust
95
+ // the stack/heap before the durable 64 KiB event boundary can protect it.
96
+ return sanitizeEventPayloadDeep(boundSessionEventPayload(payload));
97
+ }
98
+
99
+ function sanitizeEventPayloadDeep<T>(payload: T): T {
92
100
  if (typeof payload === "string") {
93
101
  return sanitizeEventString(payload) as unknown as T;
94
102
  }
95
103
  if (Array.isArray(payload)) {
96
- return payload.map((item) => sanitizeEventPayload(item)) as unknown as T;
104
+ return payload.map((item) => sanitizeEventPayloadDeep(item)) as unknown as T;
105
+ }
106
+ if (payload instanceof Date) {
107
+ return safeDateIso(payload) as unknown as T;
97
108
  }
98
109
  if (payload && typeof payload === "object") {
99
110
  const entries = Object.entries(payload as Record<string, unknown>).map(
@@ -115,21 +126,48 @@ export function sanitizeEventPayload<T>(payload: T): T {
115
126
  * performs only the database-safety repair (NUL removal and UTF-16 repair).
116
127
  */
117
128
  export function sanitizeModelPayload<T>(payload: T): T {
129
+ return sanitizeModelPayloadDeep(payload, new WeakSet<object>(), 0);
130
+ }
131
+
132
+ const MODEL_PAYLOAD_SANITIZE_MAX_DEPTH = 64;
133
+ const MODEL_PAYLOAD_CYCLE_MARKER = "[OpenGeni omitted cyclic model payload]";
134
+ const MODEL_PAYLOAD_DEPTH_MARKER = "[OpenGeni omitted model payload beyond database-safety depth]";
135
+
136
+ function sanitizeModelPayloadDeep<T>(payload: T, seen: WeakSet<object>, depth: number): T {
118
137
  if (typeof payload === "string") {
119
138
  return sanitizeEventString(payload) as unknown as T;
120
139
  }
121
- if (Array.isArray(payload)) {
122
- return payload.map((item) => sanitizeModelPayload(item)) as unknown as T;
140
+ if (!payload || typeof payload !== "object") return payload;
141
+ if (payload instanceof Date) {
142
+ return safeDateIso(payload) as unknown as T;
123
143
  }
124
- if (payload && typeof payload === "object") {
144
+ if (depth >= MODEL_PAYLOAD_SANITIZE_MAX_DEPTH) {
145
+ return MODEL_PAYLOAD_DEPTH_MARKER as unknown as T;
146
+ }
147
+ if (seen.has(payload)) return MODEL_PAYLOAD_CYCLE_MARKER as unknown as T;
148
+ seen.add(payload);
149
+ try {
150
+ if (Array.isArray(payload)) {
151
+ return payload.map((item) => sanitizeModelPayloadDeep(item, seen, depth + 1)) as unknown as T;
152
+ }
125
153
  return Object.fromEntries(
126
154
  Object.entries(payload as Record<string, unknown>).map(([key, value]) => [
127
155
  sanitizeEventString(key),
128
- sanitizeModelPayload(value),
156
+ sanitizeModelPayloadDeep(value, seen, depth + 1),
129
157
  ]),
130
158
  ) as unknown as T;
159
+ } finally {
160
+ seen.delete(payload);
161
+ }
162
+ }
163
+
164
+ function safeDateIso(value: Date): string | null {
165
+ try {
166
+ const epoch = Date.prototype.getTime.call(value);
167
+ return Number.isFinite(epoch) ? Date.prototype.toISOString.call(value) : null;
168
+ } catch {
169
+ return null;
131
170
  }
132
- return payload;
133
171
  }
134
172
 
135
173
  function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
@@ -142,19 +180,19 @@ function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
142
180
  if (SENSITIVE_FIELD_NAMES.has(normalizeFieldName(key))) {
143
181
  return REDACTED;
144
182
  }
145
- return sanitizeEventPayload(value);
183
+ return sanitizeEventPayloadDeep(value);
146
184
  }
147
185
 
148
186
  function sanitizeSessionMcpServerList(value: unknown): unknown {
149
187
  if (!Array.isArray(value)) {
150
- return sanitizeEventPayload(value);
188
+ return sanitizeEventPayloadDeep(value);
151
189
  }
152
190
  return value.map((item) => {
153
191
  if (!isPlainObject(item)) {
154
- return sanitizeEventPayload(item);
192
+ return sanitizeEventPayloadDeep(item);
155
193
  }
156
194
  const { headers, headersEncrypted, ...rest } = item;
157
- const cleaned = sanitizeEventPayload(rest) as Record<string, unknown>;
195
+ const cleaned = sanitizeEventPayloadDeep(rest) as Record<string, unknown>;
158
196
  const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
159
197
  if (headerNames) {
160
198
  cleaned.headerNames = headerNames;
@@ -165,14 +203,14 @@ function sanitizeSessionMcpServerList(value: unknown): unknown {
165
203
 
166
204
  function sanitizeMcpCredentialUpdateList(value: unknown): unknown {
167
205
  if (!Array.isArray(value)) {
168
- return sanitizeEventPayload(value);
206
+ return sanitizeEventPayloadDeep(value);
169
207
  }
170
208
  return value.map((item) => {
171
209
  if (!isPlainObject(item)) {
172
- return sanitizeEventPayload(item);
210
+ return sanitizeEventPayloadDeep(item);
173
211
  }
174
212
  const { headers, headersEncrypted, ...rest } = item;
175
- const cleaned = sanitizeEventPayload(rest) as Record<string, unknown>;
213
+ const cleaned = sanitizeEventPayloadDeep(rest) as Record<string, unknown>;
176
214
  const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
177
215
  if (headerNames) {
178
216
  cleaned.headerNames = headerNames;