@opengeni/db 0.27.8 → 0.27.9

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-IHPCI4GV.js → chunk-2JFRTKTG.js} +1 -1
  2. package/dist/chunk-2JFRTKTG.js.map +1 -0
  3. package/dist/{chunk-M6EOXYHK.js → chunk-NX2JNJJP.js} +8 -4
  4. package/dist/{chunk-M6EOXYHK.js.map → chunk-NX2JNJJP.js.map} +1 -1
  5. package/dist/codex-token-resolver.d.ts +50 -8
  6. package/dist/connection-token-resolver.d.ts +45 -7
  7. package/dist/database.d.ts +137 -0
  8. package/dist/index.d.ts +21 -154
  9. package/dist/index.js +2694 -2615
  10. package/dist/index.js.map +1 -1
  11. package/dist/insights.d.ts +1 -1
  12. package/dist/memory-governance.d.ts +1 -1
  13. package/dist/new-session-drafts.d.ts +1 -1
  14. package/dist/preference-registry.d.ts +1 -1
  15. package/dist/provision-roles.d.ts +1 -1
  16. package/dist/provision-roles.js +1 -1
  17. package/dist/runtime-posture.d.ts +1 -1
  18. package/dist/schema.d.ts +27 -0
  19. package/dist/schema.js +1 -1
  20. package/dist/scoped-knowledge.d.ts +1 -1
  21. package/dist/session-control.d.ts +1 -1
  22. package/dist/session-queue-commands.d.ts +1 -1
  23. package/dist/session-realtime-context.d.ts +1 -1
  24. package/dist/session-realtime-ledger.d.ts +1 -1
  25. package/dist/session-realtime-mirror.d.ts +1 -1
  26. package/dist/session-realtime-state.d.ts +1 -1
  27. package/dist/session-realtime-terminal.d.ts +1 -1
  28. package/dist/session-realtime.d.ts +1 -1
  29. package/dist/session-tool-call-settlement.d.ts +1 -1
  30. package/dist/turn-initiator.d.ts +1 -1
  31. package/dist/workspace-artifacts.d.ts +1 -1
  32. package/dist/workspace-instruction-policies.d.ts +1 -1
  33. package/drizzle/0171_social_connection_subject_ownership.sql +53 -0
  34. package/package.json +3 -3
  35. package/src/codex-token-resolver.ts +102 -49
  36. package/src/connection-token-resolver.ts +67 -26
  37. package/src/database.ts +393 -0
  38. package/src/index.ts +279 -479
  39. package/src/insights.ts +2 -2
  40. package/src/memory-governance.ts +2 -2
  41. package/src/new-session-drafts.ts +1 -1
  42. package/src/preference-registry.ts +2 -2
  43. package/src/provision-roles.ts +1 -1
  44. package/src/runtime-posture.ts +1 -1
  45. package/src/schema.ts +11 -3
  46. package/src/scoped-knowledge.ts +2 -2
  47. package/src/session-control.ts +1 -1
  48. package/src/session-queue-commands.ts +1 -1
  49. package/src/session-realtime-context.ts +1 -1
  50. package/src/session-realtime-ledger.ts +1 -1
  51. package/src/session-realtime-mirror.ts +1 -1
  52. package/src/session-realtime-state.ts +1 -1
  53. package/src/session-realtime-terminal.ts +1 -1
  54. package/src/session-realtime.ts +1 -1
  55. package/src/session-tool-call-settlement.ts +1 -1
  56. package/src/turn-initiator.ts +1 -1
  57. package/src/workspace-artifacts.ts +2 -2
  58. package/src/workspace-instruction-policies.ts +2 -2
  59. package/dist/chunk-IHPCI4GV.js.map +0 -1
@@ -4,7 +4,9 @@ import {
4
4
  type Settings,
5
5
  } from "@opengeni/config";
6
6
  import type {
7
+ ConnectionKind,
7
8
  ConnectionCredentialsPort,
9
+ ConnectionStatus,
8
10
  McpConnectionResourceScope,
9
11
  McpCredentialAuthNeededReason,
10
12
  McpCredentialsRequest,
@@ -24,14 +26,49 @@ export { isPrivateAddress } from "@opengeni/network";
24
26
  import { Buffer } from "node:buffer";
25
27
  import { isIP } from "node:net";
26
28
  import { encryptEnvironmentValue } from "./environment-crypto";
27
- import {
28
- loadConnectionCredentialForBroker,
29
- recordConnectionTokenRefresh,
30
- recordConnectionUsed,
31
- setConnectionStatus,
32
- type ConnectionCredentialForBroker,
33
- type Database,
34
- } from "./index";
29
+ import type { Database } from "./database";
30
+
31
+ export type ConnectionCredentialForBroker = {
32
+ id: string;
33
+ accountId: string;
34
+ workspaceId: string;
35
+ subjectId: string | null;
36
+ providerDomain: string;
37
+ kind: ConnectionKind;
38
+ status: ConnectionStatus;
39
+ credential: Record<string, unknown>;
40
+ grantedScopes: string[];
41
+ expiresAt: Date | null;
42
+ lastRefreshAt: Date | null;
43
+ version: number;
44
+ metadata: Record<string, unknown>;
45
+ };
46
+
47
+ export type ConnectionCredentialLookupInput = {
48
+ workspaceId: string;
49
+ connectionId?: string;
50
+ providerDomain: string;
51
+ kind?: ConnectionKind;
52
+ subjectId?: string | null;
53
+ allowSubjectOwned?: boolean;
54
+ };
55
+
56
+ export type ConnectionTokenRefreshInput = {
57
+ id: string;
58
+ version: number;
59
+ workspaceId: string;
60
+ credentialEncrypted: string;
61
+ expiresAt: Date | null;
62
+ grantedScopes?: string[];
63
+ lastRefreshAt: Date;
64
+ subjectId?: string | null;
65
+ };
66
+
67
+ export type ConnectionStatusGuard = {
68
+ id: string;
69
+ version: number;
70
+ subjectId?: string | null;
71
+ };
35
72
 
36
73
  export type ResolveConnectionCredentialResult =
37
74
  | {
@@ -334,10 +371,25 @@ function parseHostCredentialExpiry(value: string | null | undefined): Date | nul
334
371
  }
335
372
 
336
373
  export type ConnectionBrokerDeps = {
337
- loadCredential: typeof loadConnectionCredentialForBroker;
338
- recordRefresh: typeof recordConnectionTokenRefresh;
339
- setStatus: typeof setConnectionStatus;
340
- recordUsed: typeof recordConnectionUsed;
374
+ loadCredential: (
375
+ db: Database,
376
+ settings: Settings,
377
+ input: ConnectionCredentialLookupInput,
378
+ ) => Promise<ConnectionCredentialForBroker | null>;
379
+ recordRefresh: (db: Database, input: ConnectionTokenRefreshInput) => Promise<boolean>;
380
+ setStatus: (
381
+ db: Database,
382
+ workspaceId: string,
383
+ status: ConnectionStatus,
384
+ lastError: string | null,
385
+ guard: ConnectionStatusGuard,
386
+ ) => Promise<boolean>;
387
+ recordUsed: (
388
+ db: Database,
389
+ workspaceId: string,
390
+ connectionId: string,
391
+ subjectId?: string | null,
392
+ ) => Promise<void>;
341
393
  refresh: typeof refreshOAuthConnectionCredential;
342
394
  encrypt: typeof encryptEnvironmentValue;
343
395
  keyBytes: typeof environmentsEncryptionKeyBytes;
@@ -372,17 +424,6 @@ export type RefreshTransportOptions = {
372
424
  dnsLookup?: DnsLookup;
373
425
  };
374
426
 
375
- const defaultDeps: ConnectionBrokerDeps = {
376
- loadCredential: loadConnectionCredentialForBroker,
377
- recordRefresh: recordConnectionTokenRefresh,
378
- setStatus: setConnectionStatus,
379
- recordUsed: recordConnectionUsed,
380
- refresh: refreshOAuthConnectionCredential,
381
- encrypt: encryptEnvironmentValue,
382
- keyBytes: environmentsEncryptionKeyBytes,
383
- now: () => new Date(),
384
- };
385
-
386
427
  const inflight = new Map<string, Promise<ConnectionCredentialForBroker>>();
387
428
  const REFRESH_WINDOW_MS = 60_000;
388
429
  const CONNECTION_REFRESH_TIMEOUT_MS = 10_000;
@@ -390,7 +431,7 @@ const CONNECTION_REFRESH_TIMEOUT_MS = 10_000;
390
431
  export function buildConnectionTokenResolver(
391
432
  db: Database,
392
433
  settings: Settings,
393
- deps: ConnectionBrokerDeps = defaultDeps,
434
+ deps: ConnectionBrokerDeps,
394
435
  options: ConnectionTokenResolverOptions = {},
395
436
  ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
396
437
  type CredentialLookupInput = Pick<
@@ -404,7 +445,7 @@ export function buildConnectionTokenResolver(
404
445
  if (subjectOwned && !input.subjectId) {
405
446
  return null;
406
447
  }
407
- const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
448
+ const request: ConnectionCredentialLookupInput = {
408
449
  workspaceId: input.workspaceId,
409
450
  providerDomain: input.connectionRef.providerDomain,
410
451
  allowSubjectOwned: subjectOwned,
@@ -484,7 +525,7 @@ export function buildConnectionTokenResolver(
484
525
  throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
485
526
  }
486
527
  const refreshed = await deps.refresh(cred, ref, settings, options.refreshTransport);
487
- const refreshRecord: Parameters<typeof recordConnectionTokenRefresh>[1] = {
528
+ const refreshRecord: ConnectionTokenRefreshInput = {
488
529
  id: cred.id,
489
530
  version: cred.version,
490
531
  workspaceId: cred.workspaceId,
@@ -0,0 +1,393 @@
1
+ import { eq, sql, type SQL } from "drizzle-orm";
2
+ import type { PgDatabase, PgTransactionConfig } from "drizzle-orm/pg-core";
3
+ import { drizzle } from "drizzle-orm/postgres-js";
4
+ import postgres from "postgres";
5
+
6
+ import {
7
+ runIdempotentPersistenceTransaction,
8
+ type IdempotentPersistenceTransactionOptions,
9
+ } from "./persistence-errors";
10
+ import * as schema from "./schema";
11
+
12
+ // §7.7 driver widening (Step I). `Database` is the structural, cross-driver
13
+ // query-layer port: every helper in this file accepts `db: Database` and uses
14
+ // only the methods present on drizzle's base `PgDatabase` (select/insert/update/
15
+ // delete/transaction/execute). Widening from the concrete
16
+ // `PostgresJsDatabase<typeof schema>` to `PgDatabase<any, typeof schema>` is a
17
+ // pure TYPE change — no runtime behavior changes — that lets an embedded host
18
+ // inject ANY drizzle pg driver handle (node-postgres, neon-http, etc.) bound to
19
+ // OpenGeni's schema, not just the postgres-js handle `createDb` builds. The
20
+ // `any` for the query-result HKT is deliberate: it keeps `db.execute(sql\`…\`)`
21
+ // callable across drivers whose raw-result shapes differ (postgres-js returns a
22
+ // row array; node-postgres returns `{ rows }`). The three raw `db.execute(…)`
23
+ // reads that index a row array (`getManagedUserByEmail` here is the only
24
+ // host-facing one — see `userLookup`) stay postgres-js-shaped for standalone;
25
+ // `userLookup` is the injection seam for hosts on a different driver.
26
+ // `PostgresJsDatabase<typeof schema>` is assignable to this, so standalone is
27
+ // unaffected.
28
+
29
+ export type Database = PgDatabase<any, typeof schema>;
30
+
31
+ export type DbClient = {
32
+ db: Database;
33
+ close: () => Promise<void>;
34
+ };
35
+
36
+ export type RlsContext = {
37
+ accountId: string;
38
+ workspaceId?: string | null;
39
+ };
40
+
41
+ /**
42
+ * RLS posture for the connection OpenGeni's query layer runs over (Step I, §7.7).
43
+ *
44
+ * - `"force"` (DEFAULT — today's standalone behavior, byte-for-byte): OpenGeni
45
+ * connects as a NON-OWNER role (`opengeni_app`) and every table carries
46
+ * `FORCE ROW LEVEL SECURITY`, so the workspace/account GUCs set by
47
+ * `setRlsContext` are the ONLY thing that admits rows — even the table owner
48
+ * is subject to RLS. This is the Fork-A isolation guarantee.
49
+ * - `"scoped"` (embedded Fork-B opt-in): the host runs OpenGeni's queries over a
50
+ * role that OWNS the dedicated schema (RLS need not be forced for that role),
51
+ * relying on the host's own tenant boundary. OpenGeni STILL emits the
52
+ * `set_config('opengeni.account_id'/'workspace_id', …)` GUCs defensively on
53
+ * every scoped query, so the application query path is byte-identical between
54
+ * the two strategies and the app code is RLS-mode-agnostic. The strategy is a
55
+ * declared posture (consumed by `provisionRoles` and as a documented
56
+ * invariant), NOT a query-path branch — there is deliberately no `if
57
+ * (strategy === …)` anywhere in the helpers below. Picking `"scoped"` does not
58
+ * relax any GUC; it only changes which DB role the host provisions/connects as
59
+ * and asserts that the host accepts owning the isolation boundary.
60
+ */
61
+ export type RlsStrategy = "force" | "scoped";
62
+
63
+ /**
64
+ * Resolve a host-IdP/Better-Auth user *identifier* by email. Injected via
65
+ * `createDb({ userLookup })` (Step I). UNSET → today's raw parameterized select
66
+ * against Better Auth's `auth_users` table (see `getManagedUserByEmail`), which
67
+ * relies on the postgres-js array-shaped `db.execute` result. An embedded host
68
+ * whose identity lives elsewhere (a different IdP table, a different driver, or
69
+ * a non-`auth_users` user store) injects this closure so OpenGeni never touches
70
+ * `auth_users` directly. Returns the user id, or null when no such user exists.
71
+ */
72
+ export type UserLookup = (db: Database, email: string) => Promise<string | null>;
73
+
74
+ export type CreateDbOptions = {
75
+ /**
76
+ * The Postgres `search_path` for this connection (Step I, §7.8 runtime half).
77
+ * UNSET → today's behavior: NO `search_path` startup parameter is sent, so the
78
+ * server default applies (`public` for standalone, where every table + the
79
+ * `vector` extension + `gen_random_uuid()` live). For an embedded dedicated
80
+ * schema, pass e.g. `"opengeni,opengeni_private,public"` — postgres-js sends
81
+ * it as a per-session startup parameter (the supported, query-param-free way;
82
+ * URL `?search_path=` is IGNORED by postgres-js). Keep `public` LAST so the
83
+ * `vector` type and `gen_random_uuid()` (which live in `public` on the
84
+ * pgvector image) still resolve — the schema-isolation contract live footgun.
85
+ */
86
+ searchPath?: string;
87
+ /** RLS posture; defaults to `"force"` (today's standalone). */
88
+ rlsStrategy?: RlsStrategy;
89
+ /** Host-provided user-by-email resolver; unset → today's raw `auth_users` query. */
90
+ userLookup?: UserLookup;
91
+ /** postgres-js pool size; defaults to today's `10`. */
92
+ max?: number;
93
+ /**
94
+ * Connection-local default transaction isolation sent in the postgres-js
95
+ * startup parameters. This is intentionally not a role/database default:
96
+ * tests and embedded callers can exercise a different ambient isolation
97
+ * without mutating a shared PostgreSQL role or affecting other connections.
98
+ */
99
+ isolationLevel?: postgres.ConnectionParameters["default_transaction_isolation"];
100
+ };
101
+
102
+ /**
103
+ * The active RLS strategy + userLookup for an injected `Database`, recorded in a
104
+ * side WeakMap so helpers (and `getManagedUserByEmail`) can consult the host's
105
+ * binding without changing every call signature. A handle with no recorded
106
+ * config (e.g. one built outside `createDb`, or in a test) falls back to the
107
+ * standalone defaults: `rlsStrategy: "force"`, raw `auth_users` lookup.
108
+ */
109
+ type DbBinding = { rlsStrategy: RlsStrategy; userLookup?: UserLookup };
110
+
111
+ const dbBindings = new WeakMap<object, DbBinding>();
112
+
113
+ /** The strategy bound to a handle (or the `"force"` default). */
114
+ export function rlsStrategyFor(db: Database): RlsStrategy {
115
+ return dbBindings.get(db as unknown as object)?.rlsStrategy ?? "force";
116
+ }
117
+
118
+ /**
119
+ * Run a raw SQL query and read its rows as a typed array.
120
+ *
121
+ * Why this exists: the Step I driver widening (`Database = PgDatabase<any, …>`)
122
+ * deliberately sets the query-result HKT to `any` so `db.execute(…)` is callable
123
+ * across drivers whose raw-result shapes differ (postgres-js → row array;
124
+ * node-postgres → `{ rows }`). A side effect is that `db.execute<T>(…)` now
125
+ * resolves to `any`, erasing the per-row element type at the call site. OpenGeni's
126
+ * OWN internal raw queries usually run over the postgres-js handle `createDb`
127
+ * builds (array result), while an embedded host may inject a node-postgres style
128
+ * driver (`{ rows }`). Normalize those two standard shapes in one place; reject
129
+ * an unknown driver result rather than silently treating it as an empty query.
130
+ */
131
+ export async function rawRows<T extends Record<string, unknown>>(
132
+ executor: Pick<Database, "execute">,
133
+ query: SQL,
134
+ ): Promise<T[]> {
135
+ const result = await executor.execute<T>(query);
136
+ if (Array.isArray(result)) {
137
+ return result as unknown as T[];
138
+ }
139
+ const rows = (result as unknown as { rows?: unknown }).rows;
140
+ if (Array.isArray(rows)) {
141
+ return rows as T[];
142
+ }
143
+ throw new Error("Unsupported database execute() result shape");
144
+ }
145
+
146
+ export function createDb(databaseUrl: string, options: CreateDbOptions = {}): DbClient {
147
+ // `prepare: false` is REQUIRED for Azure Database for PostgreSQL Flexible
148
+ // Server's transaction-pooling PgBouncer: postgres-js's default named prepared
149
+ // statements (`s_N`) are bound to one backend, but a transaction pooler hands
150
+ // each transaction a different backend, so a later `execute` intermittently
151
+ // throws `prepared statement "s_N" does not exist`. Every RLS read in this
152
+ // module (set_config + SELECT inside one db.transaction) rides on this pool, so
153
+ // the failure surfaces as a "worked, then didn't" credential/permission read.
154
+ // idle_timeout + max_lifetime recycle connections so a pooler-recycled backend
155
+ // is never reused indefinitely; application_name aids server-side diagnostics.
156
+ const client = postgres(databaseUrl, {
157
+ max: options.max ?? 10,
158
+ prepare: false,
159
+ idle_timeout: 30,
160
+ max_lifetime: 1800,
161
+ // `connection` carries per-session Postgres STARTUP parameters. `application_name`
162
+ // (always) aids server-side diagnostics; `search_path` (embedded only) is the
163
+ // supported, query-param-free way to scope a connection to a dedicated schema —
164
+ // postgres-js IGNORES a URL `?search_path=`. Unset searchPath → omit it so the
165
+ // server default (`public`) is unchanged for standalone.
166
+ connection: {
167
+ application_name: "opengeni",
168
+ ...(options.searchPath ? { search_path: options.searchPath } : {}),
169
+ ...(options.isolationLevel ? { default_transaction_isolation: options.isolationLevel } : {}),
170
+ },
171
+ });
172
+ const db = drizzle(client, { schema });
173
+ dbBindings.set(db as unknown as object, {
174
+ rlsStrategy: options.rlsStrategy ?? "force",
175
+ ...(options.userLookup ? { userLookup: options.userLookup } : {}),
176
+ });
177
+ return {
178
+ db,
179
+ close: async () => {
180
+ await client.end();
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Register a host's `rlsStrategy`/`userLookup` against an externally-constructed
187
+ * `Database` handle (e.g. one the embedded host built from its own driver and
188
+ * injected, rather than via `createDb`). Lets the same WeakMap-backed lookups
189
+ * work for injected handles. Standalone never calls this (it uses `createDb`).
190
+ */
191
+ export function registerDbBinding(
192
+ db: Database,
193
+ binding: { rlsStrategy?: RlsStrategy; userLookup?: UserLookup },
194
+ ): void {
195
+ dbBindings.set(db as unknown as object, {
196
+ rlsStrategy: binding.rlsStrategy ?? "force",
197
+ ...(binding.userLookup ? { userLookup: binding.userLookup } : {}),
198
+ });
199
+ }
200
+
201
+ export async function setRlsContext(db: Database, context: RlsContext): Promise<void> {
202
+ // Fail loud on an empty/blank account id: a "" account would set an RLS GUC
203
+ // that matches no tenant row, silently returning zero rows from every scoped
204
+ // read (a phantom "not found" / "no active subscription"). An RLS context with
205
+ // no account is always a bug at the call site, never a valid query scope.
206
+ if (typeof context.accountId !== "string" || context.accountId.trim() === "") {
207
+ throw new Error("setRlsContext: a non-empty accountId is required to establish an RLS context");
208
+ }
209
+ await db.execute(sql`select set_config('opengeni.account_id', ${context.accountId}, true)`);
210
+ await db.execute(
211
+ sql`select set_config('opengeni.workspace_id', ${context.workspaceId ?? ""}, true)`,
212
+ );
213
+ await db.execute(sql`select set_config('opengeni.sandbox_recovery_protocol_v2', '1', true)`);
214
+ }
215
+
216
+ export async function withRlsContext<T>(
217
+ db: Database,
218
+ context: RlsContext,
219
+ fn: (db: Database) => Promise<T>,
220
+ transactionConfig?: PgTransactionConfig,
221
+ ): Promise<T> {
222
+ return await db.transaction(async (tx) => {
223
+ const scoped = tx as unknown as Database;
224
+ await setRlsContext(scoped, context);
225
+ // Defense-in-depth: read the LOCAL GUC back on THIS backend BEFORE running
226
+ // the scoped query. The set_config and this read share one db.transaction,
227
+ // which a transaction pooler pins to a single backend — so a mismatch here
228
+ // means the context was genuinely lost (a torn transaction / pooler backend
229
+ // swap), not normal operation. Without this guard such an event runs the
230
+ // scoped read with an empty account_id and returns zero RLS-visible rows,
231
+ // manufacturing a phantom "no active subscription" from a credential that is
232
+ // in fact active. Convert that silent false into a loud, root-cause-bearing
233
+ // error so the caller can retry rather than permanently mis-decide.
234
+ const applied = await tx.execute<{
235
+ account_id: string | null;
236
+ workspace_id: string | null;
237
+ }>(
238
+ sql`select
239
+ current_setting('opengeni.account_id', true) as account_id,
240
+ current_setting('opengeni.workspace_id', true) as workspace_id`,
241
+ );
242
+ const appliedAccountId = applied[0]?.account_id ?? "";
243
+ const expectedWorkspaceId = context.workspaceId ?? "";
244
+ const appliedWorkspaceId = applied[0]?.workspace_id ?? "";
245
+ if (appliedAccountId !== context.accountId) {
246
+ throw new Error(
247
+ `RLS context not applied on the active backend: expected account ${context.accountId}, got "${appliedAccountId}"`,
248
+ );
249
+ }
250
+ if (appliedWorkspaceId !== expectedWorkspaceId) {
251
+ throw new Error(
252
+ `RLS context not applied on the active backend: expected workspace "${expectedWorkspaceId}", got "${appliedWorkspaceId}"`,
253
+ );
254
+ }
255
+ return await fn(scoped);
256
+ }, transactionConfig);
257
+ }
258
+
259
+ /**
260
+ * Run one bounded database operation on a transaction-pinned backend.
261
+ *
262
+ * Callers that also have an application deadline should check their abort
263
+ * signal before returning from `fn`; throwing there rolls the transaction back
264
+ * even when the application deadline won a surrounding Promise race.
265
+ */
266
+ export async function withDatabaseStatementTimeout<T>(
267
+ db: Database,
268
+ timeoutMs: number,
269
+ fn: (db: Database) => Promise<T>,
270
+ ): Promise<T> {
271
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
272
+ throw new Error("withDatabaseStatementTimeout requires a positive timeout");
273
+ }
274
+ const boundedTimeoutMs = Math.max(1, Math.floor(timeoutMs));
275
+ return await db.transaction(async (tx) => {
276
+ const scoped = tx as unknown as Database;
277
+ await scoped.execute(
278
+ sql`select set_config('statement_timeout', ${`${boundedTimeoutMs}ms`}, true)`,
279
+ );
280
+ return await fn(scoped);
281
+ });
282
+ }
283
+
284
+ export async function rlsContextForWorkspace(
285
+ db: Database,
286
+ workspaceId: string,
287
+ ): Promise<RlsContext> {
288
+ const [row] = await db
289
+ .select({ accountId: schema.workspaces.accountId })
290
+ .from(schema.workspaces)
291
+ .where(eq(schema.workspaces.id, workspaceId))
292
+ .limit(1);
293
+ if (!row) {
294
+ throw new Error(`Workspace not found: ${workspaceId}`);
295
+ }
296
+ return { accountId: row.accountId, workspaceId };
297
+ }
298
+
299
+ export async function withWorkspaceRls<T>(
300
+ db: Database,
301
+ workspaceId: string,
302
+ fn: (db: Database) => Promise<T>,
303
+ ): Promise<T> {
304
+ return await withRlsContext(db, await rlsContextForWorkspace(db, workspaceId), fn);
305
+ }
306
+
307
+ export async function retryWorkspacePersistence<T>(
308
+ db: Database,
309
+ workspaceId: string,
310
+ options: IdempotentPersistenceTransactionOptions,
311
+ fn: (db: Database) => Promise<T>,
312
+ ): Promise<T> {
313
+ return await runIdempotentPersistenceTransaction(options, async () => {
314
+ return await withWorkspaceRls(db, workspaceId, fn);
315
+ });
316
+ }
317
+
318
+ export async function retryRlsPersistence<T>(
319
+ db: Database,
320
+ context: RlsContext,
321
+ options: IdempotentPersistenceTransactionOptions,
322
+ fn: (db: Database) => Promise<T>,
323
+ ): Promise<T> {
324
+ return await runIdempotentPersistenceTransaction(options, async () => {
325
+ return await withRlsContext(db, context, fn);
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Personal workspace data needs both tenant and authenticated-principal GUCs.
331
+ * `session_pins` uses this helper so FORCE RLS rejects another member's rows
332
+ * even if a future query accidentally omits its explicit subject predicate.
333
+ */
334
+ export async function withWorkspaceSubjectRls<T>(
335
+ db: Database,
336
+ workspaceId: string,
337
+ subjectId: string,
338
+ fn: (db: Database) => Promise<T>,
339
+ transactionConfig?: PgTransactionConfig,
340
+ ): Promise<T> {
341
+ if (!subjectId.trim()) {
342
+ throw new Error("withWorkspaceSubjectRls: a non-empty subjectId is required");
343
+ }
344
+ const context = await rlsContextForWorkspace(db, workspaceId);
345
+ return await withRlsContext(
346
+ db,
347
+ context,
348
+ async (scopedDb) => {
349
+ await setSubjectRlsContext(scopedDb, subjectId);
350
+ return await fn(scopedDb);
351
+ },
352
+ transactionConfig,
353
+ );
354
+ }
355
+
356
+ /** Apply and verify actor-private RLS on an already transaction-pinned handle. */
357
+ export async function setSubjectRlsContext(db: Database, subjectId: string): Promise<void> {
358
+ if (!subjectId.trim()) {
359
+ throw new Error("setSubjectRlsContext: a non-empty subjectId is required");
360
+ }
361
+ await db.execute(sql`select set_config('opengeni.subject_id', ${subjectId}, true)`);
362
+ const applied = await db.execute<{ subject_id: string | null }>(
363
+ sql`select current_setting('opengeni.subject_id', true) as subject_id`,
364
+ );
365
+ if ((applied[0]?.subject_id ?? "") !== subjectId) {
366
+ throw new Error("Authenticated subject RLS context was not applied on the active backend");
367
+ }
368
+ }
369
+
370
+ export async function withWorkspaceUsageLock<T>(
371
+ db: Database,
372
+ workspaceId: string,
373
+ fn: (db: Database) => Promise<T>,
374
+ ): Promise<T> {
375
+ const context = await rlsContextForWorkspace(db, workspaceId);
376
+ return await withRlsContext(db, context, async (scopedDb) => {
377
+ await scopedDb.execute(sql`select pg_advisory_xact_lock(hashtext(${`usage:${workspaceId}`}))`);
378
+ return await fn(scopedDb);
379
+ });
380
+ }
381
+
382
+ export async function withAccountRls<T>(
383
+ db: Database,
384
+ accountId: string,
385
+ fn: (db: Database) => Promise<T>,
386
+ ): Promise<T> {
387
+ return await withRlsContext(db, { accountId, workspaceId: null }, fn);
388
+ }
389
+
390
+ /** Internal lookup for the host binding attached to a database handle. */
391
+ export function dbBindingFor(db: Database): DbBinding | undefined {
392
+ return dbBindings.get(db as unknown as object);
393
+ }