postgresai 0.14.0-beta.1 → 0.14.0-beta.11
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 +104 -61
- package/bin/postgres-ai.ts +1304 -417
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +28560 -1778
- 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 +1330 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +415 -220
- 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/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 +8 -7
- package/sql/02.permissions.sql +9 -5
- 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 +891 -0
- package/test/init.integration.test.ts +499 -0
- package/test/init.test.ts +417 -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/test-utils.ts +122 -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 -75
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -483
- 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 -368
- package/test/init.test.cjs +0 -154
package/lib/init.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import * as readline from "readline";
|
|
2
1
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { URL } from "url";
|
|
2
|
+
import { URL, fileURLToPath } from "url";
|
|
3
|
+
import type { ConnectionOptions as TlsConnectionOptions } from "tls";
|
|
4
4
|
import type { Client as PgClient } from "pg";
|
|
5
5
|
import * as fs from "fs";
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
|
|
8
|
+
export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
9
|
+
|
|
8
10
|
export type PgClientConfig = {
|
|
9
11
|
connectionString?: string;
|
|
10
12
|
host?: string;
|
|
@@ -12,14 +14,138 @@ export type PgClientConfig = {
|
|
|
12
14
|
user?: string;
|
|
13
15
|
password?: string;
|
|
14
16
|
database?: string;
|
|
15
|
-
ssl?:
|
|
17
|
+
ssl?: boolean | TlsConnectionOptions;
|
|
16
18
|
};
|
|
17
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
|
+
|
|
18
54
|
export type AdminConnection = {
|
|
19
55
|
clientConfig: PgClientConfig;
|
|
20
56
|
display: string;
|
|
57
|
+
/** True if SSL fallback is enabled (try SSL first, fall back to non-SSL on failure). */
|
|
58
|
+
sslFallbackEnabled?: boolean;
|
|
21
59
|
};
|
|
22
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
|
+
|
|
23
149
|
export type InitStep = {
|
|
24
150
|
name: string;
|
|
25
151
|
sql: string;
|
|
@@ -33,13 +159,27 @@ export type InitPlan = {
|
|
|
33
159
|
steps: InitStep[];
|
|
34
160
|
};
|
|
35
161
|
|
|
36
|
-
function packageRootDirFromCompiled(): string {
|
|
37
|
-
// dist/lib/init.js -> <pkg>/dist/lib ; package root is ../..
|
|
38
|
-
return path.resolve(__dirname, "..", "..");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
162
|
function sqlDir(): string {
|
|
42
|
-
|
|
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(", ")}`);
|
|
43
183
|
}
|
|
44
184
|
|
|
45
185
|
function loadSqlTemplate(filename: string): string {
|
|
@@ -57,15 +197,26 @@ function applyTemplate(sql: string, vars: Record<string, string>): string {
|
|
|
57
197
|
|
|
58
198
|
function quoteIdent(ident: string): string {
|
|
59
199
|
// Always quote. Escape embedded quotes by doubling.
|
|
200
|
+
if (ident.includes("\0")) {
|
|
201
|
+
throw new Error("Identifier cannot contain null bytes");
|
|
202
|
+
}
|
|
60
203
|
return `"${ident.replace(/"/g, "\"\"")}"`;
|
|
61
204
|
}
|
|
62
205
|
|
|
63
206
|
function quoteLiteral(value: string): string {
|
|
64
207
|
// Single-quote and escape embedded quotes by doubling.
|
|
65
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
|
+
}
|
|
66
212
|
return `'${value.replace(/'/g, "''")}'`;
|
|
67
213
|
}
|
|
68
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
|
+
|
|
69
220
|
export function maskConnectionString(dbUrl: string): string {
|
|
70
221
|
// Hide password if present (postgresql://user:pass@host/db).
|
|
71
222
|
try {
|
|
@@ -129,6 +280,7 @@ function tokenizeConninfo(input: string): string[] {
|
|
|
129
280
|
export function parseLibpqConninfo(input: string): PgClientConfig {
|
|
130
281
|
const tokens = tokenizeConninfo(input);
|
|
131
282
|
const cfg: PgClientConfig = {};
|
|
283
|
+
let sslmode: string | undefined;
|
|
132
284
|
|
|
133
285
|
for (const t of tokens) {
|
|
134
286
|
const eq = t.indexOf("=");
|
|
@@ -157,12 +309,20 @@ export function parseLibpqConninfo(input: string): PgClientConfig {
|
|
|
157
309
|
case "database":
|
|
158
310
|
cfg.database = val;
|
|
159
311
|
break;
|
|
160
|
-
|
|
312
|
+
case "sslmode":
|
|
313
|
+
sslmode = val;
|
|
314
|
+
break;
|
|
315
|
+
// ignore everything else (options, application_name, etc.)
|
|
161
316
|
default:
|
|
162
317
|
break;
|
|
163
318
|
}
|
|
164
319
|
}
|
|
165
320
|
|
|
321
|
+
// Apply SSL configuration based on sslmode
|
|
322
|
+
if (sslmode) {
|
|
323
|
+
cfg.ssl = sslModeToConfig(sslmode);
|
|
324
|
+
}
|
|
325
|
+
|
|
166
326
|
return cfg;
|
|
167
327
|
}
|
|
168
328
|
|
|
@@ -189,6 +349,9 @@ export function resolveAdminConnection(opts: {
|
|
|
189
349
|
const conn = (opts.conn || "").trim();
|
|
190
350
|
const dbUrlFlag = (opts.dbUrlFlag || "").trim();
|
|
191
351
|
|
|
352
|
+
// Resolve explicit SSL setting from environment (undefined = auto-detect)
|
|
353
|
+
const explicitSsl = process.env.PGSSLMODE;
|
|
354
|
+
|
|
192
355
|
// NOTE: passwords alone (PGPASSWORD / --admin-password) do NOT constitute a connection.
|
|
193
356
|
// We require at least some connection addressing (host/port/user/db) if no positional arg / --db-url is provided.
|
|
194
357
|
const hasConnDetails = !!(opts.host || opts.port || opts.username || opts.dbname);
|
|
@@ -200,28 +363,45 @@ export function resolveAdminConnection(opts: {
|
|
|
200
363
|
if (conn || dbUrlFlag) {
|
|
201
364
|
const v = conn || dbUrlFlag;
|
|
202
365
|
if (isLikelyUri(v)) {
|
|
203
|
-
|
|
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
|
+
};
|
|
204
383
|
}
|
|
205
384
|
// libpq conninfo (dbname=... host=...)
|
|
206
385
|
const cfg = parseLibpqConninfo(v);
|
|
207
386
|
if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
|
|
208
|
-
|
|
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
|
+
};
|
|
209
400
|
}
|
|
210
401
|
|
|
211
402
|
if (!hasConnDetails) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
"Connection is required.",
|
|
215
|
-
"",
|
|
216
|
-
"Examples:",
|
|
217
|
-
" postgresai init postgresql://admin@host:5432/dbname",
|
|
218
|
-
" postgresai init \"dbname=dbname host=host user=admin\"",
|
|
219
|
-
" postgresai init -h host -p 5432 -U admin -d dbname",
|
|
220
|
-
"",
|
|
221
|
-
"Admin password:",
|
|
222
|
-
" --admin-password <password> (or set PGPASSWORD)",
|
|
223
|
-
].join("\n")
|
|
224
|
-
);
|
|
403
|
+
// Keep this message short: the CLI prints full help (including examples) on this error.
|
|
404
|
+
throw new Error("Connection is required.");
|
|
225
405
|
}
|
|
226
406
|
|
|
227
407
|
const cfg: PgClientConfig = {};
|
|
@@ -237,84 +417,30 @@ export function resolveAdminConnection(opts: {
|
|
|
237
417
|
if (opts.dbname) cfg.database = opts.dbname;
|
|
238
418
|
if (opts.adminPassword) cfg.password = opts.adminPassword;
|
|
239
419
|
if (opts.envPassword && !cfg.password) cfg.password = opts.envPassword;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// - prompt text is visible
|
|
246
|
-
// - only user input is masked
|
|
247
|
-
// - we don't rely on non-public readline internals
|
|
248
|
-
if (!process.stdin.isTTY) {
|
|
249
|
-
throw new Error("Cannot prompt for password in non-interactive mode");
|
|
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 };
|
|
250
425
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
stdout.write(prompt);
|
|
256
|
-
|
|
257
|
-
return await new Promise<string>((resolve, reject) => {
|
|
258
|
-
let value = "";
|
|
259
|
-
|
|
260
|
-
const cleanup = () => {
|
|
261
|
-
try {
|
|
262
|
-
stdin.setRawMode(false);
|
|
263
|
-
} catch {
|
|
264
|
-
// ignore
|
|
265
|
-
}
|
|
266
|
-
stdin.removeListener("keypress", onKeypress);
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const onKeypress = (str: string, key: any) => {
|
|
270
|
-
if (key?.ctrl && key?.name === "c") {
|
|
271
|
-
stdout.write("\n");
|
|
272
|
-
cleanup();
|
|
273
|
-
reject(new Error("Cancelled"));
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (key?.name === "return" || key?.name === "enter") {
|
|
278
|
-
stdout.write("\n");
|
|
279
|
-
cleanup();
|
|
280
|
-
resolve(value);
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (key?.name === "backspace") {
|
|
285
|
-
if (value.length > 0) {
|
|
286
|
-
value = value.slice(0, -1);
|
|
287
|
-
// Erase one mask char.
|
|
288
|
-
stdout.write("\b \b");
|
|
289
|
-
}
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Ignore other control keys.
|
|
294
|
-
if (key?.ctrl || key?.meta) return;
|
|
295
|
-
|
|
296
|
-
if (typeof str === "string" && str.length > 0) {
|
|
297
|
-
value += str;
|
|
298
|
-
stdout.write("*");
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
readline.emitKeypressEvents(stdin);
|
|
303
|
-
stdin.setRawMode(true);
|
|
304
|
-
stdin.on("keypress", onKeypress);
|
|
305
|
-
stdin.resume();
|
|
306
|
-
});
|
|
426
|
+
// Default: try SSL with fallback (sslmode=prefer behavior)
|
|
427
|
+
cfg.ssl = { rejectUnauthorized: false };
|
|
428
|
+
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
|
|
307
429
|
}
|
|
308
430
|
|
|
309
431
|
function generateMonitoringPassword(): string {
|
|
310
|
-
// URL-safe and easy to copy/paste;
|
|
311
|
-
|
|
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");
|
|
437
|
+
}
|
|
438
|
+
return password;
|
|
312
439
|
}
|
|
313
440
|
|
|
314
441
|
export async function resolveMonitoringPassword(opts: {
|
|
315
442
|
passwordFlag?: string;
|
|
316
443
|
passwordEnv?: string;
|
|
317
|
-
prompt?: (prompt: string) => Promise<string>;
|
|
318
444
|
monitoringUser: string;
|
|
319
445
|
}): Promise<{ password: string; generated: boolean }> {
|
|
320
446
|
const fromFlag = (opts.passwordFlag || "").trim();
|
|
@@ -332,9 +458,9 @@ export async function buildInitPlan(params: {
|
|
|
332
458
|
monitoringUser?: string;
|
|
333
459
|
monitoringPassword: string;
|
|
334
460
|
includeOptionalPermissions: boolean;
|
|
335
|
-
roleExists?: boolean;
|
|
336
461
|
}): Promise<InitPlan> {
|
|
337
|
-
|
|
462
|
+
// NOTE: kept async for API stability / potential future async template loading.
|
|
463
|
+
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
338
464
|
const database = params.database;
|
|
339
465
|
|
|
340
466
|
const qRole = quoteIdent(monitoringUser);
|
|
@@ -344,27 +470,26 @@ export async function buildInitPlan(params: {
|
|
|
344
470
|
|
|
345
471
|
const steps: InitStep[] = [];
|
|
346
472
|
|
|
347
|
-
const vars = {
|
|
473
|
+
const vars: Record<string, string> = {
|
|
348
474
|
ROLE_IDENT: qRole,
|
|
349
475
|
DB_IDENT: qDb,
|
|
350
476
|
};
|
|
351
477
|
|
|
352
478
|
// Role creation/update is done in one template file.
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
roleStmt = `alter user ${qRole} with password ${qPw};`;
|
|
359
|
-
} else {
|
|
360
|
-
roleStmt = `do $$ begin
|
|
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
|
|
361
484
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
485
|
+
begin
|
|
486
|
+
create user ${qRole} with password ${qPw};
|
|
487
|
+
exception when duplicate_object then
|
|
488
|
+
null;
|
|
489
|
+
end;
|
|
365
490
|
end if;
|
|
491
|
+
alter user ${qRole} with password ${qPw};
|
|
366
492
|
end $$;`;
|
|
367
|
-
}
|
|
368
493
|
|
|
369
494
|
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
370
495
|
steps.push({ name: "01.role", sql: roleSql });
|
|
@@ -374,6 +499,12 @@ end $$;`;
|
|
|
374
499
|
sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
|
|
375
500
|
});
|
|
376
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
|
+
});
|
|
507
|
+
|
|
377
508
|
if (params.includeOptionalPermissions) {
|
|
378
509
|
steps.push(
|
|
379
510
|
{
|
|
@@ -400,39 +531,66 @@ export async function applyInitPlan(params: {
|
|
|
400
531
|
const applied: string[] = [];
|
|
401
532
|
const skippedOptional: string[] = [];
|
|
402
533
|
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
407
542
|
try {
|
|
408
|
-
await params.client.query(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
412
|
-
const errAny = e as any;
|
|
413
|
-
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
|
|
414
|
-
// Preserve Postgres error code so callers can provide better hints (e.g., 42501 insufficient_privilege).
|
|
415
|
-
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
416
|
-
wrapped.code = errAny.code;
|
|
417
|
-
}
|
|
418
|
-
throw wrapped;
|
|
543
|
+
await params.client.query("rollback;");
|
|
544
|
+
} catch {
|
|
545
|
+
// ignore
|
|
419
546
|
}
|
|
547
|
+
throw e;
|
|
420
548
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// Apply non-optional steps, each in its own transaction
|
|
552
|
+
for (const step of params.plan.steps.filter((s) => !s.optional)) {
|
|
424
553
|
try {
|
|
425
|
-
await
|
|
426
|
-
|
|
427
|
-
|
|
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;
|
|
428
587
|
}
|
|
429
|
-
throw e;
|
|
430
588
|
}
|
|
431
589
|
|
|
432
|
-
// Apply optional steps
|
|
590
|
+
// Apply optional steps, each in its own transaction (failure doesn't abort)
|
|
433
591
|
for (const step of params.plan.steps.filter((s) => s.optional)) {
|
|
434
592
|
try {
|
|
435
|
-
await
|
|
593
|
+
await executeStep(step);
|
|
436
594
|
applied.push(step.name);
|
|
437
595
|
} catch {
|
|
438
596
|
skippedOptional.push(step.name);
|
|
@@ -455,111 +613,148 @@ export async function verifyInitSetup(params: {
|
|
|
455
613
|
monitoringUser: string;
|
|
456
614
|
includeOptionalPermissions: boolean;
|
|
457
615
|
}): Promise<VerifyInitResult> {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
+
}
|
|
471
632
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
}
|
|
479
640
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
}
|
|
487
648
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
495
656
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
} else {
|
|
500
|
-
const viewPrivRes = await params.client.query(
|
|
501
|
-
"select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
|
|
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",
|
|
502
660
|
[role]
|
|
503
661
|
);
|
|
504
|
-
if (!
|
|
505
|
-
missingRequired.push("
|
|
662
|
+
if (!schemaExistsRes.rows?.[0]?.ok) {
|
|
663
|
+
missingRequired.push("USAGE on schema postgres_ai");
|
|
506
664
|
}
|
|
507
|
-
}
|
|
508
665
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
+
}
|
|
516
678
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
// We accept any ordering as long as public and pg_catalog are included.
|
|
524
|
-
const sp = spLine.toLowerCase();
|
|
525
|
-
if (!sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
526
|
-
missingRequired.push("role search_path includes public and pg_catalog");
|
|
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");
|
|
527
685
|
}
|
|
528
|
-
}
|
|
529
686
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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]);
|
|
541
743
|
if (!fnRes.rows?.[0]?.ok) {
|
|
542
|
-
missingOptional.push(
|
|
744
|
+
missingOptional.push(`EXECUTE on ${fn}`);
|
|
543
745
|
}
|
|
544
746
|
}
|
|
545
747
|
}
|
|
546
748
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
for (const fn of optionalFns) {
|
|
555
|
-
const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
|
|
556
|
-
if (!fnRes.rows?.[0]?.ok) {
|
|
557
|
-
missingOptional.push(`EXECUTE on ${fn}`);
|
|
558
|
-
}
|
|
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
|
|
559
756
|
}
|
|
560
757
|
}
|
|
561
|
-
|
|
562
|
-
return { ok: missingRequired.length === 0, missingRequired, missingOptional };
|
|
563
758
|
}
|
|
564
759
|
|
|
565
760
|
|