postgresai 0.14.0-dev.7 → 0.14.0-dev.70

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 (82) hide show
  1. package/README.md +161 -61
  2. package/bin/postgres-ai.ts +1957 -404
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +20 -0
  5. package/dist/bin/postgres-ai.js +29351 -1576
  6. package/dist/sql/01.role.sql +16 -0
  7. package/dist/sql/02.permissions.sql +37 -0
  8. package/dist/sql/03.optional_rds.sql +6 -0
  9. package/dist/sql/04.optional_self_managed.sql +8 -0
  10. package/dist/sql/05.helpers.sql +439 -0
  11. package/dist/sql/sql/01.role.sql +16 -0
  12. package/dist/sql/sql/02.permissions.sql +37 -0
  13. package/dist/sql/sql/03.optional_rds.sql +6 -0
  14. package/dist/sql/sql/04.optional_self_managed.sql +8 -0
  15. package/dist/sql/sql/05.helpers.sql +439 -0
  16. package/lib/auth-server.ts +124 -106
  17. package/lib/checkup-api.ts +386 -0
  18. package/lib/checkup.ts +1396 -0
  19. package/lib/config.ts +6 -3
  20. package/lib/init.ts +512 -156
  21. package/lib/issues.ts +400 -191
  22. package/lib/mcp-server.ts +213 -90
  23. package/lib/metrics-embedded.ts +79 -0
  24. package/lib/metrics-loader.ts +127 -0
  25. package/lib/supabase.ts +769 -0
  26. package/lib/util.ts +61 -0
  27. package/package.json +20 -10
  28. package/packages/postgres-ai/README.md +26 -0
  29. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  30. package/packages/postgres-ai/package.json +27 -0
  31. package/scripts/embed-metrics.ts +154 -0
  32. package/sql/01.role.sql +16 -0
  33. package/sql/02.permissions.sql +37 -0
  34. package/sql/03.optional_rds.sql +6 -0
  35. package/sql/04.optional_self_managed.sql +8 -0
  36. package/sql/05.helpers.sql +439 -0
  37. package/test/auth.test.ts +258 -0
  38. package/test/checkup.integration.test.ts +321 -0
  39. package/test/checkup.test.ts +1117 -0
  40. package/test/init.integration.test.ts +500 -0
  41. package/test/init.test.ts +527 -0
  42. package/test/issues.cli.test.ts +314 -0
  43. package/test/issues.test.ts +456 -0
  44. package/test/mcp-server.test.ts +988 -0
  45. package/test/schema-validation.test.ts +81 -0
  46. package/test/supabase.test.ts +568 -0
  47. package/test/test-utils.ts +128 -0
  48. package/tsconfig.json +12 -20
  49. package/dist/bin/postgres-ai.d.ts +0 -3
  50. package/dist/bin/postgres-ai.d.ts.map +0 -1
  51. package/dist/bin/postgres-ai.js.map +0 -1
  52. package/dist/lib/auth-server.d.ts +0 -31
  53. package/dist/lib/auth-server.d.ts.map +0 -1
  54. package/dist/lib/auth-server.js +0 -263
  55. package/dist/lib/auth-server.js.map +0 -1
  56. package/dist/lib/config.d.ts +0 -45
  57. package/dist/lib/config.d.ts.map +0 -1
  58. package/dist/lib/config.js +0 -181
  59. package/dist/lib/config.js.map +0 -1
  60. package/dist/lib/init.d.ts +0 -61
  61. package/dist/lib/init.d.ts.map +0 -1
  62. package/dist/lib/init.js +0 -359
  63. package/dist/lib/init.js.map +0 -1
  64. package/dist/lib/issues.d.ts +0 -75
  65. package/dist/lib/issues.d.ts.map +0 -1
  66. package/dist/lib/issues.js +0 -336
  67. package/dist/lib/issues.js.map +0 -1
  68. package/dist/lib/mcp-server.d.ts +0 -9
  69. package/dist/lib/mcp-server.d.ts.map +0 -1
  70. package/dist/lib/mcp-server.js +0 -168
  71. package/dist/lib/mcp-server.js.map +0 -1
  72. package/dist/lib/pkce.d.ts +0 -32
  73. package/dist/lib/pkce.d.ts.map +0 -1
  74. package/dist/lib/pkce.js +0 -101
  75. package/dist/lib/pkce.js.map +0 -1
  76. package/dist/lib/util.d.ts +0 -27
  77. package/dist/lib/util.d.ts.map +0 -1
  78. package/dist/lib/util.js +0 -46
  79. package/dist/lib/util.js.map +0 -1
  80. package/dist/package.json +0 -46
  81. package/test/init.integration.test.cjs +0 -269
  82. package/test/init.test.cjs +0 -69
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Supabase Management API client for database operations.
3
+ *
4
+ * This module provides an alternative to direct PostgreSQL connections by using
5
+ * the Supabase Management API to execute SQL queries.
6
+ *
7
+ * API Reference: https://supabase.com/docs/reference/api/introduction
8
+ * Endpoint: POST /v1/projects/{ref}/database/query
9
+ */
10
+
11
+ const SUPABASE_API_BASE = "https://api.supabase.com";
12
+
13
+ export type SupabaseConfig = {
14
+ /** Supabase project reference (e.g., "abc123xyz") */
15
+ projectRef: string;
16
+ /** Supabase Management API access token (Personal Access Token) */
17
+ accessToken: string;
18
+ };
19
+
20
+ /**
21
+ * PostgreSQL-compatible error structure.
22
+ * Mirrors the error fields from node-postgres for consistent error handling.
23
+ */
24
+ export type PgCompatibleError = Error & {
25
+ code?: string;
26
+ detail?: string;
27
+ hint?: string;
28
+ position?: string;
29
+ internalPosition?: string;
30
+ internalQuery?: string;
31
+ where?: string;
32
+ schema?: string;
33
+ table?: string;
34
+ column?: string;
35
+ dataType?: string;
36
+ constraint?: string;
37
+ file?: string;
38
+ line?: string;
39
+ routine?: string;
40
+ // Supabase-specific fields (mapped to pg-compatible structure)
41
+ supabaseErrorCode?: string;
42
+ httpStatus?: number;
43
+ };
44
+
45
+ /**
46
+ * Result from Supabase Management API query endpoint.
47
+ */
48
+ export type SupabaseQueryResult = {
49
+ rows: Record<string, unknown>[];
50
+ rowCount: number;
51
+ };
52
+
53
+ /**
54
+ * Raw response from Supabase Management API.
55
+ */
56
+ type SupabaseApiResponse = {
57
+ // Success case: array of rows
58
+ // Error case: { code, message, ... }
59
+ error?: {
60
+ code?: string;
61
+ message?: string;
62
+ details?: string;
63
+ hint?: string;
64
+ };
65
+ // The API returns the result directly (array) on success
66
+ } | Record<string, unknown>[];
67
+
68
+ /**
69
+ * Validate Supabase project reference format.
70
+ * Project refs are typically 20 lowercase alphanumeric characters.
71
+ */
72
+ function isValidProjectRef(ref: string): boolean {
73
+ // Supabase project refs are alphanumeric, typically 20 chars, lowercase
74
+ return /^[a-z0-9]{10,30}$/i.test(ref);
75
+ }
76
+
77
+ /**
78
+ * Supabase Management API client for executing SQL queries.
79
+ */
80
+ export class SupabaseClient {
81
+ private config: SupabaseConfig;
82
+
83
+ constructor(config: SupabaseConfig) {
84
+ if (!config.projectRef) {
85
+ throw new Error("Supabase project reference is required");
86
+ }
87
+ if (!config.accessToken) {
88
+ throw new Error("Supabase access token is required");
89
+ }
90
+ // Validate project ref format to prevent path traversal
91
+ if (!isValidProjectRef(config.projectRef)) {
92
+ throw new Error(`Invalid Supabase project reference format: "${config.projectRef}". Expected 10-30 alphanumeric characters.`);
93
+ }
94
+ this.config = config;
95
+ }
96
+
97
+ /**
98
+ * Execute a SQL query via the Supabase Management API.
99
+ *
100
+ * @param sql The SQL query to execute
101
+ * @param readOnly If true, uses read_only flag in API request (default: false for DDL/DML operations)
102
+ * @returns Query result with rows and rowCount (rowCount is array length for SELECT queries)
103
+ * @throws PgCompatibleError on failure
104
+ */
105
+ async query(sql: string, readOnly = false): Promise<SupabaseQueryResult> {
106
+ // URL-encode projectRef for safety (validated in constructor, but defense in depth)
107
+ const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(this.config.projectRef)}/database/query`;
108
+
109
+ const response = await fetch(url, {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ Authorization: `Bearer ${this.config.accessToken}`,
114
+ },
115
+ body: JSON.stringify({
116
+ query: sql,
117
+ read_only: readOnly,
118
+ }),
119
+ });
120
+
121
+ const body = await response.text();
122
+ let data: SupabaseApiResponse;
123
+
124
+ try {
125
+ data = JSON.parse(body);
126
+ } catch {
127
+ // If we can't parse JSON, create an error with the raw body
128
+ throw this.createPgError({
129
+ message: `Supabase API returned non-JSON response: ${body.slice(0, 200)}`,
130
+ httpStatus: response.status,
131
+ });
132
+ }
133
+
134
+ // Handle HTTP errors
135
+ if (!response.ok) {
136
+ throw this.parseApiError(data, response.status);
137
+ }
138
+
139
+ // Handle explicit error response
140
+ if (data && typeof data === "object" && "error" in data && data.error) {
141
+ throw this.parseApiError(data, response.status);
142
+ }
143
+
144
+ // Success: API returns array of rows directly
145
+ const rows = Array.isArray(data) ? data : [];
146
+ return {
147
+ rows: rows as Record<string, unknown>[],
148
+ rowCount: rows.length,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Test connection by executing a simple query.
154
+ */
155
+ async testConnection(): Promise<{ database: string; version: string }> {
156
+ const result = await this.query(
157
+ "SELECT current_database() as db, version() as version",
158
+ true
159
+ );
160
+ const row = result.rows[0] ?? {};
161
+ return {
162
+ database: String(row.db ?? ""),
163
+ version: String(row.version ?? ""),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Get current database name.
169
+ */
170
+ async getCurrentDatabase(): Promise<string> {
171
+ const result = await this.query("SELECT current_database() as db", true);
172
+ const row = result.rows[0] ?? {};
173
+ return String(row.db ?? "");
174
+ }
175
+
176
+ /**
177
+ * Parse Supabase API error and convert to PostgreSQL-compatible error.
178
+ */
179
+ private parseApiError(
180
+ data: SupabaseApiResponse,
181
+ httpStatus: number
182
+ ): PgCompatibleError {
183
+ // Handle different error formats from Supabase API
184
+ if (data && typeof data === "object" && !Array.isArray(data)) {
185
+ const errObj = "error" in data && data.error ? data.error : data;
186
+
187
+ // Check for PostgreSQL error embedded in the response
188
+ // Supabase forwards PostgreSQL errors with their original structure
189
+ const pgCode = this.extractPgErrorCode(errObj);
190
+ const message = this.extractErrorMessage(errObj);
191
+ const detail = this.extractField(errObj, ["details", "detail"]);
192
+ const hint = this.extractField(errObj, ["hint"]);
193
+
194
+ return this.createPgError({
195
+ message,
196
+ code: pgCode,
197
+ detail,
198
+ hint,
199
+ httpStatus,
200
+ supabaseErrorCode:
201
+ typeof errObj === "object" && errObj && "code" in errObj
202
+ ? String((errObj as Record<string, unknown>).code ?? "")
203
+ : undefined,
204
+ });
205
+ }
206
+
207
+ return this.createPgError({
208
+ message: `Supabase API error (HTTP ${httpStatus})`,
209
+ httpStatus,
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Extract PostgreSQL error code from various error formats.
215
+ * Supabase may return errors as:
216
+ * - { code: "42501", ... } (PostgreSQL error code)
217
+ * - { code: "PGRST...", ... } (PostgREST error code)
218
+ * - { error: { code: "...", ... } }
219
+ */
220
+ private extractPgErrorCode(errObj: unknown): string | undefined {
221
+ if (!errObj || typeof errObj !== "object") return undefined;
222
+
223
+ const obj = errObj as Record<string, unknown>;
224
+
225
+ // Direct code field
226
+ if (typeof obj.code === "string") {
227
+ const code = obj.code;
228
+ // PostgreSQL error codes are 5 characters (e.g., "42501")
229
+ if (/^\d{5}$/.test(code)) {
230
+ return code;
231
+ }
232
+ // Map common Supabase/PostgREST error codes to PostgreSQL equivalents
233
+ return this.mapSupabaseCodeToPg(code);
234
+ }
235
+
236
+ return undefined;
237
+ }
238
+
239
+ /**
240
+ * Map Supabase/PostgREST error codes to PostgreSQL equivalents.
241
+ */
242
+ private mapSupabaseCodeToPg(code: string): string | undefined {
243
+ // PostgREST error codes: https://postgrest.org/en/stable/references/errors.html
244
+ const mapping: Record<string, string> = {
245
+ // Authentication/Authorization
246
+ PGRST301: "28000", // invalid_authorization_specification
247
+ PGRST302: "28P01", // invalid_password
248
+ // Permission errors
249
+ "42501": "42501", // insufficient_privilege (pass through)
250
+ PGRST000: "42501", // permission denied (generic)
251
+ // Syntax errors
252
+ "42601": "42601", // syntax_error (pass through)
253
+ // Object errors
254
+ "42P01": "42P01", // undefined_table (pass through)
255
+ PGRST200: "42P01", // table not found
256
+ "42883": "42883", // undefined_function (pass through)
257
+ // Connection errors
258
+ "08000": "08000", // connection_exception (pass through)
259
+ "08003": "08003", // connection_does_not_exist (pass through)
260
+ "08006": "08006", // connection_failure (pass through)
261
+ // Duplicate object
262
+ "42710": "42710", // duplicate_object (pass through)
263
+ };
264
+
265
+ return mapping[code];
266
+ }
267
+
268
+ /**
269
+ * Extract error message from various error formats.
270
+ */
271
+ private extractErrorMessage(errObj: unknown): string {
272
+ if (!errObj || typeof errObj !== "object") {
273
+ return "Unknown Supabase API error";
274
+ }
275
+
276
+ const obj = errObj as Record<string, unknown>;
277
+
278
+ // Try common message fields
279
+ for (const field of ["message", "error", "msg", "description"]) {
280
+ if (typeof obj[field] === "string" && obj[field]) {
281
+ return obj[field] as string;
282
+ }
283
+ }
284
+
285
+ // If error is nested, try to extract from it
286
+ if (obj.error && typeof obj.error === "object") {
287
+ return this.extractErrorMessage(obj.error);
288
+ }
289
+
290
+ return "Unknown Supabase API error";
291
+ }
292
+
293
+ /**
294
+ * Extract a field from error object, trying multiple possible field names.
295
+ */
296
+ private extractField(
297
+ errObj: unknown,
298
+ fieldNames: string[]
299
+ ): string | undefined {
300
+ if (!errObj || typeof errObj !== "object") return undefined;
301
+
302
+ const obj = errObj as Record<string, unknown>;
303
+
304
+ for (const field of fieldNames) {
305
+ if (typeof obj[field] === "string" && obj[field]) {
306
+ return obj[field] as string;
307
+ }
308
+ }
309
+
310
+ return undefined;
311
+ }
312
+
313
+ /**
314
+ * Create a PostgreSQL-compatible error object.
315
+ */
316
+ private createPgError(opts: {
317
+ message: string;
318
+ code?: string;
319
+ detail?: string;
320
+ hint?: string;
321
+ httpStatus?: number;
322
+ supabaseErrorCode?: string;
323
+ }): PgCompatibleError {
324
+ const err = new Error(opts.message) as PgCompatibleError;
325
+
326
+ if (opts.code) err.code = opts.code;
327
+ if (opts.detail) err.detail = opts.detail;
328
+ if (opts.hint) err.hint = opts.hint;
329
+ if (opts.httpStatus) err.httpStatus = opts.httpStatus;
330
+ if (opts.supabaseErrorCode) err.supabaseErrorCode = opts.supabaseErrorCode;
331
+
332
+ return err;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Resolve Supabase configuration from options and environment variables.
338
+ */
339
+ export function resolveSupabaseConfig(opts: {
340
+ accessToken?: string;
341
+ projectRef?: string;
342
+ }): SupabaseConfig {
343
+ const accessToken =
344
+ opts.accessToken?.trim() ||
345
+ process.env.SUPABASE_ACCESS_TOKEN?.trim() ||
346
+ "";
347
+
348
+ const projectRef =
349
+ opts.projectRef?.trim() || process.env.SUPABASE_PROJECT_REF?.trim() || "";
350
+
351
+ if (!accessToken) {
352
+ throw new Error(
353
+ "Supabase access token is required.\n" +
354
+ "Provide it via --supabase-access-token or SUPABASE_ACCESS_TOKEN environment variable.\n" +
355
+ "Generate a token at: https://supabase.com/dashboard/account/tokens"
356
+ );
357
+ }
358
+
359
+ if (!projectRef) {
360
+ throw new Error(
361
+ "Supabase project reference is required.\n" +
362
+ "Provide it via --supabase-project-ref or SUPABASE_PROJECT_REF environment variable.\n" +
363
+ "Find your project ref in the Supabase dashboard URL: https://supabase.com/dashboard/project/<ref>"
364
+ );
365
+ }
366
+
367
+ return { accessToken, projectRef };
368
+ }
369
+
370
+ /**
371
+ * Extract project reference from a Supabase database URL.
372
+ * Supabase database URLs typically look like:
373
+ * - Direct: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
374
+ * - Pooler (modern): postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
375
+ * - Pooler (legacy): postgresql://postgres:[PASSWORD]@[PROJECT_REF].pooler.supabase.com:6543/postgres
376
+ *
377
+ * @param dbUrl PostgreSQL connection URL
378
+ * @returns Project reference if found, undefined otherwise
379
+ */
380
+ export function extractProjectRefFromUrl(dbUrl: string): string | undefined {
381
+ try {
382
+ const url = new URL(dbUrl);
383
+ const host = url.hostname;
384
+
385
+ // Match db.<ref>.supabase.co or <ref>.supabase.co patterns (direct connection)
386
+ const match = host.match(/^(?:db\.)?([^.]+)\.supabase\.co$/i);
387
+ if (match && match[1]) {
388
+ return match[1];
389
+ }
390
+
391
+ // Modern pooler URLs: project ref is in the username as postgres.<ref>
392
+ // Example: postgresql://postgres.abcdefghij:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres
393
+ if (host.includes("pooler.supabase.com")) {
394
+ const username = url.username;
395
+ const userMatch = username.match(/^postgres\.([a-z0-9]+)$/i);
396
+ if (userMatch && userMatch[1]) {
397
+ return userMatch[1];
398
+ }
399
+ }
400
+
401
+ // Legacy pooler URLs: <project-ref>.pooler.supabase.com (fallback)
402
+ const poolerMatch = host.match(/^([a-z0-9]+)\.pooler\.supabase\.com$/i);
403
+ if (poolerMatch && poolerMatch[1] && !poolerMatch[1].startsWith("aws-")) {
404
+ return poolerMatch[1];
405
+ }
406
+
407
+ return undefined;
408
+ } catch {
409
+ return undefined;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Apply init plan steps via Supabase Management API.
415
+ * Mirrors the behavior of applyInitPlan() in init.ts but uses Supabase API.
416
+ */
417
+ export async function applyInitPlanViaSupabase(params: {
418
+ client: SupabaseClient;
419
+ plan: {
420
+ monitoringUser: string;
421
+ database: string;
422
+ steps: Array<{
423
+ name: string;
424
+ sql: string;
425
+ params?: unknown[];
426
+ optional?: boolean;
427
+ }>;
428
+ };
429
+ verbose?: boolean;
430
+ }): Promise<{ applied: string[]; skippedOptional: string[] }> {
431
+ const applied: string[] = [];
432
+ const skippedOptional: string[] = [];
433
+
434
+ // Helper to execute a step (each step is wrapped in BEGIN/COMMIT)
435
+ const executeStep = async (step: {
436
+ name: string;
437
+ sql: string;
438
+ optional?: boolean;
439
+ }): Promise<void> => {
440
+ // Wrap in explicit transaction for atomic execution.
441
+ // Note: Supabase API uses pooled connections, so if the transaction fails,
442
+ // PostgreSQL automatically rolls it back - no separate ROLLBACK needed.
443
+ const wrappedSql = `BEGIN;\n${step.sql}\nCOMMIT;`;
444
+ await params.client.query(wrappedSql, false);
445
+ };
446
+
447
+ // Apply non-optional steps first
448
+ for (const step of params.plan.steps.filter((s) => !s.optional)) {
449
+ try {
450
+ if (params.verbose) {
451
+ console.log(`Executing step: ${step.name}`);
452
+ }
453
+ await executeStep(step);
454
+ applied.push(step.name);
455
+ } catch (e) {
456
+ const msg = e instanceof Error ? e.message : String(e);
457
+ const errAny = e as PgCompatibleError;
458
+ const wrapped: PgCompatibleError = new Error(
459
+ `Failed at step "${step.name}": ${msg}`
460
+ ) as PgCompatibleError;
461
+
462
+ // Preserve PostgreSQL error fields for consistent error handling
463
+ const pgErrorFields = [
464
+ "code",
465
+ "detail",
466
+ "hint",
467
+ "position",
468
+ "internalPosition",
469
+ "internalQuery",
470
+ "where",
471
+ "schema",
472
+ "table",
473
+ "column",
474
+ "dataType",
475
+ "constraint",
476
+ "file",
477
+ "line",
478
+ "routine",
479
+ "httpStatus",
480
+ "supabaseErrorCode",
481
+ ] as const;
482
+
483
+ for (const field of pgErrorFields) {
484
+ if (errAny[field] !== undefined) {
485
+ (wrapped as unknown as Record<string, unknown>)[field] = errAny[field];
486
+ }
487
+ }
488
+
489
+ if (e instanceof Error && e.stack) {
490
+ wrapped.stack = e.stack;
491
+ }
492
+
493
+ throw wrapped;
494
+ }
495
+ }
496
+
497
+ // Apply optional steps (failures don't abort)
498
+ for (const step of params.plan.steps.filter((s) => s.optional)) {
499
+ try {
500
+ if (params.verbose) {
501
+ console.log(`Executing optional step: ${step.name}`);
502
+ }
503
+ await executeStep(step);
504
+ applied.push(step.name);
505
+ } catch {
506
+ skippedOptional.push(step.name);
507
+ // best-effort: ignore errors for optional steps
508
+ }
509
+ }
510
+
511
+ return { applied, skippedOptional };
512
+ }
513
+
514
+ /**
515
+ * Verify init setup via Supabase Management API.
516
+ * Mirrors the behavior of verifyInitSetup() in init.ts but uses Supabase API.
517
+ *
518
+ * @param params.client - Supabase client for API calls
519
+ * @param params.database - Database name to verify
520
+ * @param params.monitoringUser - Role name to check permissions for
521
+ * @param params.includeOptionalPermissions - Whether to check optional permissions
522
+ * @returns Object with ok status and arrays of missing required/optional items
523
+ */
524
+ export async function verifyInitSetupViaSupabase(params: {
525
+ client: SupabaseClient;
526
+ database: string;
527
+ monitoringUser: string;
528
+ includeOptionalPermissions: boolean;
529
+ }): Promise<{
530
+ ok: boolean;
531
+ missingRequired: string[];
532
+ missingOptional: string[];
533
+ }> {
534
+ const missingRequired: string[] = [];
535
+ const missingOptional: string[] = [];
536
+
537
+ const role = params.monitoringUser;
538
+ const db = params.database;
539
+
540
+ // Validate role name to prevent SQL injection
541
+ if (!isValidIdentifier(role)) {
542
+ throw new Error(`Invalid monitoring user name: "${role}". Must be a valid PostgreSQL identifier (letters, digits, underscores, max 63 chars, starting with letter or underscore).`);
543
+ }
544
+
545
+ // Check if role exists
546
+ const roleRes = await params.client.query(
547
+ `SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
548
+ true
549
+ );
550
+ const roleExists = roleRes.rowCount > 0;
551
+
552
+ if (!roleExists) {
553
+ missingRequired.push(`role "${role}" does not exist`);
554
+ return { ok: false, missingRequired, missingOptional };
555
+ }
556
+
557
+ // Check CONNECT privilege
558
+ const connectRes = await params.client.query(
559
+ `SELECT has_database_privilege('${escapeLiteral(role)}', '${escapeLiteral(db)}', 'CONNECT') as ok`,
560
+ true
561
+ );
562
+ if (!connectRes.rows?.[0]?.ok) {
563
+ missingRequired.push(`CONNECT on database "${db}"`);
564
+ }
565
+
566
+ // Check pg_monitor membership
567
+ const pgMonitorRes = await params.client.query(
568
+ `SELECT pg_has_role('${escapeLiteral(role)}', 'pg_monitor', 'member') as ok`,
569
+ true
570
+ );
571
+ if (!pgMonitorRes.rows?.[0]?.ok) {
572
+ missingRequired.push("membership in role pg_monitor");
573
+ }
574
+
575
+ // Check SELECT on pg_index
576
+ const pgIndexRes = await params.client.query(
577
+ `SELECT has_table_privilege('${escapeLiteral(role)}', 'pg_catalog.pg_index', 'SELECT') as ok`,
578
+ true
579
+ );
580
+ if (!pgIndexRes.rows?.[0]?.ok) {
581
+ missingRequired.push("SELECT on pg_catalog.pg_index");
582
+ }
583
+
584
+ // Check postgres_ai schema exists and has USAGE privilege
585
+ // First check if schema exists to avoid has_schema_privilege throwing error
586
+ const schemaExistsRes = await params.client.query(
587
+ "SELECT nspname FROM pg_namespace WHERE nspname = 'postgres_ai'",
588
+ true
589
+ );
590
+ if (schemaExistsRes.rowCount === 0) {
591
+ missingRequired.push("schema postgres_ai exists");
592
+ } else {
593
+ const schemaPrivRes = await params.client.query(
594
+ `SELECT has_schema_privilege('${escapeLiteral(role)}', 'postgres_ai', 'USAGE') as ok`,
595
+ true
596
+ );
597
+ if (!schemaPrivRes.rows?.[0]?.ok) {
598
+ missingRequired.push("USAGE on schema postgres_ai");
599
+ }
600
+ }
601
+
602
+ // Check pg_statistic view
603
+ const viewExistsRes = await params.client.query(
604
+ "SELECT to_regclass('postgres_ai.pg_statistic') IS NOT NULL as ok",
605
+ true
606
+ );
607
+ if (!viewExistsRes.rows?.[0]?.ok) {
608
+ missingRequired.push("view postgres_ai.pg_statistic exists");
609
+ } else {
610
+ const viewPrivRes = await params.client.query(
611
+ `SELECT has_table_privilege('${escapeLiteral(role)}', 'postgres_ai.pg_statistic', 'SELECT') as ok`,
612
+ true
613
+ );
614
+ if (!viewPrivRes.rows?.[0]?.ok) {
615
+ missingRequired.push("SELECT on view postgres_ai.pg_statistic");
616
+ }
617
+ }
618
+
619
+ // Check USAGE on public schema (check existence first to avoid has_schema_privilege throwing)
620
+ const publicSchemaExistsRes = await params.client.query(
621
+ "SELECT nspname FROM pg_namespace WHERE nspname = 'public'",
622
+ true
623
+ );
624
+ if (publicSchemaExistsRes.rowCount === 0) {
625
+ missingRequired.push("schema public exists");
626
+ } else {
627
+ const schemaUsageRes = await params.client.query(
628
+ `SELECT has_schema_privilege('${escapeLiteral(role)}', 'public', 'USAGE') as ok`,
629
+ true
630
+ );
631
+ if (!schemaUsageRes.rows?.[0]?.ok) {
632
+ missingRequired.push("USAGE on schema public");
633
+ }
634
+ }
635
+
636
+ // Check search_path
637
+ const rolcfgRes = await params.client.query(
638
+ `SELECT rolconfig FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
639
+ true
640
+ );
641
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig as string[] | null;
642
+ const spLine = Array.isArray(rolconfig)
643
+ ? rolconfig.find((v: string) => String(v).startsWith("search_path="))
644
+ : undefined;
645
+ if (typeof spLine !== "string" || !spLine) {
646
+ missingRequired.push("role search_path is set");
647
+ } else {
648
+ const sp = spLine.toLowerCase();
649
+ if (
650
+ !sp.includes("postgres_ai") ||
651
+ !sp.includes("public") ||
652
+ !sp.includes("pg_catalog")
653
+ ) {
654
+ missingRequired.push(
655
+ "role search_path includes postgres_ai, public and pg_catalog"
656
+ );
657
+ }
658
+ }
659
+
660
+ // Check helper functions - first verify they exist to avoid has_function_privilege errors
661
+ const explainFnExistsRes = await params.client.query(
662
+ "SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
663
+ true
664
+ );
665
+ if (explainFnExistsRes.rowCount === 0) {
666
+ missingRequired.push("function postgres_ai.explain_generic exists");
667
+ } else {
668
+ const explainFnRes = await params.client.query(
669
+ `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
670
+ true
671
+ );
672
+ if (!explainFnRes.rows?.[0]?.ok) {
673
+ missingRequired.push(
674
+ "EXECUTE on postgres_ai.explain_generic(text, text, text)"
675
+ );
676
+ }
677
+ }
678
+
679
+ const tableDescribeFnExistsRes = await params.client.query(
680
+ "SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
681
+ true
682
+ );
683
+ if (tableDescribeFnExistsRes.rowCount === 0) {
684
+ missingRequired.push("function postgres_ai.table_describe exists");
685
+ } else {
686
+ const tableDescribeFnRes = await params.client.query(
687
+ `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok`,
688
+ true
689
+ );
690
+ if (!tableDescribeFnRes.rows?.[0]?.ok) {
691
+ missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
692
+ }
693
+ }
694
+
695
+ // Optional permissions
696
+ if (params.includeOptionalPermissions) {
697
+ // RDS tools extension
698
+ const extRes = await params.client.query(
699
+ "SELECT 1 FROM pg_extension WHERE extname = 'rds_tools'",
700
+ true
701
+ );
702
+ if (extRes.rowCount === 0) {
703
+ missingOptional.push("extension rds_tools");
704
+ } else {
705
+ try {
706
+ const fnRes = await params.client.query(
707
+ `SELECT has_function_privilege('${escapeLiteral(role)}', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok`,
708
+ true
709
+ );
710
+ if (!fnRes.rows?.[0]?.ok) {
711
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
712
+ }
713
+ } catch {
714
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
715
+ }
716
+ }
717
+
718
+ // Self-managed extras (these are hardcoded constants, safe to use directly)
719
+ const optionalFns = [
720
+ "pg_catalog.pg_stat_file(text)",
721
+ "pg_catalog.pg_stat_file(text, boolean)",
722
+ "pg_catalog.pg_ls_dir(text)",
723
+ "pg_catalog.pg_ls_dir(text, boolean, boolean)",
724
+ ];
725
+ for (const fn of optionalFns) {
726
+ try {
727
+ const fnRes = await params.client.query(
728
+ `SELECT has_function_privilege('${escapeLiteral(role)}', '${fn}', 'EXECUTE') as ok`,
729
+ true
730
+ );
731
+ if (!fnRes.rows?.[0]?.ok) {
732
+ missingOptional.push(`EXECUTE on ${fn}`);
733
+ }
734
+ } catch {
735
+ // Function may not exist on this PostgreSQL version
736
+ missingOptional.push(`EXECUTE on ${fn}`);
737
+ }
738
+ }
739
+ }
740
+
741
+ return {
742
+ ok: missingRequired.length === 0,
743
+ missingRequired,
744
+ missingOptional,
745
+ };
746
+ }
747
+
748
+ /**
749
+ * Validate that a string is a valid PostgreSQL identifier.
750
+ * PostgreSQL identifiers can contain letters, digits, and underscores,
751
+ * must start with a letter or underscore, and are max 63 characters.
752
+ */
753
+ function isValidIdentifier(name: string): boolean {
754
+ return /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(name);
755
+ }
756
+
757
+ /**
758
+ * Escape a string literal for use in SQL.
759
+ * Handles null bytes and single quotes for safe SQL interpolation.
760
+ * Note: This is for dynamic query building where parameterized queries aren't possible.
761
+ */
762
+ function escapeLiteral(value: string): string {
763
+ // Reject null bytes which can cause string truncation
764
+ if (value.includes("\0")) {
765
+ throw new Error("SQL literal cannot contain null bytes");
766
+ }
767
+ // Escape single quotes by doubling them
768
+ return value.replace(/'/g, "''");
769
+ }