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.
- package/README.md +161 -61
- package/bin/postgres-ai.ts +1957 -404
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +29351 -1576
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +439 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup.ts +1396 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +512 -156
- package/lib/issues.ts +400 -191
- package/lib/mcp-server.ts +213 -90
- package/lib/metrics-embedded.ts +79 -0
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +769 -0
- package/lib/util.ts +61 -0
- package/package.json +20 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +16 -0
- package/sql/02.permissions.sql +37 -0
- package/sql/03.optional_rds.sql +6 -0
- package/sql/04.optional_self_managed.sql +8 -0
- package/sql/05.helpers.sql +439 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1117 -0
- package/test/init.integration.test.ts +500 -0
- package/test/init.test.ts +527 -0
- package/test/issues.cli.test.ts +314 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +988 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -61
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -359
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -269
- package/test/init.test.cjs +0 -69
package/lib/init.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { URL } from "url";
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import { URL, fileURLToPath } from "url";
|
|
3
|
+
import type { ConnectionOptions as TlsConnectionOptions } from "tls";
|
|
3
4
|
import type { Client as PgClient } from "pg";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
4
9
|
|
|
5
10
|
export type PgClientConfig = {
|
|
6
11
|
connectionString?: string;
|
|
@@ -9,14 +14,138 @@ export type PgClientConfig = {
|
|
|
9
14
|
user?: string;
|
|
10
15
|
password?: string;
|
|
11
16
|
database?: string;
|
|
12
|
-
ssl?:
|
|
17
|
+
ssl?: boolean | TlsConnectionOptions;
|
|
13
18
|
};
|
|
14
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Convert PostgreSQL sslmode to node-postgres ssl config.
|
|
22
|
+
*/
|
|
23
|
+
function sslModeToConfig(mode: string): boolean | TlsConnectionOptions {
|
|
24
|
+
if (mode.toLowerCase() === "disable") return false;
|
|
25
|
+
if (mode.toLowerCase() === "verify-full" || mode.toLowerCase() === "verify-ca") return true;
|
|
26
|
+
// For require/prefer/allow: encrypt without certificate verification
|
|
27
|
+
return { rejectUnauthorized: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Extract sslmode from a PostgreSQL connection URI. */
|
|
31
|
+
function extractSslModeFromUri(uri: string): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(uri).searchParams.get("sslmode") ?? undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return uri.match(/[?&]sslmode=([^&]+)/i)?.[1];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Remove sslmode parameter from a PostgreSQL connection URI. */
|
|
40
|
+
function stripSslModeFromUri(uri: string): string {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(uri);
|
|
43
|
+
u.searchParams.delete("sslmode");
|
|
44
|
+
return u.toString();
|
|
45
|
+
} catch {
|
|
46
|
+
// Fallback regex for malformed URIs
|
|
47
|
+
return uri
|
|
48
|
+
.replace(/[?&]sslmode=[^&]*/gi, "")
|
|
49
|
+
.replace(/\?&/, "?")
|
|
50
|
+
.replace(/\?$/, "");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
export type AdminConnection = {
|
|
16
55
|
clientConfig: PgClientConfig;
|
|
17
56
|
display: string;
|
|
57
|
+
/** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
|
|
58
|
+
sslFallbackEnabled?: boolean;
|
|
18
59
|
};
|
|
19
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Check if an error indicates SSL negotiation failed and fallback to non-SSL should be attempted.
|
|
63
|
+
* This mimics libpq's sslmode=prefer behavior.
|
|
64
|
+
*
|
|
65
|
+
* IMPORTANT: This should NOT match certificate errors (expired, invalid, self-signed)
|
|
66
|
+
* as those are real errors the user needs to fix, not negotiation failures.
|
|
67
|
+
*/
|
|
68
|
+
function isSslNegotiationError(err: unknown): boolean {
|
|
69
|
+
if (!err || typeof err !== "object") return false;
|
|
70
|
+
const e = err as any;
|
|
71
|
+
const msg = typeof e.message === "string" ? e.message.toLowerCase() : "";
|
|
72
|
+
const code = typeof e.code === "string" ? e.code : "";
|
|
73
|
+
|
|
74
|
+
// Specific patterns that indicate server doesn't support SSL (should fallback)
|
|
75
|
+
const fallbackPatterns = [
|
|
76
|
+
"the server does not support ssl",
|
|
77
|
+
"ssl off",
|
|
78
|
+
"server does not support ssl connections",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const pattern of fallbackPatterns) {
|
|
82
|
+
if (msg.includes(pattern)) return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// PostgreSQL error code 08P01 (protocol violation) during initial connection
|
|
86
|
+
// often indicates SSL negotiation mismatch, but only if the message suggests it
|
|
87
|
+
if (code === "08P01" && (msg.includes("ssl") || msg.includes("unsupported"))) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Connect to PostgreSQL with sslmode=prefer-like behavior.
|
|
96
|
+
* If sslFallbackEnabled is true, tries SSL first, then falls back to non-SSL on failure.
|
|
97
|
+
*/
|
|
98
|
+
export async function connectWithSslFallback(
|
|
99
|
+
ClientClass: new (config: PgClientConfig) => PgClient,
|
|
100
|
+
adminConn: AdminConnection,
|
|
101
|
+
verbose?: boolean
|
|
102
|
+
): Promise<{ client: PgClient; usedSsl: boolean }> {
|
|
103
|
+
const tryConnect = async (config: PgClientConfig): Promise<PgClient> => {
|
|
104
|
+
const client = new ClientClass(config);
|
|
105
|
+
await client.connect();
|
|
106
|
+
return client;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// If SSL was explicitly set or no SSL configured, just try once
|
|
110
|
+
if (!adminConn.sslFallbackEnabled) {
|
|
111
|
+
const client = await tryConnect(adminConn.clientConfig);
|
|
112
|
+
return { client, usedSsl: !!adminConn.clientConfig.ssl };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// sslmode=prefer behavior: try SSL first, fallback to non-SSL
|
|
116
|
+
try {
|
|
117
|
+
const client = await tryConnect(adminConn.clientConfig);
|
|
118
|
+
return { client, usedSsl: true };
|
|
119
|
+
} catch (sslErr) {
|
|
120
|
+
if (!isSslNegotiationError(sslErr)) {
|
|
121
|
+
// Not an SSL error, don't retry
|
|
122
|
+
throw sslErr;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (verbose) {
|
|
126
|
+
console.log("SSL connection failed, retrying without SSL...");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Retry without SSL
|
|
130
|
+
const noSslConfig: PgClientConfig = { ...adminConn.clientConfig, ssl: false };
|
|
131
|
+
try {
|
|
132
|
+
const client = await tryConnect(noSslConfig);
|
|
133
|
+
return { client, usedSsl: false };
|
|
134
|
+
} catch (noSslErr) {
|
|
135
|
+
// If non-SSL also fails, check if it's "SSL required" - throw that instead
|
|
136
|
+
if (isSslNegotiationError(noSslErr)) {
|
|
137
|
+
const msg = (noSslErr as any)?.message || "";
|
|
138
|
+
if (msg.toLowerCase().includes("ssl") && msg.toLowerCase().includes("required")) {
|
|
139
|
+
// Server requires SSL but SSL attempt failed - throw original SSL error
|
|
140
|
+
throw sslErr;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Throw the non-SSL error (it's more relevant since SSL attempt also failed)
|
|
144
|
+
throw noSslErr;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
20
149
|
export type InitStep = {
|
|
21
150
|
name: string;
|
|
22
151
|
sql: string;
|
|
@@ -30,11 +159,64 @@ export type InitPlan = {
|
|
|
30
159
|
steps: InitStep[];
|
|
31
160
|
};
|
|
32
161
|
|
|
162
|
+
function sqlDir(): string {
|
|
163
|
+
// Handle both development and production paths
|
|
164
|
+
// Development: lib/init.ts -> ../sql
|
|
165
|
+
// Production (bundled): dist/bin/postgres-ai.js -> ../sql (copied during build)
|
|
166
|
+
//
|
|
167
|
+
// IMPORTANT: Use import.meta.url instead of __dirname because bundlers (bun/esbuild)
|
|
168
|
+
// bake in __dirname at build time, while import.meta.url resolves at runtime.
|
|
169
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
170
|
+
const currentDir = path.dirname(currentFile);
|
|
171
|
+
|
|
172
|
+
const candidates = [
|
|
173
|
+
path.resolve(currentDir, "..", "sql"), // bundled: dist/bin -> dist/sql
|
|
174
|
+
path.resolve(currentDir, "..", "..", "sql"), // dev from lib: lib -> ../sql
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
for (const candidate of candidates) {
|
|
178
|
+
if (fs.existsSync(candidate)) {
|
|
179
|
+
return candidate;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`SQL directory not found. Searched: ${candidates.join(", ")}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function loadSqlTemplate(filename: string): string {
|
|
186
|
+
const p = path.join(sqlDir(), filename);
|
|
187
|
+
return fs.readFileSync(p, "utf8");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function applyTemplate(sql: string, vars: Record<string, string>): string {
|
|
191
|
+
return sql.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => {
|
|
192
|
+
const v = vars[key];
|
|
193
|
+
if (v === undefined) throw new Error(`Missing SQL template var: ${key}`);
|
|
194
|
+
return v;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
33
198
|
function quoteIdent(ident: string): string {
|
|
34
199
|
// Always quote. Escape embedded quotes by doubling.
|
|
200
|
+
if (ident.includes("\0")) {
|
|
201
|
+
throw new Error("Identifier cannot contain null bytes");
|
|
202
|
+
}
|
|
35
203
|
return `"${ident.replace(/"/g, "\"\"")}"`;
|
|
36
204
|
}
|
|
37
205
|
|
|
206
|
+
function quoteLiteral(value: string): string {
|
|
207
|
+
// Single-quote and escape embedded quotes by doubling.
|
|
208
|
+
// This is used where Postgres grammar requires a literal (e.g., CREATE/ALTER ROLE PASSWORD).
|
|
209
|
+
if (value.includes("\0")) {
|
|
210
|
+
throw new Error("Literal cannot contain null bytes");
|
|
211
|
+
}
|
|
212
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function redactPasswordsInSql(sql: string): string {
|
|
216
|
+
// Replace PASSWORD '<literal>' (handles doubled quotes inside).
|
|
217
|
+
return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
|
|
218
|
+
}
|
|
219
|
+
|
|
38
220
|
export function maskConnectionString(dbUrl: string): string {
|
|
39
221
|
// Hide password if present (postgresql://user:pass@host/db).
|
|
40
222
|
try {
|
|
@@ -98,6 +280,7 @@ function tokenizeConninfo(input: string): string[] {
|
|
|
98
280
|
export function parseLibpqConninfo(input: string): PgClientConfig {
|
|
99
281
|
const tokens = tokenizeConninfo(input);
|
|
100
282
|
const cfg: PgClientConfig = {};
|
|
283
|
+
let sslmode: string | undefined;
|
|
101
284
|
|
|
102
285
|
for (const t of tokens) {
|
|
103
286
|
const eq = t.indexOf("=");
|
|
@@ -126,12 +309,20 @@ export function parseLibpqConninfo(input: string): PgClientConfig {
|
|
|
126
309
|
case "database":
|
|
127
310
|
cfg.database = val;
|
|
128
311
|
break;
|
|
129
|
-
|
|
312
|
+
case "sslmode":
|
|
313
|
+
sslmode = val;
|
|
314
|
+
break;
|
|
315
|
+
// ignore everything else (options, application_name, etc.)
|
|
130
316
|
default:
|
|
131
317
|
break;
|
|
132
318
|
}
|
|
133
319
|
}
|
|
134
320
|
|
|
321
|
+
// Apply SSL configuration based on sslmode
|
|
322
|
+
if (sslmode) {
|
|
323
|
+
cfg.ssl = sslModeToConfig(sslmode);
|
|
324
|
+
}
|
|
325
|
+
|
|
135
326
|
return cfg;
|
|
136
327
|
}
|
|
137
328
|
|
|
@@ -158,8 +349,12 @@ export function resolveAdminConnection(opts: {
|
|
|
158
349
|
const conn = (opts.conn || "").trim();
|
|
159
350
|
const dbUrlFlag = (opts.dbUrlFlag || "").trim();
|
|
160
351
|
|
|
161
|
-
|
|
162
|
-
|
|
352
|
+
// Resolve explicit SSL setting from environment (undefined = auto-detect)
|
|
353
|
+
const explicitSsl = process.env.PGSSLMODE;
|
|
354
|
+
|
|
355
|
+
// NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
|
|
356
|
+
// We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
|
|
357
|
+
const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
|
|
163
358
|
|
|
164
359
|
if (conn && dbUrlFlag) {
|
|
165
360
|
throw new Error("Provide either positional connection string or --db-url, not both");
|
|
@@ -168,84 +363,94 @@ export function resolveAdminConnection(opts: {
|
|
|
168
363
|
if (conn || dbUrlFlag) {
|
|
169
364
|
const v = conn || dbUrlFlag;
|
|
170
365
|
if (isLikelyUri(v)) {
|
|
171
|
-
|
|
366
|
+
const urlSslMode = extractSslModeFromUri(v);
|
|
367
|
+
const effectiveSslMode = explicitSsl || urlSslMode;
|
|
368
|
+
// SSL priority: PGSSLMODE env > URL param > auto (sslmode=prefer behavior)
|
|
369
|
+
const sslConfig = effectiveSslMode
|
|
370
|
+
? sslModeToConfig(effectiveSslMode)
|
|
371
|
+
: { rejectUnauthorized: false }; // Default: try SSL (with fallback)
|
|
372
|
+
// Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
|
|
373
|
+
const shouldFallback = !effectiveSslMode ||
|
|
374
|
+
effectiveSslMode.toLowerCase() === "prefer" ||
|
|
375
|
+
effectiveSslMode.toLowerCase() === "allow";
|
|
376
|
+
// Strip sslmode from URI so pg uses our ssl config object instead
|
|
377
|
+
const cleanUri = stripSslModeFromUri(v);
|
|
378
|
+
return {
|
|
379
|
+
clientConfig: { connectionString: cleanUri, ssl: sslConfig },
|
|
380
|
+
display: maskConnectionString(v),
|
|
381
|
+
sslFallbackEnabled: shouldFallback,
|
|
382
|
+
};
|
|
172
383
|
}
|
|
173
384
|
// libpq conninfo (dbname=... host=...)
|
|
174
385
|
const cfg = parseLibpqConninfo(v);
|
|
175
386
|
if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
|
|
176
|
-
|
|
387
|
+
const cfgHadSsl = cfg.ssl !== undefined;
|
|
388
|
+
if (cfg.ssl === undefined) {
|
|
389
|
+
if (explicitSsl) cfg.ssl = sslModeToConfig(explicitSsl);
|
|
390
|
+
else cfg.ssl = { rejectUnauthorized: false }; // Default: try SSL (with fallback)
|
|
391
|
+
}
|
|
392
|
+
// Enable fallback for: no explicit mode OR explicit "prefer"/"allow"
|
|
393
|
+
const shouldFallback = (!explicitSsl && !cfgHadSsl) ||
|
|
394
|
+
(!!explicitSsl && (explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow"));
|
|
395
|
+
return {
|
|
396
|
+
clientConfig: cfg,
|
|
397
|
+
display: describePgConfig(cfg),
|
|
398
|
+
sslFallbackEnabled: shouldFallback,
|
|
399
|
+
};
|
|
177
400
|
}
|
|
178
401
|
|
|
179
|
-
if (!
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
402
|
+
if (!hasConnDetails) {
|
|
403
|
+
// Keep this message short: the CLI prints full help (including examples) on this error.
|
|
404
|
+
throw new Error("Connection is required.");
|
|
183
405
|
}
|
|
184
406
|
|
|
185
407
|
const cfg: PgClientConfig = {};
|
|
186
408
|
if (opts.host) cfg.host = opts.host;
|
|
187
|
-
if (opts.port !== undefined && opts.port !== "")
|
|
409
|
+
if (opts.port !== undefined && opts.port !== "") {
|
|
410
|
+
const p = Number(opts.port);
|
|
411
|
+
if (!Number.isFinite(p) || !Number.isInteger(p) || p <= 0 || p > 65535) {
|
|
412
|
+
throw new Error(`Invalid port value: ${String(opts.port)}`);
|
|
413
|
+
}
|
|
414
|
+
cfg.port = p;
|
|
415
|
+
}
|
|
188
416
|
if (opts.username) cfg.user = opts.username;
|
|
189
417
|
if (opts.dbname) cfg.database = opts.dbname;
|
|
190
418
|
if (opts.adminPassword) cfg.password = opts.adminPassword;
|
|
191
419
|
if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
|
|
192
|
-
|
|
420
|
+
if (explicitSsl) {
|
|
421
|
+
cfg.ssl = sslModeToConfig(explicitSsl);
|
|
422
|
+
// Enable fallback for explicit "prefer"/"allow"
|
|
423
|
+
const shouldFallback = explicitSsl.toLowerCase() === "prefer" || explicitSsl.toLowerCase() === "allow";
|
|
424
|
+
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: shouldFallback };
|
|
425
|
+
}
|
|
426
|
+
// Default: try SSL with fallback (sslmode=prefer behavior)
|
|
427
|
+
cfg.ssl = { rejectUnauthorized: false };
|
|
428
|
+
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
|
|
193
429
|
}
|
|
194
430
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// Mask input by overriding internal write method.
|
|
203
|
-
const anyRl = rl as any;
|
|
204
|
-
const out = process.stdout as NodeJS.WriteStream;
|
|
205
|
-
anyRl._writeToOutput = (str: string) => {
|
|
206
|
-
// Keep newlines and carriage returns; mask everything else.
|
|
207
|
-
if (str === "\n" || str === "\r\n") {
|
|
208
|
-
out.write(str);
|
|
209
|
-
} else {
|
|
210
|
-
out.write("*");
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
const answer = await new Promise<string>((resolve) => rl.question(prompt, resolve));
|
|
216
|
-
// Ensure we end the masked line cleanly.
|
|
217
|
-
process.stdout.write("\n");
|
|
218
|
-
return answer;
|
|
219
|
-
} finally {
|
|
220
|
-
rl.close();
|
|
431
|
+
function generateMonitoringPassword(): string {
|
|
432
|
+
// URL-safe and easy to copy/paste; 24 bytes => 32 base64url chars (no padding).
|
|
433
|
+
// Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
|
|
434
|
+
const password = randomBytes(24).toString("base64url");
|
|
435
|
+
if (password.length < 30) {
|
|
436
|
+
throw new Error("Password generation failed: unexpected output length");
|
|
221
437
|
}
|
|
438
|
+
return password;
|
|
222
439
|
}
|
|
223
440
|
|
|
224
441
|
export async function resolveMonitoringPassword(opts: {
|
|
225
442
|
passwordFlag?: string;
|
|
226
443
|
passwordEnv?: string;
|
|
227
|
-
prompt?: (prompt: string) => Promise<string>;
|
|
228
444
|
monitoringUser: string;
|
|
229
|
-
}): Promise<string> {
|
|
445
|
+
}): Promise<{ password: string; generated: boolean }> {
|
|
230
446
|
const fromFlag = (opts.passwordFlag || "").trim();
|
|
231
|
-
if (fromFlag) return fromFlag;
|
|
447
|
+
if (fromFlag) return { password: fromFlag, generated: false };
|
|
232
448
|
|
|
233
449
|
const fromEnv = (opts.passwordEnv || "").trim();
|
|
234
|
-
if (fromEnv) return fromEnv;
|
|
235
|
-
|
|
236
|
-
if (!process.stdin.isTTY) {
|
|
237
|
-
throw new Error(
|
|
238
|
-
"Monitoring user password is required in non-interactive mode (use --password or PGAI_MON_PASSWORD)"
|
|
239
|
-
);
|
|
240
|
-
}
|
|
450
|
+
if (fromEnv) return { password: fromEnv, generated: false };
|
|
241
451
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const pw = (await prompter(`Enter password for monitoring user ${opts.monitoringUser}: `)).trim();
|
|
245
|
-
if (pw) return pw;
|
|
246
|
-
// eslint-disable-next-line no-console
|
|
247
|
-
console.error("Password cannot be empty");
|
|
248
|
-
}
|
|
452
|
+
// Default: auto-generate (safer than prompting; works in non-interactive mode).
|
|
453
|
+
return { password: generateMonitoringPassword(), generated: true };
|
|
249
454
|
}
|
|
250
455
|
|
|
251
456
|
export async function buildInitPlan(params: {
|
|
@@ -253,106 +458,63 @@ export async function buildInitPlan(params: {
|
|
|
253
458
|
monitoringUser?: string;
|
|
254
459
|
monitoringPassword: string;
|
|
255
460
|
includeOptionalPermissions: boolean;
|
|
256
|
-
roleExists?: boolean;
|
|
257
461
|
}): Promise<InitPlan> {
|
|
258
|
-
|
|
462
|
+
// NOTE: kept async for API stability / potential future async template loading.
|
|
463
|
+
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
259
464
|
const database = params.database;
|
|
260
465
|
|
|
261
466
|
const qRole = quoteIdent(monitoringUser);
|
|
262
467
|
const qDb = quoteIdent(database);
|
|
468
|
+
const qPw = quoteLiteral(params.monitoringPassword);
|
|
469
|
+
const qRoleNameLit = quoteLiteral(monitoringUser);
|
|
263
470
|
|
|
264
471
|
const steps: InitStep[] = [];
|
|
265
472
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
sql: `create user ${qRole} with password $1;`,
|
|
271
|
-
params: [params.monitoringPassword],
|
|
272
|
-
});
|
|
273
|
-
} else if (params.roleExists === true) {
|
|
274
|
-
steps.push({
|
|
275
|
-
name: "update monitoring user password",
|
|
276
|
-
sql: `alter user ${qRole} with password $1;`,
|
|
277
|
-
params: [params.monitoringPassword],
|
|
278
|
-
});
|
|
279
|
-
} else {
|
|
280
|
-
// Unknown: caller will rebuild after probing role existence.
|
|
281
|
-
}
|
|
473
|
+
const vars: Record<string, string> = {
|
|
474
|
+
ROLE_IDENT: qRole,
|
|
475
|
+
DB_IDENT: qDb,
|
|
476
|
+
};
|
|
282
477
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
{
|
|
313
|
-
name: "grant select on public.pg_statistic",
|
|
314
|
-
sql: `grant select on public.pg_statistic to ${qRole};`,
|
|
315
|
-
},
|
|
316
|
-
{
|
|
317
|
-
name: "ensure access to public schema (for hardened clusters)",
|
|
318
|
-
sql: `grant usage on schema public to ${qRole};`,
|
|
319
|
-
},
|
|
320
|
-
{
|
|
321
|
-
name: "set monitoring user search_path",
|
|
322
|
-
sql: `alter user ${qRole} set search_path = "$user", public, pg_catalog;`,
|
|
323
|
-
}
|
|
324
|
-
);
|
|
478
|
+
// Role creation/update is done in one template file.
|
|
479
|
+
// Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
|
|
480
|
+
// We:
|
|
481
|
+
// - create role if missing (and handle duplicate_object in case another session created it concurrently),
|
|
482
|
+
// - then ALTER ROLE to ensure the password is set to the desired value.
|
|
483
|
+
const roleStmt = `do $$ begin
|
|
484
|
+
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
485
|
+
begin
|
|
486
|
+
create user ${qRole} with password ${qPw};
|
|
487
|
+
exception when duplicate_object then
|
|
488
|
+
null;
|
|
489
|
+
end;
|
|
490
|
+
end if;
|
|
491
|
+
alter user ${qRole} with password ${qPw};
|
|
492
|
+
end $$;`;
|
|
493
|
+
|
|
494
|
+
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
495
|
+
steps.push({ name: "01.role", sql: roleSql });
|
|
496
|
+
|
|
497
|
+
steps.push({
|
|
498
|
+
name: "02.permissions",
|
|
499
|
+
sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Helper functions (SECURITY DEFINER) for plan analysis and table info
|
|
503
|
+
steps.push({
|
|
504
|
+
name: "05.helpers",
|
|
505
|
+
sql: applyTemplate(loadSqlTemplate("05.helpers.sql"), vars),
|
|
506
|
+
});
|
|
325
507
|
|
|
326
508
|
if (params.includeOptionalPermissions) {
|
|
327
509
|
steps.push(
|
|
328
510
|
{
|
|
329
|
-
name: "
|
|
330
|
-
sql: "
|
|
511
|
+
name: "03.optional_rds",
|
|
512
|
+
sql: applyTemplate(loadSqlTemplate("03.optional_rds.sql"), vars),
|
|
331
513
|
optional: true,
|
|
332
514
|
},
|
|
333
515
|
{
|
|
334
|
-
name: "
|
|
335
|
-
sql:
|
|
336
|
-
optional: true,
|
|
337
|
-
},
|
|
338
|
-
{
|
|
339
|
-
name: "grant pg_stat_file(text) (optional)",
|
|
340
|
-
sql: `grant execute on function pg_catalog.pg_stat_file(text) to ${qRole};`,
|
|
341
|
-
optional: true,
|
|
342
|
-
},
|
|
343
|
-
{
|
|
344
|
-
name: "grant pg_stat_file(text, boolean) (optional)",
|
|
345
|
-
sql: `grant execute on function pg_catalog.pg_stat_file(text, boolean) to ${qRole};`,
|
|
346
|
-
optional: true,
|
|
347
|
-
},
|
|
348
|
-
{
|
|
349
|
-
name: "grant pg_ls_dir(text) (optional)",
|
|
350
|
-
sql: `grant execute on function pg_catalog.pg_ls_dir(text) to ${qRole};`,
|
|
351
|
-
optional: true,
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
name: "grant pg_ls_dir(text, boolean, boolean) (optional)",
|
|
355
|
-
sql: `grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to ${qRole};`,
|
|
516
|
+
name: "04.optional_self_managed",
|
|
517
|
+
sql: applyTemplate(loadSqlTemplate("04.optional_self_managed.sql"), vars),
|
|
356
518
|
optional: true,
|
|
357
519
|
}
|
|
358
520
|
);
|
|
@@ -369,28 +531,66 @@ export async function applyInitPlan(params: {
|
|
|
369
531
|
const applied: string[] = [];
|
|
370
532
|
const skippedOptional: string[] = [];
|
|
371
533
|
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
534
|
+
// Helper to wrap a step execution in begin/commit
|
|
535
|
+
const executeStep = async (step: InitStep): Promise<void> => {
|
|
536
|
+
await params.client.query("begin;");
|
|
537
|
+
try {
|
|
538
|
+
await params.client.query(step.sql, step.params as any);
|
|
539
|
+
await params.client.query("commit;");
|
|
540
|
+
} catch (e) {
|
|
541
|
+
// Rollback errors should never mask the original failure.
|
|
376
542
|
try {
|
|
377
|
-
await params.client.query(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
381
|
-
throw new Error(`Failed at step "${step.name}": ${msg}`);
|
|
543
|
+
await params.client.query("rollback;");
|
|
544
|
+
} catch {
|
|
545
|
+
// ignore
|
|
382
546
|
}
|
|
547
|
+
throw e;
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// Apply non-optional steps, each in its own transaction
|
|
552
|
+
for (const step of params.plan.steps.filter((s) => !s.optional)) {
|
|
553
|
+
try {
|
|
554
|
+
await executeStep(step);
|
|
555
|
+
applied.push(step.name);
|
|
556
|
+
} catch (e) {
|
|
557
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
558
|
+
const errAny = e as any;
|
|
559
|
+
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
|
|
560
|
+
// Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
|
|
561
|
+
const pgErrorFields = [
|
|
562
|
+
"code",
|
|
563
|
+
"detail",
|
|
564
|
+
"hint",
|
|
565
|
+
"position",
|
|
566
|
+
"internalPosition",
|
|
567
|
+
"internalQuery",
|
|
568
|
+
"where",
|
|
569
|
+
"schema",
|
|
570
|
+
"table",
|
|
571
|
+
"column",
|
|
572
|
+
"dataType",
|
|
573
|
+
"constraint",
|
|
574
|
+
"file",
|
|
575
|
+
"line",
|
|
576
|
+
"routine",
|
|
577
|
+
] as const;
|
|
578
|
+
if (errAny && typeof errAny === "object") {
|
|
579
|
+
for (const field of pgErrorFields) {
|
|
580
|
+
if (errAny[field] !== undefined) wrapped[field] = errAny[field];
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (e instanceof Error && e.stack) {
|
|
584
|
+
wrapped.stack = e.stack;
|
|
585
|
+
}
|
|
586
|
+
throw wrapped;
|
|
383
587
|
}
|
|
384
|
-
await params.client.query("commit;");
|
|
385
|
-
} catch (e) {
|
|
386
|
-
await params.client.query("rollback;");
|
|
387
|
-
throw e;
|
|
388
588
|
}
|
|
389
589
|
|
|
390
|
-
// Apply optional steps
|
|
590
|
+
// Apply optional steps, each in its own transaction (failure doesn't abort)
|
|
391
591
|
for (const step of params.plan.steps.filter((s) => s.optional)) {
|
|
392
592
|
try {
|
|
393
|
-
await
|
|
593
|
+
await executeStep(step);
|
|
394
594
|
applied.push(step.name);
|
|
395
595
|
} catch {
|
|
396
596
|
skippedOptional.push(step.name);
|
|
@@ -401,4 +601,160 @@ export async function applyInitPlan(params: {
|
|
|
401
601
|
return { applied, skippedOptional };
|
|
402
602
|
}
|
|
403
603
|
|
|
604
|
+
export type VerifyInitResult = {
|
|
605
|
+
ok: boolean;
|
|
606
|
+
missingRequired: string[];
|
|
607
|
+
missingOptional: string[];
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
export async function verifyInitSetup(params: {
|
|
611
|
+
client: PgClient;
|
|
612
|
+
database: string;
|
|
613
|
+
monitoringUser: string;
|
|
614
|
+
includeOptionalPermissions: boolean;
|
|
615
|
+
}): Promise<VerifyInitResult> {
|
|
616
|
+
// Use a repeatable-read snapshot so all checks see a consistent view.
|
|
617
|
+
await params.client.query("begin isolation level repeatable read;");
|
|
618
|
+
try {
|
|
619
|
+
const missingRequired: string[] = [];
|
|
620
|
+
const missingOptional: string[] = [];
|
|
621
|
+
|
|
622
|
+
const role = params.monitoringUser;
|
|
623
|
+
const db = params.database;
|
|
624
|
+
|
|
625
|
+
const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
626
|
+
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
627
|
+
if (!roleExists) {
|
|
628
|
+
missingRequired.push(`role "${role}" does not exist`);
|
|
629
|
+
// If role is missing, other checks will error or be meaningless.
|
|
630
|
+
return { ok: false, missingRequired, missingOptional };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const connectRes = await params.client.query(
|
|
634
|
+
"select has_database_privilege($1, $2, 'CONNECT') as ok",
|
|
635
|
+
[role, db]
|
|
636
|
+
);
|
|
637
|
+
if (!connectRes.rows?.[0]?.ok) {
|
|
638
|
+
missingRequired.push(`CONNECT on database "${db}"`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const pgMonitorRes = await params.client.query(
|
|
642
|
+
"select pg_has_role($1, 'pg_monitor', 'member') as ok",
|
|
643
|
+
[role]
|
|
644
|
+
);
|
|
645
|
+
if (!pgMonitorRes.rows?.[0]?.ok) {
|
|
646
|
+
missingRequired.push("membership in role pg_monitor");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const pgIndexRes = await params.client.query(
|
|
650
|
+
"select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
|
|
651
|
+
[role]
|
|
652
|
+
);
|
|
653
|
+
if (!pgIndexRes.rows?.[0]?.ok) {
|
|
654
|
+
missingRequired.push("SELECT on pg_catalog.pg_index");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Check postgres_ai schema exists and is usable
|
|
658
|
+
const schemaExistsRes = await params.client.query(
|
|
659
|
+
"select has_schema_privilege($1, 'postgres_ai', 'USAGE') as ok",
|
|
660
|
+
[role]
|
|
661
|
+
);
|
|
662
|
+
if (!schemaExistsRes.rows?.[0]?.ok) {
|
|
663
|
+
missingRequired.push("USAGE on schema postgres_ai");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const viewExistsRes = await params.client.query("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
|
|
667
|
+
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
668
|
+
missingRequired.push("view postgres_ai.pg_statistic exists");
|
|
669
|
+
} else {
|
|
670
|
+
const viewPrivRes = await params.client.query(
|
|
671
|
+
"select has_table_privilege($1, 'postgres_ai.pg_statistic', 'SELECT') as ok",
|
|
672
|
+
[role]
|
|
673
|
+
);
|
|
674
|
+
if (!viewPrivRes.rows?.[0]?.ok) {
|
|
675
|
+
missingRequired.push("SELECT on view postgres_ai.pg_statistic");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const schemaUsageRes = await params.client.query(
|
|
680
|
+
"select has_schema_privilege($1, 'public', 'USAGE') as ok",
|
|
681
|
+
[role]
|
|
682
|
+
);
|
|
683
|
+
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
684
|
+
missingRequired.push("USAGE on schema public");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
688
|
+
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
689
|
+
const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
|
|
690
|
+
if (typeof spLine !== "string" || !spLine) {
|
|
691
|
+
missingRequired.push("role search_path is set");
|
|
692
|
+
} else {
|
|
693
|
+
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
|
|
694
|
+
const sp = spLine.toLowerCase();
|
|
695
|
+
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
696
|
+
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Check for helper functions
|
|
701
|
+
const explainFnRes = await params.client.query(
|
|
702
|
+
"select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
|
|
703
|
+
[role]
|
|
704
|
+
);
|
|
705
|
+
if (!explainFnRes.rows?.[0]?.ok) {
|
|
706
|
+
missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const tableDescribeFnRes = await params.client.query(
|
|
710
|
+
"select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
|
|
711
|
+
[role]
|
|
712
|
+
);
|
|
713
|
+
if (!tableDescribeFnRes.rows?.[0]?.ok) {
|
|
714
|
+
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (params.includeOptionalPermissions) {
|
|
718
|
+
// Optional RDS/Aurora extras
|
|
719
|
+
{
|
|
720
|
+
const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
|
|
721
|
+
if ((extRes.rowCount ?? 0) === 0) {
|
|
722
|
+
missingOptional.push("extension rds_tools");
|
|
723
|
+
} else {
|
|
724
|
+
const fnRes = await params.client.query(
|
|
725
|
+
"select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
|
|
726
|
+
[role]
|
|
727
|
+
);
|
|
728
|
+
if (!fnRes.rows?.[0]?.ok) {
|
|
729
|
+
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Optional self-managed extras
|
|
735
|
+
const optionalFns = [
|
|
736
|
+
"pg_catalog.pg_stat_file(text)",
|
|
737
|
+
"pg_catalog.pg_stat_file(text, boolean)",
|
|
738
|
+
"pg_catalog.pg_ls_dir(text)",
|
|
739
|
+
"pg_catalog.pg_ls_dir(text, boolean, boolean)",
|
|
740
|
+
];
|
|
741
|
+
for (const fn of optionalFns) {
|
|
742
|
+
const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
|
|
743
|
+
if (!fnRes.rows?.[0]?.ok) {
|
|
744
|
+
missingOptional.push(`EXECUTE on ${fn}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return { ok: missingRequired.length === 0, missingRequired, missingOptional };
|
|
750
|
+
} finally {
|
|
751
|
+
// Read-only: rollback to release snapshot; do not mask original errors.
|
|
752
|
+
try {
|
|
753
|
+
await params.client.query("rollback;");
|
|
754
|
+
} catch {
|
|
755
|
+
// ignore
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
404
760
|
|