@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.
- package/dist/{chunk-IHPCI4GV.js → chunk-2JFRTKTG.js} +1 -1
- package/dist/chunk-2JFRTKTG.js.map +1 -0
- package/dist/{chunk-M6EOXYHK.js → chunk-NX2JNJJP.js} +8 -4
- package/dist/{chunk-M6EOXYHK.js.map → chunk-NX2JNJJP.js.map} +1 -1
- package/dist/codex-token-resolver.d.ts +50 -8
- package/dist/connection-token-resolver.d.ts +45 -7
- package/dist/database.d.ts +137 -0
- package/dist/index.d.ts +21 -154
- package/dist/index.js +2694 -2615
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts +1 -1
- package/dist/memory-governance.d.ts +1 -1
- package/dist/new-session-drafts.d.ts +1 -1
- package/dist/preference-registry.d.ts +1 -1
- package/dist/provision-roles.d.ts +1 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +1 -1
- package/dist/schema.d.ts +27 -0
- package/dist/schema.js +1 -1
- package/dist/scoped-knowledge.d.ts +1 -1
- package/dist/session-control.d.ts +1 -1
- package/dist/session-queue-commands.d.ts +1 -1
- package/dist/session-realtime-context.d.ts +1 -1
- package/dist/session-realtime-ledger.d.ts +1 -1
- package/dist/session-realtime-mirror.d.ts +1 -1
- package/dist/session-realtime-state.d.ts +1 -1
- package/dist/session-realtime-terminal.d.ts +1 -1
- package/dist/session-realtime.d.ts +1 -1
- package/dist/session-tool-call-settlement.d.ts +1 -1
- package/dist/turn-initiator.d.ts +1 -1
- package/dist/workspace-artifacts.d.ts +1 -1
- package/dist/workspace-instruction-policies.d.ts +1 -1
- package/drizzle/0171_social_connection_subject_ownership.sql +53 -0
- package/package.json +3 -3
- package/src/codex-token-resolver.ts +102 -49
- package/src/connection-token-resolver.ts +67 -26
- package/src/database.ts +393 -0
- package/src/index.ts +279 -479
- package/src/insights.ts +2 -2
- package/src/memory-governance.ts +2 -2
- package/src/new-session-drafts.ts +1 -1
- package/src/preference-registry.ts +2 -2
- package/src/provision-roles.ts +1 -1
- package/src/runtime-posture.ts +1 -1
- package/src/schema.ts +11 -3
- package/src/scoped-knowledge.ts +2 -2
- package/src/session-control.ts +1 -1
- package/src/session-queue-commands.ts +1 -1
- package/src/session-realtime-context.ts +1 -1
- package/src/session-realtime-ledger.ts +1 -1
- package/src/session-realtime-mirror.ts +1 -1
- package/src/session-realtime-state.ts +1 -1
- package/src/session-realtime-terminal.ts +1 -1
- package/src/session-realtime.ts +1 -1
- package/src/session-tool-call-settlement.ts +1 -1
- package/src/turn-initiator.ts +1 -1
- package/src/workspace-artifacts.ts +2 -2
- package/src/workspace-instruction-policies.ts +2 -2
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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:
|
|
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:
|
|
528
|
+
const refreshRecord: ConnectionTokenRefreshInput = {
|
|
488
529
|
id: cred.id,
|
|
489
530
|
version: cred.version,
|
|
490
531
|
workspaceId: cred.workspaceId,
|
package/src/database.ts
ADDED
|
@@ -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
|
+
}
|