@opengeni/db 0.6.1 → 0.7.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.
Files changed (53) hide show
  1. package/dist/{chunk-OGCE6O2X.js → chunk-7LDU7F5P.js} +31 -3
  2. package/dist/chunk-7LDU7F5P.js.map +1 -0
  3. package/dist/chunk-B22X3IEZ.js +2634 -0
  4. package/dist/chunk-B22X3IEZ.js.map +1 -0
  5. package/dist/{chunk-57MLICFR.js → chunk-YFQ7SGE4.js} +18 -6
  6. package/dist/chunk-YFQ7SGE4.js.map +1 -0
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +16571 -5018
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrate.js +1 -1
  11. package/dist/provision-roles.d.ts +2121 -248
  12. package/dist/provision-roles.js +1 -1
  13. package/dist/{schema-BUbuMteO.d.ts → schema-BN5mB9xZ.d.ts} +8716 -3432
  14. package/dist/schema.d.ts +1 -1
  15. package/dist/schema.js +43 -5
  16. package/drizzle/0044_reap_dead_turn_holders.sql +104 -0
  17. package/drizzle/0045_workspace_captures.sql +83 -0
  18. package/drizzle/0045_workspace_memory_v1.sql +71 -0
  19. package/drizzle/0046_variable_sets_rename.sql +56 -0
  20. package/drizzle/0047_rigs.sql +151 -0
  21. package/drizzle/0048_rig_runtime.sql +9 -0
  22. package/drizzle/0049_enrollment_went_offline.sql +28 -0
  23. package/drizzle/0050_enrollment_op_stream.sql +1 -0
  24. package/drizzle/0051_codex_pin_source.sql +48 -0
  25. package/drizzle/0052_file_upload_cleanup.sql +91 -0
  26. package/drizzle/0053_codex_credential_leases.sql +230 -0
  27. package/drizzle/0054_session_pins.sql +85 -0
  28. package/drizzle/0055_session_list_snapshots.sql +73 -0
  29. package/drizzle/0056_workspace_model_policies.sql +48 -0
  30. package/drizzle/0057_durable_queue_control.sql +536 -0
  31. package/drizzle/0058_turn_admission_usage_enrollment.sql +158 -0
  32. package/drizzle/0059_workspace_pause_control_kind.sql +12 -0
  33. package/drizzle/0060_session_system_update_deferral.sql +10 -0
  34. package/drizzle/0061_session_workflow_wake_outbox.sql +157 -0
  35. package/drizzle/0062_session_list_snapshot_reaper.sql +48 -0
  36. package/drizzle/0063_session_control_mega_foundation.sql +1324 -0
  37. package/package.json +13 -13
  38. package/src/codex-token-resolver.ts +58 -23
  39. package/src/connection-token-resolver.ts +146 -57
  40. package/src/environment-crypto.ts +5 -1
  41. package/src/event-payload-sanitizer.ts +29 -1
  42. package/src/index.ts +20990 -6465
  43. package/src/memory-domain.ts +218 -0
  44. package/src/migrate.ts +58 -3
  45. package/src/provision-roles.ts +46 -17
  46. package/src/schema.ts +2888 -1121
  47. package/src/session-control.ts +1759 -0
  48. package/src/session-queue-commands.ts +1753 -0
  49. package/src/session-tool-call-settlement.ts +269 -0
  50. package/dist/chunk-57MLICFR.js.map +0 -1
  51. package/dist/chunk-OGCE6O2X.js.map +0 -1
  52. package/dist/chunk-ZIUCA2IO.js +0 -1268
  53. package/dist/chunk-ZIUCA2IO.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -8,6 +8,11 @@
8
8
  "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
9
9
  "directory": "packages/db"
10
10
  },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "drizzle"
15
+ ],
11
16
  "type": "module",
12
17
  "sideEffects": false,
13
18
  "main": "./dist/index.js",
@@ -31,14 +36,6 @@
31
36
  "import": "./dist/provision-roles.js"
32
37
  }
33
38
  },
34
- "files": [
35
- "dist",
36
- "src",
37
- "drizzle"
38
- ],
39
- "engines": {
40
- "node": ">=18"
41
- },
42
39
  "publishConfig": {
43
40
  "access": "public",
44
41
  "provenance": true
@@ -47,15 +44,18 @@
47
44
  "generate": "drizzle-kit generate",
48
45
  "migrate": "bun src/migrate.ts",
49
46
  "provision-roles": "bun src/provision-roles.ts",
50
- "typecheck": "tsc --noEmit",
47
+ "typecheck": "tsgo --noEmit",
51
48
  "build": "tsup",
52
49
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
53
50
  },
54
51
  "dependencies": {
55
- "@opengeni/codex": "^0.2.1",
56
- "@opengeni/config": "^0.4.0",
57
- "@opengeni/contracts": "^0.9.0",
52
+ "@opengeni/codex": "^0.2.2",
53
+ "@opengeni/config": "^0.5.0",
54
+ "@opengeni/contracts": "^0.10.0",
58
55
  "drizzle-orm": "^0.45.2",
59
56
  "postgres": "^3.4.7"
57
+ },
58
+ "engines": {
59
+ "node": ">=18"
60
60
  }
61
61
  }
@@ -12,14 +12,10 @@
12
12
  // refresh helpers and the @opengeni/config key — keeping the refresh-CAS + RLS
13
13
  // invariants co-located with the rows they protect.
14
14
  //
15
- // CROSS-PROCESS SAFETY is preserved unchanged: the single-flight `inflight` map is
16
- // process-module-scoped, so worker and api each get their own — that is CORRECT
17
- // (each process coalesces its own concurrent refreshes). The real cross-process
18
- // guard is the (id, version) CAS inside recordCodexTokenRefresh: if the api
19
- // refreshes a token while a worker turn refreshes the same account, the loser's
20
- // CAS writes 0 rows (stale version) and it re-reads the winner's token, so the
21
- // one-time refresh token is never double-spent. RLS is untouched (every accessor
22
- // wraps withWorkspaceRls internally).
15
+ // CROSS-PROCESS SAFETY: the process-module `inflight` map coalesces local callers,
16
+ // while a Postgres advisory transaction lock serializes API/worker replicas. A
17
+ // waiter re-reads after taking that lock and skips refresh when the version moved.
18
+ // The existing (id,version) CAS remains the final stale-family write fence.
23
19
 
24
20
  import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
25
21
  import {
@@ -40,6 +36,7 @@ import {
40
36
  recordCodexAccountUsage,
41
37
  recordCodexTokenRefresh,
42
38
  setCodexCredentialStatus,
39
+ withCodexCredentialRefreshLock,
43
40
  type CodexCredentialForRun,
44
41
  type Database,
45
42
  } from "./index";
@@ -63,6 +60,7 @@ export type CodexAuthDeps = {
63
60
  refresh: typeof refreshCodexToken;
64
61
  encrypt: typeof encryptEnvironmentValue;
65
62
  keyBytes: typeof environmentsEncryptionKeyBytes;
63
+ withRefreshLock: typeof withCodexCredentialRefreshLock;
66
64
  };
67
65
 
68
66
  const defaultDeps: CodexAuthDeps = {
@@ -72,6 +70,7 @@ const defaultDeps: CodexAuthDeps = {
72
70
  refresh: refreshCodexToken,
73
71
  encrypt: encryptEnvironmentValue,
74
72
  keyBytes: environmentsEncryptionKeyBytes,
73
+ withRefreshLock: withCodexCredentialRefreshLock,
75
74
  };
76
75
 
77
76
  export function buildCodexTokenResolver(
@@ -92,7 +91,10 @@ export function buildCodexTokenResolver(
92
91
  isFedramp: cred.isFedramp,
93
92
  });
94
93
 
95
- const performRefresh = async (cred: CodexCredentialForRun): Promise<CodexTokenSnapshot> => {
94
+ const performRefresh = async (
95
+ refreshDb: Database,
96
+ cred: CodexCredentialForRun,
97
+ ): Promise<CodexTokenSnapshot> => {
96
98
  try {
97
99
  const next = await deps.refresh(cred.tokens.refreshToken);
98
100
  const tokens = {
@@ -107,7 +109,7 @@ export function buildCodexTokenResolver(
107
109
  // Compare-and-set on the loaded (id, version): if a disconnect→reconnect
108
110
  // replaced the row mid-refresh, this writes 0 rows and we must NOT clobber
109
111
  // the new credential with our now-defunct rotated tokens.
110
- const persisted = await deps.recordRefresh(db, {
112
+ const persisted = await deps.recordRefresh(refreshDb, {
111
113
  id: cred.id,
112
114
  version: cred.version,
113
115
  workspaceId,
@@ -119,40 +121,73 @@ export function buildCodexTokenResolver(
119
121
  // The row changed under us. Our rotated tokens belong to a stale family;
120
122
  // fall back to whatever is connected NOW (a reconnect leaves an active
121
123
  // row). If nothing active remains, a relogin is genuinely required.
122
- const current = await deps.loadCredential(db, settings, workspaceId, credentialId);
124
+ const current = await deps.loadCredential(refreshDb, settings, workspaceId, credentialId);
123
125
  if (current && current.status === "active") {
124
126
  return snapshot(current);
125
127
  }
126
- throw new CodexReloginRequired("Codex credential changed during token refresh; reconnect required.");
128
+ throw new CodexReloginRequired(
129
+ "Codex credential changed during token refresh; reconnect required.",
130
+ );
127
131
  }
128
- return { accessToken: tokens.access_token, chatgptAccountId: cred.chatgptAccountId, isFedramp: cred.isFedramp };
132
+ return {
133
+ accessToken: tokens.access_token,
134
+ chatgptAccountId: cred.chatgptAccountId,
135
+ isFedramp: cred.isFedramp,
136
+ };
129
137
  } catch (error) {
130
138
  if (error instanceof CodexReloginRequired) {
131
139
  // Stamp needs_relogin ONLY if the row we refreshed is STILL current
132
140
  // (compare-and-set on the loaded id+version). A relogin triggered by the
133
141
  // OLD token family must never stamp needs_relogin onto a freshly
134
142
  // reconnected credential.
135
- await deps.setStatus(db, workspaceId, "needs_relogin", error.message, { id: cred.id, version: cred.version });
143
+ await deps.setStatus(refreshDb, workspaceId, "needs_relogin", error.message, {
144
+ id: cred.id,
145
+ version: cred.version,
146
+ });
136
147
  }
137
148
  throw error;
138
149
  }
139
150
  };
140
151
 
141
- // ALL refreshes — whether triggered by proactive staleness (getToken) or by a
142
- // 401 retry (refresh) coalesce onto one in-flight promise per credential
143
- // instance, so concurrent calls can never double-spend the one-time refresh
144
- // token (which would trigger refresh_token_reused -> needs_relogin).
152
+ // ALL refreshes — whether proactive or a 401 retry coalesce locally and then
153
+ // serialize globally before any rotating refresh token reaches the provider.
145
154
  const doRefresh = (cred: CodexCredentialForRun): Promise<CodexTokenSnapshot> => {
146
155
  const key = `${cred.id}:${cred.version}`;
147
156
  const existing = inflight.get(key);
148
157
  if (existing) {
149
158
  return existing;
150
159
  }
151
- const promise = performRefresh(cred).finally(() => {
152
- if (inflight.get(key) === promise) {
153
- inflight.delete(key);
154
- }
155
- });
160
+ const promise = deps
161
+ .withRefreshLock(db, workspaceId, credentialId, async (lockedDb) => {
162
+ try {
163
+ const current = await deps.loadCredential(lockedDb, settings, workspaceId, credentialId);
164
+ if (!current || current.status !== "active") {
165
+ throw new CodexReloginRequired(
166
+ "Codex credential became unavailable while waiting to refresh.",
167
+ );
168
+ }
169
+ if (current.version !== cred.version) {
170
+ return { ok: true as const, value: snapshot(current) };
171
+ }
172
+ return { ok: true as const, value: await performRefresh(lockedDb, current) };
173
+ } catch (error) {
174
+ // withCodexCredentialRefreshLock uses an advisory TRANSACTION lock.
175
+ // performRefresh may persist `needs_relogin` before surfacing a
176
+ // permanent OAuth failure; throwing from this callback would roll that
177
+ // status write back with the outer transaction. Return the failure so
178
+ // the transaction commits, then rethrow after the lock is released.
179
+ return { ok: false as const, error };
180
+ }
181
+ })
182
+ .then((outcome) => {
183
+ if (!outcome.ok) throw outcome.error;
184
+ return outcome.value;
185
+ })
186
+ .finally(() => {
187
+ if (inflight.get(key) === promise) {
188
+ inflight.delete(key);
189
+ }
190
+ });
156
191
  inflight.set(key, promise);
157
192
  return promise;
158
193
  };
@@ -1,4 +1,8 @@
1
- import { environmentsEncryptionKeyBytes, type McpServerConnectionRef, type Settings } from "@opengeni/config";
1
+ import {
2
+ environmentsEncryptionKeyBytes,
3
+ type McpServerConnectionRef,
4
+ type Settings,
5
+ } from "@opengeni/config";
2
6
  import { Buffer } from "node:buffer";
3
7
  import { lookup } from "node:dns/promises";
4
8
  import { isIP } from "node:net";
@@ -15,15 +19,18 @@ import {
15
19
  export type ResolveConnectionCredentialResult =
16
20
  | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
17
21
  | {
18
- status: "auth_needed";
19
- reason: "missing_connection" | "expired" | "insufficient_scope" | "refresh_failed";
20
- providerDomain: string;
21
- connectionId?: string;
22
- scopes?: string[];
23
- resource?: string;
24
- authorizationUrl?: string;
25
- };
26
- type AuthNeededReason = Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>["reason"];
22
+ status: "auth_needed";
23
+ reason: "missing_connection" | "expired" | "insufficient_scope" | "refresh_failed";
24
+ providerDomain: string;
25
+ connectionId?: string;
26
+ scopes?: string[];
27
+ resource?: string;
28
+ authorizationUrl?: string;
29
+ };
30
+ type AuthNeededReason = Extract<
31
+ ResolveConnectionCredentialResult,
32
+ { status: "auth_needed" }
33
+ >["reason"];
27
34
 
28
35
  export type ResolveConnectionCredentialInput = {
29
36
  workspaceId: string;
@@ -64,7 +71,9 @@ export function buildConnectionTokenResolver(
64
71
  settings: Settings,
65
72
  deps: ConnectionBrokerDeps = defaultDeps,
66
73
  ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
67
- const load = async (input: ResolveConnectionCredentialInput): Promise<ConnectionCredentialForBroker | null> => {
74
+ const load = async (
75
+ input: ResolveConnectionCredentialInput,
76
+ ): Promise<ConnectionCredentialForBroker | null> => {
68
77
  const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
69
78
  workspaceId: input.workspaceId,
70
79
  providerDomain: input.connectionRef.providerDomain,
@@ -83,7 +92,10 @@ export function buildConnectionTokenResolver(
83
92
  return deps.loadCredential(db, settings, request);
84
93
  };
85
94
 
86
- const snapshot = async (cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): Promise<ResolveConnectionCredentialResult> => {
95
+ const snapshot = async (
96
+ cred: ConnectionCredentialForBroker,
97
+ ref: McpServerConnectionRef,
98
+ ): Promise<ResolveConnectionCredentialResult> => {
87
99
  if (cred.status !== "active") {
88
100
  return authNeededForStatus(cred, ref);
89
101
  }
@@ -118,7 +130,10 @@ export function buildConnectionTokenResolver(
118
130
  };
119
131
  };
120
132
 
121
- const performRefresh = async (cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): Promise<ConnectionCredentialForBroker> => {
133
+ const performRefresh = async (
134
+ cred: ConnectionCredentialForBroker,
135
+ ref: McpServerConnectionRef,
136
+ ): Promise<ConnectionCredentialForBroker> => {
122
137
  const key = deps.keyBytes(settings);
123
138
  if (!key) {
124
139
  throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
@@ -157,7 +172,10 @@ export function buildConnectionTokenResolver(
157
172
  throw new Error("connection credential changed during token refresh");
158
173
  };
159
174
 
160
- const refreshSingleFlight = (cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): Promise<ConnectionCredentialForBroker> => {
175
+ const refreshSingleFlight = (
176
+ cred: ConnectionCredentialForBroker,
177
+ ref: McpServerConnectionRef,
178
+ ): Promise<ConnectionCredentialForBroker> => {
161
179
  const key = `${cred.id}:${cred.version}`;
162
180
  const existing = inflight.get(key);
163
181
  if (existing) {
@@ -193,10 +211,18 @@ export function buildConnectionTokenResolver(
193
211
  // Only a rejected grant may poison the connection; transient failures
194
212
  // (network errors, AS 5xx) leave it active so the next resolve retries.
195
213
  if (isPermanentRefreshError(error)) {
196
- await deps.setStatus(db, input.workspaceId, "needs_reauth", error instanceof Error ? error.message : String(error), {
197
- id: cred.id,
198
- version: cred.version,
199
- }).catch(() => undefined);
214
+ await deps
215
+ .setStatus(
216
+ db,
217
+ input.workspaceId,
218
+ "needs_reauth",
219
+ error instanceof Error ? error.message : String(error),
220
+ {
221
+ id: cred.id,
222
+ version: cred.version,
223
+ },
224
+ )
225
+ .catch(() => undefined);
200
226
  }
201
227
  return authNeeded(ref, "refresh_failed", cred.id);
202
228
  }
@@ -219,9 +245,13 @@ export class ConnectionRefreshHttpError extends Error {
219
245
  // forward. 429 (throttling) and 408 are transient despite being 4xx; network
220
246
  // failures and AS 5xx are likewise retryable.
221
247
  function isPermanentRefreshError(error: unknown): boolean {
222
- return error instanceof ConnectionRefreshHttpError
223
- && error.httpStatus >= 400 && error.httpStatus < 500
224
- && error.httpStatus !== 408 && error.httpStatus !== 429;
248
+ return (
249
+ error instanceof ConnectionRefreshHttpError &&
250
+ error.httpStatus >= 400 &&
251
+ error.httpStatus < 500 &&
252
+ error.httpStatus !== 408 &&
253
+ error.httpStatus !== 429
254
+ );
225
255
  }
226
256
 
227
257
  function shouldRefresh(cred: ConnectionCredentialForBroker, force: boolean, now: Date): boolean {
@@ -237,7 +267,11 @@ function shouldRefresh(cred: ConnectionCredentialForBroker, force: boolean, now:
237
267
  return cred.expiresAt.getTime() <= now.getTime() + REFRESH_WINDOW_MS;
238
268
  }
239
269
 
240
- function authNeeded(ref: McpServerConnectionRef, reason: AuthNeededReason, connectionId?: string): ResolveConnectionCredentialResult {
270
+ function authNeeded(
271
+ ref: McpServerConnectionRef,
272
+ reason: AuthNeededReason,
273
+ connectionId?: string,
274
+ ): ResolveConnectionCredentialResult {
241
275
  return {
242
276
  status: "auth_needed",
243
277
  reason,
@@ -248,11 +282,18 @@ function authNeeded(ref: McpServerConnectionRef, reason: AuthNeededReason, conne
248
282
  };
249
283
  }
250
284
 
251
- function authNeededForStatus(cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): ResolveConnectionCredentialResult {
285
+ function authNeededForStatus(
286
+ cred: ConnectionCredentialForBroker,
287
+ ref: McpServerConnectionRef,
288
+ ): ResolveConnectionCredentialResult {
252
289
  if (cred.status === "revoked") {
253
290
  return authNeeded(ref, "missing_connection", cred.id);
254
291
  }
255
- return authNeeded(ref, cred.expiresAt && cred.expiresAt.getTime() <= Date.now() ? "expired" : "refresh_failed", cred.id);
292
+ return authNeeded(
293
+ ref,
294
+ cred.expiresAt && cred.expiresAt.getTime() <= Date.now() ? "expired" : "refresh_failed",
295
+ cred.id,
296
+ );
256
297
  }
257
298
 
258
299
  function missingRequestedScopes(requested: string[] | undefined, granted: string[]): string[] {
@@ -263,6 +304,16 @@ function missingRequestedScopes(requested: string[] | undefined, granted: string
263
304
  return requested.filter((scope) => !grantedSet.has(scope));
264
305
  }
265
306
 
307
+ /**
308
+ * Normalizes an OAuth token_type into the Authorization scheme to send. RFC 6750
309
+ * says the scheme is case-insensitive, but some MCP servers (e.g. Linear) reject
310
+ * a lowercase `bearer` — so a `bearer`/`BEARER` (or absent) token_type is sent as
311
+ * the canonical `Bearer`. A non-bearer scheme is passed through unchanged.
312
+ */
313
+ export function normalizeBearerScheme(tokenType: string | null | undefined): string {
314
+ return !tokenType || /^bearer$/i.test(tokenType) ? "Bearer" : tokenType;
315
+ }
316
+
266
317
  function headersForCredential(cred: ConnectionCredentialForBroker): Record<string, string> | null {
267
318
  if (cred.kind === "api_key") {
268
319
  return stringRecord((cred.credential as { headers?: unknown }).headers);
@@ -272,8 +323,9 @@ function headersForCredential(cred: ConnectionCredentialForBroker): Record<strin
272
323
  if (!accessToken) {
273
324
  return null;
274
325
  }
275
- const tokenType = stringValue((cred.credential as { token_type?: unknown }).token_type) || "Bearer";
276
- return { authorization: `${tokenType} ${accessToken}` };
326
+ return {
327
+ authorization: `${normalizeBearerScheme(stringValue((cred.credential as { token_type?: unknown }).token_type))} ${accessToken}`,
328
+ };
277
329
  }
278
330
  return stringRecord((cred.credential as { headers?: unknown }).headers);
279
331
  }
@@ -282,15 +334,23 @@ export async function refreshOAuthConnectionCredential(
282
334
  cred: ConnectionCredentialForBroker,
283
335
  ref: McpServerConnectionRef,
284
336
  settings?: Settings,
285
- ): Promise<{ credential: Record<string, unknown>; expiresAt: Date | null; grantedScopes?: string[] }> {
337
+ ): Promise<{
338
+ credential: Record<string, unknown>;
339
+ expiresAt: Date | null;
340
+ grantedScopes?: string[];
341
+ }> {
286
342
  if (cred.kind !== "oauth2") {
287
- return { credential: cred.credential, expiresAt: cred.expiresAt, grantedScopes: cred.grantedScopes };
343
+ return {
344
+ credential: cred.credential,
345
+ expiresAt: cred.expiresAt,
346
+ grantedScopes: cred.grantedScopes,
347
+ };
288
348
  }
289
349
  const refreshToken = stringValue((cred.credential as { refresh_token?: unknown }).refresh_token);
290
350
  const tokenEndpoint =
291
- stringValue((cred.credential as { token_endpoint?: unknown }).token_endpoint)
292
- ?? stringValue((cred.metadata as { tokenEndpoint?: unknown }).tokenEndpoint)
293
- ?? stringValue((cred.metadata as { token_endpoint?: unknown }).token_endpoint);
351
+ stringValue((cred.credential as { token_endpoint?: unknown }).token_endpoint) ??
352
+ stringValue((cred.metadata as { tokenEndpoint?: unknown }).tokenEndpoint) ??
353
+ stringValue((cred.metadata as { token_endpoint?: unknown }).token_endpoint);
294
354
  if (!refreshToken || !tokenEndpoint) {
295
355
  throw new Error("connection has no refresh token endpoint");
296
356
  }
@@ -304,11 +364,14 @@ export async function refreshOAuthConnectionCredential(
304
364
  // in the token request body (RFC 6749 §3.2.1); grant flows persist the
305
365
  // client_id they authorized with into the bundle/metadata.
306
366
  const clientId =
307
- stringValue((cred.credential as { client_id?: unknown }).client_id)
308
- ?? stringValue((cred.metadata as { clientId?: unknown }).clientId)
309
- ?? stringValue((cred.metadata as { client_id?: unknown }).client_id);
367
+ stringValue((cred.credential as { client_id?: unknown }).client_id) ??
368
+ stringValue((cred.metadata as { clientId?: unknown }).clientId) ??
369
+ stringValue((cred.metadata as { client_id?: unknown }).client_id);
310
370
  const clientSecret = stringValue((cred.credential as { client_secret?: unknown }).client_secret);
311
- const authMethod = stringValue((cred.credential as { token_endpoint_auth_method?: unknown }).token_endpoint_auth_method) ?? "none";
371
+ const authMethod =
372
+ stringValue(
373
+ (cred.credential as { token_endpoint_auth_method?: unknown }).token_endpoint_auth_method,
374
+ ) ?? "none";
312
375
  if (clientId) {
313
376
  body.set("client_id", clientId);
314
377
  }
@@ -318,7 +381,8 @@ export async function refreshOAuthConnectionCredential(
318
381
  } else if (clientId && clientSecret && authMethod === "client_secret_basic") {
319
382
  headers.authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
320
383
  }
321
- const resource = ref.resource ?? stringValue((cred.credential as { resource?: unknown }).resource);
384
+ const resource =
385
+ ref.resource ?? stringValue((cred.credential as { resource?: unknown }).resource);
322
386
  if (resource) {
323
387
  body.set("resource", resource);
324
388
  }
@@ -337,7 +401,7 @@ export async function refreshOAuthConnectionCredential(
337
401
  if (!response.ok) {
338
402
  throw new ConnectionRefreshHttpError(response.status);
339
403
  }
340
- const payload = await response.json() as Record<string, unknown>;
404
+ const payload = (await response.json()) as Record<string, unknown>;
341
405
  const accessToken = stringValue(payload.access_token);
342
406
  if (!accessToken) {
343
407
  throw new Error("connection refresh response did not include access_token");
@@ -348,11 +412,16 @@ export async function refreshOAuthConnectionCredential(
348
412
  ...cred.credential,
349
413
  access_token: accessToken,
350
414
  refresh_token: stringValue(payload.refresh_token) ?? refreshToken,
351
- token_type: stringValue(payload.token_type) ?? stringValue((cred.credential as { token_type?: unknown }).token_type) ?? "Bearer",
415
+ token_type:
416
+ stringValue(payload.token_type) ??
417
+ stringValue((cred.credential as { token_type?: unknown }).token_type) ??
418
+ "Bearer",
352
419
  ...(expiresAt ? { expires_at: expiresAt.toISOString() } : {}),
353
420
  ...(resource ? { resource } : {}),
354
421
  ...(scopeText ? { scope: scopeText } : {}),
355
- ...(clientSecret ? { client_secret: clientSecret, token_endpoint_auth_method: authMethod } : {}),
422
+ ...(clientSecret
423
+ ? { client_secret: clientSecret, token_endpoint_auth_method: authMethod }
424
+ : {}),
356
425
  };
357
426
  return {
358
427
  credential: nextCredential,
@@ -361,7 +430,10 @@ export async function refreshOAuthConnectionCredential(
361
430
  };
362
431
  }
363
432
 
364
- function expiresAtFromTokenResponse(payload: Record<string, unknown>, fallback: Date | null): Date | null {
433
+ function expiresAtFromTokenResponse(
434
+ payload: Record<string, unknown>,
435
+ fallback: Date | null,
436
+ ): Date | null {
365
437
  const expiresAt = stringValue(payload.expires_at);
366
438
  if (expiresAt) {
367
439
  const parsed = new Date(expiresAt);
@@ -393,7 +465,10 @@ function stringValue(value: unknown): string | undefined {
393
465
  }
394
466
 
395
467
  async function assertOAuthEndpointAllowed(rawUrl: string, settings: Settings): Promise<void> {
396
- if (settings.integrationsAllowPrivateNetworkTargets || ["local", "test"].includes(settings.environment)) {
468
+ if (
469
+ settings.integrationsAllowPrivateNetworkTargets ||
470
+ ["local", "test"].includes(settings.environment)
471
+ ) {
397
472
  return;
398
473
  }
399
474
  const url = new URL(rawUrl);
@@ -423,14 +498,16 @@ export function isPrivateAddress(address: string): boolean {
423
498
  if (isIP(normalized) !== 6) {
424
499
  return true;
425
500
  }
426
- return normalized === "::1"
427
- || normalized === "::"
428
- || normalized.startsWith("fc")
429
- || normalized.startsWith("fd")
430
- || normalized.startsWith("fe8")
431
- || normalized.startsWith("fe9")
432
- || normalized.startsWith("fea")
433
- || normalized.startsWith("feb");
501
+ return (
502
+ normalized === "::1" ||
503
+ normalized === "::" ||
504
+ normalized.startsWith("fc") ||
505
+ normalized.startsWith("fd") ||
506
+ normalized.startsWith("fe8") ||
507
+ normalized.startsWith("fe9") ||
508
+ normalized.startsWith("fea") ||
509
+ normalized.startsWith("feb")
510
+ );
434
511
  }
435
512
  return isPrivateIpv4Address(normalized);
436
513
  }
@@ -457,7 +534,14 @@ function ipv4FromMappedIpv6(address: string): string | null {
457
534
  }
458
535
  const high = Number.parseInt(parts[0]!, 16);
459
536
  const low = Number.parseInt(parts[1]!, 16);
460
- if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || high > 0xffff || low < 0 || low > 0xffff) {
537
+ if (
538
+ !Number.isInteger(high) ||
539
+ !Number.isInteger(low) ||
540
+ high < 0 ||
541
+ high > 0xffff ||
542
+ low < 0 ||
543
+ low > 0xffff
544
+ ) {
461
545
  return null;
462
546
  }
463
547
  return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
@@ -468,14 +552,19 @@ function isPrivateIpv4Address(address: string): boolean {
468
552
  return true;
469
553
  }
470
554
  const parts = address.split(".").map((part) => Number(part));
471
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
555
+ if (
556
+ parts.length !== 4 ||
557
+ parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
558
+ ) {
472
559
  return true;
473
560
  }
474
561
  const [a, b] = parts as [number, number, number, number];
475
- return a === 0
476
- || a === 10
477
- || a === 127
478
- || (a === 169 && b === 254)
479
- || (a === 172 && b >= 16 && b <= 31)
480
- || (a === 192 && b === 168);
562
+ return (
563
+ a === 0 ||
564
+ a === 10 ||
565
+ a === 127 ||
566
+ (a === 169 && b === 254) ||
567
+ (a === 172 && b >= 16 && b <= 31) ||
568
+ (a === 192 && b === 168)
569
+ );
481
570
  }
@@ -13,7 +13,11 @@ export function encryptEnvironmentValue(key: Uint8Array, plaintext: string): str
13
13
  assertKey(key);
14
14
  const iv = randomBytes(IV_BYTES);
15
15
  const cipher = createCipheriv("aes-256-gcm", key, iv);
16
- const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final(), cipher.getAuthTag()]);
16
+ const ciphertext = Buffer.concat([
17
+ cipher.update(plaintext, "utf8"),
18
+ cipher.final(),
19
+ cipher.getAuthTag(),
20
+ ]);
17
21
  return `${VERSION_PREFIX}:${iv.toString("base64")}:${ciphertext.toString("base64")}`;
18
22
  }
19
23
 
@@ -97,13 +97,41 @@ export function sanitizeEventPayload<T>(payload: T): T {
97
97
  }
98
98
  if (payload && typeof payload === "object") {
99
99
  const entries = Object.entries(payload as Record<string, unknown>).map(
100
- ([key, value]) => [sanitizeEventString(key), sanitizeSensitiveEventField(key, value)] as const,
100
+ ([key, value]) =>
101
+ [sanitizeEventString(key), sanitizeSensitiveEventField(key, value)] as const,
101
102
  );
102
103
  return Object.fromEntries(entries) as unknown as T;
103
104
  }
104
105
  return payload;
105
106
  }
106
107
 
108
+ /**
109
+ * Make model-facing conversation data safe for Postgres without redacting it.
110
+ *
111
+ * Session events are an audit/UI projection and deliberately redact fields such
112
+ * as `token` and `authorization`. Conversation history is the replay source for
113
+ * the model, so applying event redaction there silently changes tool arguments
114
+ * and can make recovery diverge from the call that actually ran. This walker
115
+ * performs only the database-safety repair (NUL removal and UTF-16 repair).
116
+ */
117
+ export function sanitizeModelPayload<T>(payload: T): T {
118
+ if (typeof payload === "string") {
119
+ return sanitizeEventString(payload) as unknown as T;
120
+ }
121
+ if (Array.isArray(payload)) {
122
+ return payload.map((item) => sanitizeModelPayload(item)) as unknown as T;
123
+ }
124
+ if (payload && typeof payload === "object") {
125
+ return Object.fromEntries(
126
+ Object.entries(payload as Record<string, unknown>).map(([key, value]) => [
127
+ sanitizeEventString(key),
128
+ sanitizeModelPayload(value),
129
+ ]),
130
+ ) as unknown as T;
131
+ }
132
+ return payload;
133
+ }
134
+
107
135
  function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
108
136
  if (key === "mcpServers") {
109
137
  return sanitizeSessionMcpServerList(value);