@nifrajs/mcp-db 3.5.0 → 4.0.2

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.
@@ -0,0 +1,777 @@
1
+ /**
2
+ * `@nifrajs/mcp-db/postgres` - a read-only Postgres engine for a development database, on Bun's
3
+ * built-in `Bun.SQL` (no npm driver). Every layer below is enforced on each call, in this order:
4
+ *
5
+ * 1. Host gate ({@link connectPostgres}): loopback, `*.localhost` or a unix socket, else only a host
6
+ * named in `allowHosts`. Connection fields are passed explicitly, so `DATABASE_URL`/`PG*` in the
7
+ * environment can never redirect the connection.
8
+ * 2. Role gate: a superuser, a member of a superuser role or of `pg_execute_server_program` /
9
+ * `pg_read_server_files` / `pg_write_server_files`, or a role that may execute the server-file
10
+ * functions is refused - a read-only transaction does not stop `COPY ... TO PROGRAM` or
11
+ * `pg_read_file`. {@link postgresRoleSql} prints the role to use instead.
12
+ * 3. Extension gate: `dblink`, `postgres_fdw`, `file_fdw` or an untrusted language the role can use
13
+ * is refused unless `allowExtensions` names it - `dblink_exec` writes to another database from a
14
+ * read-only transaction.
15
+ * 4. Transaction: every connection starts with `default_transaction_read_only=on`; each call runs in
16
+ * `BEGIN READ ONLY` with `statement_timeout`, `lock_timeout` and
17
+ * `idle_in_transaction_session_timeout` set locally, and always rolls back.
18
+ * 5. Statement gate: the query goes over the extended protocol (the server refuses a second
19
+ * statement) as `DECLARE ... NO SCROLL CURSOR` (the server accepts only a query there), and at most
20
+ * `maxRows + 1` rows are fetched. A tokenizer in front refuses side-effect functions.
21
+ * 6. Table scope: the plan's relations (and every table they inherit from) must sit in the allowed
22
+ * schemas and outside `exclude`. A function can still read a table internally; hard isolation
23
+ * comes from the role's grants, which {@link postgresRoleSql} writes with a REVOKE per exclusion.
24
+ *
25
+ * const target = parsePostgresUrl("postgres://nifra_dev_reader@localhost:5432/app")
26
+ * const client = connectPostgres(target, { timeoutMs: 5000 })
27
+ * const rows = await queryPostgres(client, "SELECT status, count(*) FROM orders GROUP BY 1", {
28
+ * timeoutMs: 5000, maxRows: 100, maxResultBytes: 100 * 1024,
29
+ * })
30
+ */
31
+
32
+ import {
33
+ explainThroughServer,
34
+ fetchThroughCursor,
35
+ inReadOnlyTransaction,
36
+ type PgConnection,
37
+ type PlanReads,
38
+ type PlanRelation,
39
+ type PostgresClient,
40
+ records,
41
+ } from "./internal/pg-exec.ts"
42
+ import { DENIED_FUNCTION_PREFIXES, lintPostgres, withoutTerminator } from "./internal/pg-lexer.ts"
43
+ import {
44
+ type DbPlan,
45
+ type DbRedaction,
46
+ type DbRefusal,
47
+ type DbRows,
48
+ type DbSchemaReport,
49
+ type DbSchemaTable,
50
+ dbRefusal,
51
+ fitToBytes,
52
+ isDbRefusal,
53
+ type ShapeRowsOptions,
54
+ shapeRows,
55
+ } from "./internal/shared.ts"
56
+
57
+ export type { PgConnection, PgQuery, PostgresClient } from "./internal/pg-exec.ts"
58
+
59
+ /** Where to connect, parsed from a `postgres://` URL. */
60
+ export interface PostgresTarget {
61
+ /** TCP host; absent for a unix socket. */
62
+ readonly host?: string
63
+ /** Unix socket file path. */
64
+ readonly socket?: string
65
+ readonly port: number
66
+ readonly username: string
67
+ readonly password?: string
68
+ readonly database: string
69
+ readonly tls?: "disable" | "allow" | "prefer" | "require" | "verify-ca" | "verify-full"
70
+ }
71
+
72
+ const SSL_MODES = ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] as const
73
+
74
+ /**
75
+ * Parse a `postgres://` (or `postgresql://`) URL. Only the host, port, user, password, database and
76
+ * `sslmode` are read - any other parameter (`options=-c ...` included) is ignored, so a URL cannot
77
+ * switch off the read-only session settings.
78
+ */
79
+ export function parsePostgresUrl(url: string): PostgresTarget | DbRefusal {
80
+ let parsed: URL
81
+ try {
82
+ parsed = new URL(url)
83
+ } catch {
84
+ return dbRefusal("NIFRA_DB_CONFIG", "devDatabase.url is not a valid URL")
85
+ }
86
+ if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
87
+ return dbRefusal(
88
+ "NIFRA_DB_CONFIG",
89
+ "devDatabase.url must start with postgres:// or postgresql://",
90
+ )
91
+ }
92
+ const params = parsed.searchParams
93
+ const queryHost = params.get("host") ?? undefined
94
+ const host = (queryHost ?? decodeURIComponent(parsed.hostname)).replace(/^\[(.*)\]$/, "$1")
95
+ if (host.includes(",")) {
96
+ return dbRefusal("NIFRA_DB_CONFIG", "devDatabase.url names several hosts; name one")
97
+ }
98
+ const port = Number(parsed.port || params.get("port") || 5432)
99
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
100
+ return dbRefusal("NIFRA_DB_CONFIG", "devDatabase.url has an invalid port")
101
+ }
102
+ const username = decodeURIComponent(parsed.username) || params.get("user") || "postgres"
103
+ const password = decodeURIComponent(parsed.password) || params.get("password") || undefined
104
+ const database =
105
+ decodeURIComponent(parsed.pathname.replace(/^\//, "")) || params.get("dbname") || username
106
+ const sslmode = params.get("sslmode") ?? undefined
107
+ const tls = SSL_MODES.find((mode) => mode === sslmode)
108
+ if (sslmode !== undefined && tls === undefined) {
109
+ return dbRefusal("NIFRA_DB_CONFIG", `devDatabase.url has an unknown sslmode ${sslmode}`)
110
+ }
111
+ const location =
112
+ host === "" || host.startsWith("/")
113
+ ? { socket: `${host === "" ? "/tmp" : host.replace(/\/$/, "")}/.s.PGSQL.${port}` }
114
+ : { host }
115
+ return {
116
+ ...location,
117
+ port,
118
+ username,
119
+ database,
120
+ ...(password === undefined ? {} : { password }),
121
+ ...(tls === undefined ? {} : { tls }),
122
+ }
123
+ }
124
+
125
+ /** True for `localhost`, `*.localhost`, 127.0.0.0/8 and `::1` (also as an IPv4-mapped address). */
126
+ export function isLocalPostgresHost(host: string): boolean {
127
+ const name = host.toLowerCase().replace(/^\[(.*)\]$/, "$1")
128
+ if (name === "localhost" || name.endsWith(".localhost")) return true
129
+ const v4 = /^(?:::ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name)
130
+ if (v4 !== null) {
131
+ const octets = v4.slice(1).map(Number)
132
+ return octets.every((octet) => octet <= 255) && octets[0] === 127
133
+ }
134
+ return name === "::1" || name === "0:0:0:0:0:0:0:1"
135
+ }
136
+
137
+ /** Refuse a TCP host that is neither local nor named in `allowHosts`. */
138
+ export function postgresHostRefusal(
139
+ target: PostgresTarget,
140
+ allowHosts: readonly string[] = [],
141
+ ): DbRefusal | undefined {
142
+ if (target.socket !== undefined || target.host === undefined) return undefined
143
+ if (isLocalPostgresHost(target.host)) return undefined
144
+ const host = target.host.toLowerCase()
145
+ if (allowHosts.some((allowed) => allowed.toLowerCase() === host)) return undefined
146
+ return dbRefusal(
147
+ "NIFRA_DB_REMOTE_HOST",
148
+ `${target.host} is not a local host, and devDatabase.allowHosts does not name it`,
149
+ )
150
+ }
151
+
152
+ /** Options for {@link connectPostgres}. */
153
+ export interface ConnectPostgresOptions {
154
+ readonly timeoutMs: number
155
+ readonly allowHosts?: readonly string[]
156
+ }
157
+
158
+ /**
159
+ * Open a one-connection `Bun.SQL` client for `target` after the host gate. Every session starts
160
+ * read-only with `standard_conforming_strings` on (the statement tokenizer reads strings that way).
161
+ */
162
+ export function connectPostgres(
163
+ target: PostgresTarget,
164
+ options: ConnectPostgresOptions,
165
+ ): PostgresClient | DbRefusal {
166
+ const refusal = postgresHostRefusal(target, options.allowHosts)
167
+ if (refusal !== undefined) return refusal
168
+ if (typeof Bun === "undefined") {
169
+ return dbRefusal("NIFRA_DB_DRIVER", "the Postgres engine needs Bun (it uses Bun.SQL)")
170
+ }
171
+ return new Bun.SQL({
172
+ adapter: "postgres",
173
+ ...(target.socket === undefined ? { hostname: target.host } : { path: target.socket }),
174
+ port: target.port,
175
+ username: target.username,
176
+ ...(target.password === undefined ? {} : { password: target.password }),
177
+ database: target.database,
178
+ ...(target.tls === undefined ? {} : { tls: target.tls }),
179
+ max: 1,
180
+ bigint: true,
181
+ connectionTimeout: Math.max(1, Math.ceil(options.timeoutMs / 1000)),
182
+ connection: {
183
+ application_name: "nifra-db",
184
+ default_transaction_read_only: "on",
185
+ standard_conforming_strings: "on",
186
+ statement_timeout: options.timeoutMs,
187
+ idle_in_transaction_session_timeout: options.timeoutMs,
188
+ },
189
+ })
190
+ }
191
+
192
+ /** What the role gate and extension gate read about the connected role. */
193
+ export interface PostgresRoleReport {
194
+ readonly role: string
195
+ readonly superuser: boolean
196
+ readonly serverVersionNum: number
197
+ /** Superuser roles the role is a member of. */
198
+ readonly superuserRoles: readonly string[]
199
+ /** Server-file and server-program roles the role is a member of. */
200
+ readonly serverRoles: readonly string[]
201
+ /** Server-file functions the role may execute. */
202
+ readonly fileFunctions: readonly string[]
203
+ /** `dblink`, `postgres_fdw`, `file_fdw`: installed and usable by the role. */
204
+ readonly extensions: readonly string[]
205
+ /** Untrusted procedural languages the role can use or call functions in. */
206
+ readonly languages: readonly string[]
207
+ }
208
+
209
+ const asStrings = (value: unknown): string[] =>
210
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
211
+
212
+ async function readRole(connection: PgConnection): Promise<PostgresRoleReport> {
213
+ const [row] = records(
214
+ await connection`
215
+ SELECT current_user::text AS role,
216
+ r.rolsuper AS superuser,
217
+ pg_catalog.current_setting('server_version_num')::int AS version,
218
+ to_json(ARRAY(SELECT s.rolname::text FROM pg_catalog.pg_roles s
219
+ WHERE s.rolsuper AND s.rolname <> current_user
220
+ AND pg_catalog.pg_has_role(current_user, s.oid, 'MEMBER') ORDER BY 1)) AS superuser_roles,
221
+ to_json(ARRAY(SELECT p.rolname::text FROM pg_catalog.pg_roles p
222
+ WHERE p.rolname IN ('pg_execute_server_program', 'pg_read_server_files', 'pg_write_server_files')
223
+ AND pg_catalog.pg_has_role(current_user, p.oid, 'MEMBER') ORDER BY 1)) AS server_roles,
224
+ to_json(ARRAY(SELECT DISTINCT f.proname::text FROM pg_catalog.pg_proc f
225
+ WHERE f.pronamespace = 'pg_catalog'::regnamespace
226
+ AND f.proname IN ('pg_read_file', 'pg_read_binary_file', 'pg_ls_dir', 'pg_stat_file', 'lo_import', 'lo_export')
227
+ AND pg_catalog.has_function_privilege(f.oid, 'EXECUTE') ORDER BY 1)) AS file_functions,
228
+ to_json(ARRAY(
229
+ SELECT e.extname::text FROM pg_catalog.pg_extension e
230
+ WHERE e.extname = 'dblink' AND EXISTS (
231
+ SELECT 1 FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_proc p ON p.oid = d.objid
232
+ WHERE d.classid = 'pg_catalog.pg_proc'::regclass AND d.refclassid = 'pg_catalog.pg_extension'::regclass
233
+ AND d.refobjid = e.oid AND d.deptype = 'e' AND pg_catalog.has_function_privilege(p.oid, 'EXECUTE'))
234
+ UNION
235
+ SELECT w.fdwname::text FROM pg_catalog.pg_foreign_data_wrapper w
236
+ WHERE w.fdwname IN ('postgres_fdw', 'file_fdw') AND (
237
+ pg_catalog.has_foreign_data_wrapper_privilege(w.oid, 'USAGE') OR EXISTS (
238
+ SELECT 1 FROM pg_catalog.pg_foreign_table ft JOIN pg_catalog.pg_foreign_server fs ON fs.oid = ft.ftserver
239
+ WHERE fs.srvfdw = w.oid AND pg_catalog.has_table_privilege(ft.ftrelid, 'SELECT')))
240
+ ORDER BY 1)) AS extensions,
241
+ to_json(ARRAY(SELECT l.lanname::text FROM pg_catalog.pg_language l
242
+ WHERE NOT l.lanpltrusted AND l.lanname NOT IN ('c', 'internal') AND (
243
+ pg_catalog.has_language_privilege(l.oid, 'USAGE') OR EXISTS (
244
+ SELECT 1 FROM pg_catalog.pg_proc p WHERE p.prolang = l.oid
245
+ AND pg_catalog.has_function_privilege(p.oid, 'EXECUTE')))
246
+ ORDER BY 1)) AS languages
247
+ FROM pg_catalog.pg_roles r WHERE r.rolname = current_user`,
248
+ )
249
+ if (row === undefined) throw new Error("the connected role is not in pg_roles")
250
+ return {
251
+ role: String(row.role),
252
+ superuser: row.superuser === true,
253
+ serverVersionNum: Number(row.version),
254
+ superuserRoles: asStrings(row.superuser_roles),
255
+ serverRoles: asStrings(row.server_roles),
256
+ fileFunctions: asStrings(row.file_functions),
257
+ extensions: asStrings(row.extensions),
258
+ languages: asStrings(row.languages),
259
+ }
260
+ }
261
+
262
+ /** Read the connected role's privileges, in a read-only transaction. */
263
+ export function inspectPostgresRole(
264
+ client: PostgresClient,
265
+ options: { readonly timeoutMs: number },
266
+ ): Promise<PostgresRoleReport | DbRefusal> {
267
+ return inReadOnlyTransaction(client, options.timeoutMs, readRole)
268
+ }
269
+
270
+ /** The role gate and the extension gate, as one decision over a {@link PostgresRoleReport}. */
271
+ export function postgresRoleRefusal(
272
+ report: PostgresRoleReport,
273
+ options: { readonly allowExtensions?: readonly string[] } = {},
274
+ ): DbRefusal | undefined {
275
+ const role = JSON.stringify(report.role)
276
+ if (report.superuser) return dbRefusal("NIFRA_DB_SUPERUSER", `role ${role} is a superuser`)
277
+ if (report.superuserRoles.length > 0) {
278
+ return dbRefusal(
279
+ "NIFRA_DB_SUPERUSER",
280
+ `role ${role} is a member of the superuser role ${report.superuserRoles.join(", ")}`,
281
+ )
282
+ }
283
+ if (report.serverRoles.length > 0) {
284
+ return dbRefusal(
285
+ "NIFRA_DB_SUPERUSER",
286
+ `role ${role} is a member of ${report.serverRoles.join(", ")}, which reach server programs or files`,
287
+ )
288
+ }
289
+ if (report.fileFunctions.length > 0) {
290
+ return dbRefusal(
291
+ "NIFRA_DB_SUPERUSER",
292
+ `role ${role} may execute ${report.fileFunctions.join(", ")}, which read or write server files`,
293
+ )
294
+ }
295
+ const allowed = new Set((options.allowExtensions ?? []).map((name) => name.toLowerCase()))
296
+ const usable = [...report.extensions, ...report.languages].filter(
297
+ (name) => !allowed.has(name.toLowerCase()),
298
+ )
299
+ if (usable.length > 0) {
300
+ return dbRefusal(
301
+ "NIFRA_DB_EXTENSION",
302
+ `role ${role} can use ${usable.join(", ")}, which can reach outside this database`,
303
+ )
304
+ }
305
+ return undefined
306
+ }
307
+
308
+ /** Refuse what the tokenizer layer refuses: a non-query, several statements, a denied function. */
309
+ export function lintPostgresStatement(sql: string): DbRefusal | undefined {
310
+ const refusal = lintPostgres(sql)
311
+ if (refusal === undefined) return undefined
312
+ return dbRefusal(
313
+ refusal.kind === "function" ? "NIFRA_DB_FUNCTION_REFUSED" : "NIFRA_DB_WRITE_REFUSED",
314
+ refusal.message,
315
+ )
316
+ }
317
+
318
+ /** Which relations a query may read. */
319
+ export interface PostgresScope {
320
+ /** Schemas a query may read (default `["public"]`). */
321
+ readonly schemas?: readonly string[]
322
+ /** Tables a query may not read: `name` in any allowed schema, or `schema.name`. */
323
+ readonly exclude?: readonly string[]
324
+ }
325
+
326
+ function scopeMatcher(scope: PostgresScope): (relation: PlanRelation) => string | undefined {
327
+ const schemas = new Set((scope.schemas ?? ["public"]).map((name) => name.toLowerCase()))
328
+ const excluded = new Set((scope.exclude ?? []).map((name) => name.toLowerCase()))
329
+ return ({ schema, name }) => {
330
+ const qualified = `${schema}.${name}`
331
+ if (!schemas.has(schema.toLowerCase())) {
332
+ return `the query reads ${qualified}, outside the allowed schemas (${[...schemas].join(", ")})`
333
+ }
334
+ if (excluded.has(name.toLowerCase()) || excluded.has(qualified.toLowerCase())) {
335
+ return `the query reads ${qualified}, which devDatabase.exclude leaves out`
336
+ }
337
+ return undefined
338
+ }
339
+ }
340
+
341
+ /**
342
+ * The plan-level table check: each relation, and every table it inherits from (partitions too). A
343
+ * `pg_*` function in FROM reads server state the way a catalog does - a system view such as
344
+ * `pg_stat_activity` plans as one once the planner drops its catalog joins - so it is out of scope too.
345
+ * With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused.
346
+ */
347
+ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean) {
348
+ const outOfScope = scopeMatcher(scope)
349
+ return async (connection: PgConnection, reads: PlanReads): Promise<DbRefusal | undefined> => {
350
+ for (const fn of reads.functions) {
351
+ const name = fn.name.toLowerCase()
352
+ if (fn.schema === "pg_catalog" && name.startsWith("pg_")) {
353
+ return dbRefusal(
354
+ "NIFRA_DB_TABLE_EXCLUDED",
355
+ `the query reads server state through ${fn.schema}.${fn.name}(), a system catalog function`,
356
+ )
357
+ }
358
+ if (DENIED_FUNCTION_PREFIXES.some((prefix) => name.startsWith(prefix))) {
359
+ return dbRefusal(
360
+ "NIFRA_DB_FUNCTION_REFUSED",
361
+ `the query calls ${fn.schema}.${fn.name}(), which reaches outside a read-only query`,
362
+ )
363
+ }
364
+ }
365
+ const { relations } = reads
366
+ for (const relation of relations) {
367
+ const reason = outOfScope(relation)
368
+ if (reason !== undefined) return dbRefusal("NIFRA_DB_TABLE_EXCLUDED", reason)
369
+ }
370
+ if (relations.length === 0) return undefined
371
+ const ancestors = records(
372
+ await connection`
373
+ WITH RECURSIVE planned AS (
374
+ SELECT pg_catalog.to_regclass(pg_catalog.quote_ident(r.schema) || '.' || pg_catalog.quote_ident(r.name))::oid AS oid
375
+ FROM pg_catalog.json_to_recordset(${[...relations]}::json) AS r(schema text, name text)
376
+ ), up(oid) AS (
377
+ SELECT i.inhparent FROM pg_catalog.pg_inherits i JOIN planned p ON i.inhrelid = p.oid
378
+ UNION
379
+ SELECT i.inhparent FROM pg_catalog.pg_inherits i JOIN up ON i.inhrelid = up.oid
380
+ )
381
+ SELECT n.nspname::text AS schema, c.relname::text AS name
382
+ FROM up JOIN pg_catalog.pg_class c ON c.oid = up.oid
383
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace`,
384
+ )
385
+ for (const row of ancestors) {
386
+ const reason = outOfScope({ schema: String(row.schema), name: String(row.name) })
387
+ if (reason !== undefined) return dbRefusal("NIFRA_DB_TABLE_EXCLUDED", reason)
388
+ }
389
+ if (masked === undefined) return undefined
390
+ const hits = new Set<string>()
391
+ const wholeRows: PlanRelation[] = []
392
+ for (const { relation, column } of reads.columns) {
393
+ if (column === "*") wholeRows.push(relation)
394
+ else if (masked(column)) hits.add(`${relation.name}.${column}`)
395
+ }
396
+ const listed = [...wholeRows, ...reads.outputs.map((output) => output.relation)]
397
+ if (listed.length > 0) {
398
+ const live = new Map<string, string[]>()
399
+ const rows = records(
400
+ await connection`
401
+ SELECT r.schema, r.name, a.attname::text AS column
402
+ FROM pg_catalog.json_to_recordset(${listed}::json) AS r(schema text, name text)
403
+ JOIN pg_catalog.pg_attribute a
404
+ ON a.attrelid = pg_catalog.to_regclass(pg_catalog.quote_ident(r.schema) || '.' || pg_catalog.quote_ident(r.name))
405
+ WHERE a.attnum > 0 AND NOT a.attisdropped`,
406
+ )
407
+ for (const row of rows) {
408
+ const key = `${String(row.schema)}.${String(row.name)}`
409
+ live.set(key, [...(live.get(key) ?? []), String(row.column)])
410
+ }
411
+ const columnsOf = (relation: PlanRelation): readonly string[] =>
412
+ live.get(`${relation.schema}.${relation.name}`) ?? []
413
+ for (const relation of wholeRows) {
414
+ for (const column of columnsOf(relation))
415
+ if (masked(column)) hits.add(`${relation.name}.${column}`)
416
+ }
417
+ for (const output of reads.outputs) {
418
+ const all = columnsOf(output.relation)
419
+ // Every column is the physical target list: the parent's expressions say what it uses.
420
+ if (all.length > 0 && all.every((column) => output.columns.includes(column))) continue
421
+ for (const column of output.columns)
422
+ if (masked(column)) hits.add(`${output.relation.name}.${column}`)
423
+ }
424
+ }
425
+ if (hits.size === 0) return undefined
426
+ return dbRefusal(
427
+ "NIFRA_DB_COLUMN_REFUSED",
428
+ `the query reads ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}`,
429
+ )
430
+ }
431
+ }
432
+
433
+ /** Options for {@link queryPostgres}. */
434
+ export interface PostgresQueryOptions extends ShapeRowsOptions, PostgresScope {
435
+ readonly timeoutMs: number
436
+ /** Extensions or untrusted languages to accept although the role can use them. */
437
+ readonly allowExtensions?: readonly string[]
438
+ }
439
+
440
+ async function gated<T>(
441
+ client: PostgresClient,
442
+ sql: string,
443
+ options: PostgresQueryOptions,
444
+ work: (connection: PgConnection, statement: string) => Promise<T | DbRefusal>,
445
+ ): Promise<T | DbRefusal> {
446
+ const linted = lintPostgresStatement(sql)
447
+ if (linted !== undefined) return linted
448
+ return inReadOnlyTransaction(client, options.timeoutMs, async (connection) => {
449
+ const refusal = postgresRoleRefusal(await readRole(connection), options)
450
+ if (refusal !== undefined) return refusal
451
+ return work(connection, withoutTerminator(sql.trim()))
452
+ })
453
+ }
454
+
455
+ /**
456
+ * Run one read-only query through every layer and return capped rows. A column `redaction.column`
457
+ * masks is refused wherever the plan uses it (selected under any alias, inside an expression, in a
458
+ * filter, or within a whole row); a matching result name is masked as well.
459
+ */
460
+ export async function queryPostgres(
461
+ client: PostgresClient,
462
+ sql: string,
463
+ options: PostgresQueryOptions,
464
+ ): Promise<DbRows | DbRefusal> {
465
+ const fetched = await gated(client, sql, options, (connection, statement) =>
466
+ fetchThroughCursor(connection, statement, {
467
+ maxRows: options.maxRows,
468
+ scope: checkScope(options, options.redaction?.column),
469
+ }),
470
+ )
471
+ if (isDbRefusal(fetched)) return fetched
472
+ return shapeRows(fetched.columns, fetched.rows, options)
473
+ }
474
+
475
+ /** Drop plan children below `depth` so an oversized plan still says what its top does. */
476
+ function prunePlan(node: unknown, depth: number): unknown {
477
+ if (Array.isArray(node)) return node.map((item) => prunePlan(item, depth))
478
+ if (typeof node !== "object" || node === null) return node
479
+ const out: Record<string, unknown> = {}
480
+ for (const [key, value] of Object.entries(node)) {
481
+ if (key === "Plans") {
482
+ if (depth > 0) out[key] = prunePlan(value, depth - 1)
483
+ } else out[key] = key === "Plan" ? prunePlan(value, depth) : value
484
+ }
485
+ return out
486
+ }
487
+
488
+ /** `EXPLAIN (FORMAT JSON)` for one query, after the same layers as {@link queryPostgres}. */
489
+ export async function explainPostgres(
490
+ client: PostgresClient,
491
+ sql: string,
492
+ options: PostgresQueryOptions & { readonly analyze?: boolean },
493
+ ): Promise<DbPlan | DbRefusal> {
494
+ const explained = await gated(client, sql, options, (connection, statement) =>
495
+ explainThroughServer(connection, statement, {
496
+ analyze: options.analyze === true,
497
+ scope: checkScope(options),
498
+ }),
499
+ )
500
+ if (isDbRefusal(explained)) return explained
501
+ const full = JSON.stringify(explained.plan)
502
+ if (new TextEncoder().encode(full).byteLength <= options.maxResultBytes) {
503
+ return { plan: explained.plan, analyzed: options.analyze === true, truncated: false }
504
+ }
505
+ const fit = fitToBytes(32, (depth) => prunePlan(explained.plan, depth), options.maxResultBytes)
506
+ return { plan: fit?.value ?? null, analyzed: options.analyze === true, truncated: true }
507
+ }
508
+
509
+ const RELKINDS: Readonly<Record<string, DbSchemaTable["kind"]>> = {
510
+ r: "table",
511
+ p: "partitioned table",
512
+ v: "view",
513
+ m: "materialized view",
514
+ f: "foreign table",
515
+ }
516
+
517
+ /** Options for {@link readPostgresSchema}. */
518
+ export interface PostgresSchemaOptions extends PostgresScope {
519
+ readonly timeoutMs: number
520
+ /** Describe only this table (`name` or `schema.name`). */
521
+ readonly table?: string | undefined
522
+ readonly redaction?: DbRedaction
523
+ }
524
+
525
+ const jsonValues = (value: unknown): unknown[] => {
526
+ const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value
527
+ return Array.isArray(parsed) ? parsed : []
528
+ }
529
+
530
+ const jsonStrings = (value: unknown): string[] => asStrings(jsonValues(value))
531
+
532
+ /**
533
+ * Describe the readable tables and views in the allowed schemas from `pg_catalog`, with queries this
534
+ * module writes (no caller SQL runs), so it works on any role: columns, primary keys, foreign keys,
535
+ * indexes and the planner's row estimate.
536
+ */
537
+ export async function readPostgresSchema(
538
+ client: PostgresClient,
539
+ options: PostgresSchemaOptions,
540
+ ): Promise<DbSchemaReport | DbRefusal> {
541
+ const schemas = [...(options.schemas ?? ["public"])]
542
+ const excluded = new Set((options.exclude ?? []).map((name) => name.toLowerCase()))
543
+ const isExcluded = (schema: string, name: string): boolean =>
544
+ excluded.has(name.toLowerCase()) || excluded.has(`${schema}.${name}`.toLowerCase())
545
+ const wanted = options.table?.toLowerCase()
546
+ const matchesWanted = (schema: string, name: string): boolean =>
547
+ wanted === undefined ||
548
+ name.toLowerCase() === wanted ||
549
+ `${schema}.${name}`.toLowerCase() === wanted
550
+ return inReadOnlyTransaction(client, options.timeoutMs, async (connection) => {
551
+ const relations = records(
552
+ await connection`
553
+ SELECT n.nspname::text AS schema, c.relname::text AS name, c.relkind::text AS kind,
554
+ c.reltuples::float8 AS estimate,
555
+ to_json(ARRAY(
556
+ WITH RECURSIVE reach(oid) AS (
557
+ SELECT c.oid
558
+ UNION
559
+ SELECT next.oid FROM reach, LATERAL (
560
+ SELECT i.inhparent AS oid FROM pg_catalog.pg_inherits i WHERE i.inhrelid = reach.oid
561
+ UNION ALL
562
+ SELECT d.refobjid FROM pg_catalog.pg_rewrite w
563
+ JOIN pg_catalog.pg_depend d ON d.classid = 'pg_catalog.pg_rewrite'::regclass
564
+ AND d.objid = w.oid AND d.refclassid = 'pg_catalog.pg_class'::regclass
565
+ WHERE w.ev_class = reach.oid
566
+ ) AS next
567
+ )
568
+ SELECT pg_catalog.json_build_array(pn.nspname::text, pc.relname::text) FROM reach
569
+ JOIN pg_catalog.pg_class pc ON pc.oid = reach.oid
570
+ JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
571
+ WHERE reach.oid <> c.oid)) AS reaches
572
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
573
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f') AND NOT c.relispartition
574
+ AND n.nspname = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${schemas}::json)))
575
+ AND pg_catalog.has_table_privilege(c.oid, 'SELECT')
576
+ ORDER BY 1, 2`,
577
+ )
578
+ const columns = records(
579
+ await connection`
580
+ SELECT n.nspname::text AS schema, c.relname::text AS table, a.attname::text AS name,
581
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, NOT a.attnotnull AS nullable,
582
+ pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default_value,
583
+ EXISTS (SELECT 1 FROM pg_catalog.pg_constraint k
584
+ WHERE k.conrelid = c.oid AND k.contype = 'p' AND a.attnum = ANY(k.conkey)) AS primary_key
585
+ FROM pg_catalog.pg_attribute a
586
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
587
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
588
+ LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
589
+ WHERE a.attnum > 0 AND NOT a.attisdropped AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
590
+ AND n.nspname = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${schemas}::json)))
591
+ ORDER BY 1, 2, a.attnum`,
592
+ )
593
+ const foreignKeys = records(
594
+ await connection`
595
+ SELECT n.nspname::text AS schema, c.relname::text AS table,
596
+ to_json(ARRAY(SELECT a.attname::text FROM unnest(k.conkey) WITH ORDINALITY AS u(num, ord)
597
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.num ORDER BY u.ord)) AS columns,
598
+ fn.nspname::text AS ref_schema, fc.relname::text AS ref_table,
599
+ to_json(ARRAY(SELECT a.attname::text FROM unnest(k.confkey) WITH ORDINALITY AS u(num, ord)
600
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = k.confrelid AND a.attnum = u.num ORDER BY u.ord)) AS ref_columns
601
+ FROM pg_catalog.pg_constraint k
602
+ JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
603
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
604
+ JOIN pg_catalog.pg_class fc ON fc.oid = k.confrelid
605
+ JOIN pg_catalog.pg_namespace fn ON fn.oid = fc.relnamespace
606
+ WHERE k.contype = 'f'
607
+ AND n.nspname = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${schemas}::json)))
608
+ ORDER BY 1, 2, k.conname`,
609
+ )
610
+ const indexes = records(
611
+ await connection`
612
+ SELECT n.nspname::text AS schema, t.relname::text AS table, i.relname::text AS name,
613
+ x.indisunique AS is_unique,
614
+ to_json(ARRAY(SELECT pg_catalog.pg_get_indexdef(x.indexrelid, k, true)
615
+ FROM generate_series(1, x.indnkeyatts) AS k ORDER BY k)) AS columns
616
+ FROM pg_catalog.pg_index x
617
+ JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid
618
+ JOIN pg_catalog.pg_class t ON t.oid = x.indrelid
619
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
620
+ WHERE n.nspname = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${schemas}::json)))
621
+ ORDER BY 1, 2, 3`,
622
+ )
623
+ const key = (schema: unknown, name: unknown): string => `${String(schema)}.${String(name)}`
624
+ const text = (value: string): string => options.redaction?.text?.(value) ?? value
625
+ // What the query scope would refuse is hidden too: a table inheriting from an excluded one, and a
626
+ // view reading one (or reading outside the allowed schemas).
627
+ const allowedSchemas = new Set(schemas.map((name) => name.toLowerCase()))
628
+ const outOfScope = (ancestor: unknown): boolean => {
629
+ const [schema, name] = asStrings(ancestor)
630
+ return (
631
+ schema === undefined ||
632
+ name === undefined ||
633
+ !allowedSchemas.has(schema.toLowerCase()) ||
634
+ isExcluded(schema, name)
635
+ )
636
+ }
637
+ const visible = relations.filter(
638
+ (row) =>
639
+ !isExcluded(String(row.schema), String(row.name)) &&
640
+ !jsonValues(row.reaches).some(outOfScope),
641
+ )
642
+ const shown = visible.filter((row) => matchesWanted(String(row.schema), String(row.name)))
643
+ if (wanted !== undefined && shown.length === 0) {
644
+ return dbRefusal(
645
+ "NIFRA_DB_TABLE_EXCLUDED",
646
+ `${JSON.stringify(options.table)} is not an exposed table or view of this database`,
647
+ )
648
+ }
649
+ const tables: DbSchemaTable[] = shown.map((row) => {
650
+ const id = key(row.schema, row.name)
651
+ const kind = RELKINDS[String(row.kind)] ?? "table"
652
+ const estimate = Number(row.estimate)
653
+ return {
654
+ schema: String(row.schema),
655
+ name: String(row.name),
656
+ kind,
657
+ rowEstimate:
658
+ kind === "view" || !Number.isFinite(estimate) || estimate < 0
659
+ ? null
660
+ : Math.round(estimate),
661
+ columns: columns
662
+ .filter((column) => key(column.schema, column.table) === id)
663
+ .map((column) => ({
664
+ name: String(column.name),
665
+ type: String(column.type),
666
+ nullable: column.nullable === true,
667
+ default: typeof column.default_value === "string" ? text(column.default_value) : null,
668
+ primaryKey: column.primary_key === true,
669
+ redacted: options.redaction?.column?.(String(column.name)) === true,
670
+ })),
671
+ foreignKeys: foreignKeys
672
+ .filter((fk) => key(fk.schema, fk.table) === id)
673
+ .map((fk) => ({
674
+ columns: jsonStrings(fk.columns),
675
+ references: {
676
+ schema: String(fk.ref_schema),
677
+ table: String(fk.ref_table),
678
+ columns: jsonStrings(fk.ref_columns),
679
+ },
680
+ })),
681
+ indexes: indexes
682
+ .filter((index) => key(index.schema, index.table) === id)
683
+ .map((index) => ({
684
+ name: String(index.name),
685
+ unique: index.is_unique === true,
686
+ columns: jsonStrings(index.columns),
687
+ })),
688
+ }
689
+ })
690
+ return { tables, excludedCount: relations.length - visible.length }
691
+ })
692
+ }
693
+
694
+ const quoteIdent = (name: string): string => `"${name.replaceAll('"', '""')}"`
695
+
696
+ /** Options for {@link postgresRoleSql}. */
697
+ export interface PostgresRoleSqlOptions extends PostgresScope {
698
+ readonly timeoutMs: number
699
+ /** The role to create (default `nifra_dev_reader`). */
700
+ readonly role?: string
701
+ }
702
+
703
+ /** The SQL that creates a read-only role for this database, for the developer to run themselves. */
704
+ export interface PostgresRoleSql {
705
+ readonly role: string
706
+ readonly database: string
707
+ readonly serverVersionNum: number
708
+ readonly statements: readonly string[]
709
+ }
710
+
711
+ /**
712
+ * Write the SQL for a login role that can read the allowed schemas and nothing else: `pg_read_all_data`
713
+ * on Postgres 14+ when nothing is excluded; otherwise `GRANT SELECT` per schema plus default
714
+ * privileges, and a `REVOKE` for each excluded table and its partitions (`pg_read_all_data` would
715
+ * override a REVOKE, so it is not used then). Nothing is executed.
716
+ */
717
+ export async function postgresRoleSql(
718
+ client: PostgresClient,
719
+ options: PostgresRoleSqlOptions,
720
+ ): Promise<PostgresRoleSql | DbRefusal> {
721
+ const role = options.role ?? "nifra_dev_reader"
722
+ const schemas = options.schemas ?? ["public"]
723
+ const exclude = (options.exclude ?? []).map((name) => name.toLowerCase())
724
+ return inReadOnlyTransaction(client, options.timeoutMs, async (connection) => {
725
+ const [facts] = records(
726
+ await connection`
727
+ SELECT pg_catalog.current_database()::text AS database,
728
+ pg_catalog.current_setting('server_version_num')::int AS version`,
729
+ )
730
+ const excluded =
731
+ exclude.length === 0
732
+ ? []
733
+ : records(
734
+ await connection`
735
+ WITH RECURSIVE picked AS (
736
+ SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
737
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
738
+ AND n.nspname = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${[...schemas]}::json)))
739
+ AND (lower(c.relname) = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${exclude}::json)))
740
+ OR lower(n.nspname || '.' || c.relname) = ANY(ARRAY(SELECT pg_catalog.json_array_elements_text(${exclude}::json))))
741
+ UNION
742
+ SELECT i.inhrelid FROM pg_catalog.pg_inherits i JOIN picked p ON i.inhparent = p.oid
743
+ )
744
+ SELECT n.nspname::text AS schema, c.relname::text AS name
745
+ FROM picked JOIN pg_catalog.pg_class c ON c.oid = picked.oid
746
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
747
+ ORDER BY 1, 2`,
748
+ )
749
+ const database = String(facts?.database ?? "")
750
+ const version = Number(facts?.version ?? 0)
751
+ const who = quoteIdent(role)
752
+ const statements = [
753
+ `-- Run as the owner of the app's tables (or a superuser). Then set the password from psql with \\password ${role}`,
754
+ `-- and point devDatabase.url at postgres://${role}:<password>@localhost/${database}`,
755
+ `CREATE ROLE ${who} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;`,
756
+ `ALTER ROLE ${who} SET default_transaction_read_only = on;`,
757
+ `GRANT CONNECT ON DATABASE ${quoteIdent(database)} TO ${who};`,
758
+ ]
759
+ if (version >= 140000 && excluded.length === 0) {
760
+ statements.push(`GRANT pg_read_all_data TO ${who};`)
761
+ } else {
762
+ for (const schema of schemas) {
763
+ statements.push(
764
+ `GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${who};`,
765
+ `GRANT SELECT ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${who};`,
766
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT ON TABLES TO ${who};`,
767
+ )
768
+ }
769
+ for (const row of excluded) {
770
+ statements.push(
771
+ `REVOKE SELECT ON TABLE ${quoteIdent(String(row.schema))}.${quoteIdent(String(row.name))} FROM ${who};`,
772
+ )
773
+ }
774
+ }
775
+ return { role, database, serverVersionNum: version, statements }
776
+ })
777
+ }