postgresai 0.15.0-dev.1 → 0.15.0-dev.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 +82 -9
- package/bin/postgres-ai.ts +813 -233
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +6193 -1059
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +255 -24
- package/lib/config.ts +3 -0
- package/lib/init.ts +197 -5
- package/lib/instances.ts +245 -0
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +229 -18
- package/lib/metrics-loader.ts +6 -4
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +367 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +2 -0
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +625 -1
- package/test/mcp-server.test.ts +944 -2
- package/test/monitoring.test.ts +355 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +935 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/lib/init.ts
CHANGED
|
@@ -87,7 +87,7 @@ export type AdminConnection = {
|
|
|
87
87
|
/**
|
|
88
88
|
* Check if an error indicates SSL negotiation failed and fallback to non-SSL should be attempted.
|
|
89
89
|
* This mimics libpq's sslmode=prefer behavior.
|
|
90
|
-
*
|
|
90
|
+
*
|
|
91
91
|
* IMPORTANT: This should NOT match certificate errors (expired, invalid, self-signed)
|
|
92
92
|
* as those are real errors the user needs to fix, not negotiation failures.
|
|
93
93
|
*/
|
|
@@ -127,8 +127,10 @@ export async function connectWithSslFallback(
|
|
|
127
127
|
verbose?: boolean
|
|
128
128
|
): Promise<{ client: PgClient; usedSsl: boolean }> {
|
|
129
129
|
const tryConnect = async (config: PgClientConfig): Promise<PgClient> => {
|
|
130
|
-
const client = new ClientClass(config);
|
|
130
|
+
const client = new ClientClass({ ...config, connectionTimeoutMillis: 10_000 } as any);
|
|
131
131
|
await client.connect();
|
|
132
|
+
// Set a default statement timeout to prevent runaway queries
|
|
133
|
+
await client.query("SET statement_timeout = '30s'");
|
|
132
134
|
return client;
|
|
133
135
|
};
|
|
134
136
|
|
|
@@ -149,7 +151,7 @@ export async function connectWithSslFallback(
|
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
if (verbose) {
|
|
152
|
-
console.
|
|
154
|
+
console.error("SSL connection failed, retrying without SSL...");
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
// Retry without SSL
|
|
@@ -454,8 +456,18 @@ export function resolveAdminConnection(opts: {
|
|
|
454
456
|
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
|
|
455
457
|
}
|
|
456
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Generate a cryptographically secure random password for the monitoring role.
|
|
461
|
+
*
|
|
462
|
+
* Encoding note — bytes vs output length:
|
|
463
|
+
* - hex: N bytes → 2N characters (24 bytes → 48 hex chars)
|
|
464
|
+
* - base64: N bytes → ⌈4N/3⌉ chars (24 bytes → 32 base64url chars, no padding)
|
|
465
|
+
*
|
|
466
|
+
* We use base64url (RFC 4648 §5) because it is shorter than hex and safe in URLs,
|
|
467
|
+
* connection strings, and shell variables without quoting.
|
|
468
|
+
*/
|
|
457
469
|
function generateMonitoringPassword(): string {
|
|
458
|
-
//
|
|
470
|
+
// 24 random bytes → 32 base64url characters (no padding).
|
|
459
471
|
// Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
|
|
460
472
|
const password = randomBytes(24).toString("base64url");
|
|
461
473
|
if (password.length < 30) {
|
|
@@ -659,6 +671,36 @@ export type VerifyInitResult = {
|
|
|
659
671
|
missingOptional: string[];
|
|
660
672
|
};
|
|
661
673
|
|
|
674
|
+
/** A single permission check result from the preflight query. */
|
|
675
|
+
export type PermissionCheckRow = {
|
|
676
|
+
permission_name: string;
|
|
677
|
+
status: "required" | "optional";
|
|
678
|
+
/**
|
|
679
|
+
* Whether the permission is granted.
|
|
680
|
+
* - `true` — permission is granted
|
|
681
|
+
* - `false` — permission is explicitly denied
|
|
682
|
+
* - `null` — check was skipped (e.g., object does not exist, so the privilege
|
|
683
|
+
* check is inapplicable — such as SELECT on a view that hasn't been created)
|
|
684
|
+
*/
|
|
685
|
+
granted: boolean | null;
|
|
686
|
+
fix_command: string | null;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Result of the preflight permission check for the current DB user.
|
|
691
|
+
*
|
|
692
|
+
* - `ok` is `true` when `missingRequired` is empty.
|
|
693
|
+
* - `rows` contains every check (for inspection / logging).
|
|
694
|
+
* - `missingRequired` / `missingOptional` are filtered subsets of `rows`
|
|
695
|
+
* where the permission is not granted (`granted !== true`).
|
|
696
|
+
*/
|
|
697
|
+
export type PreflightPermissionResult = {
|
|
698
|
+
ok: boolean;
|
|
699
|
+
rows: PermissionCheckRow[];
|
|
700
|
+
missingRequired: PermissionCheckRow[];
|
|
701
|
+
missingOptional: PermissionCheckRow[];
|
|
702
|
+
};
|
|
703
|
+
|
|
662
704
|
export type UninitPlan = {
|
|
663
705
|
monitoringUser: string;
|
|
664
706
|
database: string;
|
|
@@ -813,7 +855,12 @@ export async function verifyInitSetup(params: {
|
|
|
813
855
|
missingRequired.push("USAGE on schema postgres_ai");
|
|
814
856
|
}
|
|
815
857
|
|
|
816
|
-
const viewExistsRes = await params.client.query(
|
|
858
|
+
const viewExistsRes = await params.client.query(`
|
|
859
|
+
select case
|
|
860
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
861
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
862
|
+
end as ok
|
|
863
|
+
`);
|
|
817
864
|
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
818
865
|
missingRequired.push("view postgres_ai.pg_statistic exists");
|
|
819
866
|
} else {
|
|
@@ -936,4 +983,149 @@ export async function verifyInitSetup(params: {
|
|
|
936
983
|
}
|
|
937
984
|
}
|
|
938
985
|
|
|
986
|
+
/**
|
|
987
|
+
* Check that the currently connected DB user has sufficient permissions for
|
|
988
|
+
* monitoring operations. Returns structured results with fix commands.
|
|
989
|
+
*
|
|
990
|
+
* Required permissions cause startup to fail; optional ones produce warnings.
|
|
991
|
+
*
|
|
992
|
+
* @param client An already-connected PostgreSQL client.
|
|
993
|
+
* @returns A {@link PreflightPermissionResult} with per-check rows and
|
|
994
|
+
* filtered `missingRequired` / `missingOptional` arrays.
|
|
995
|
+
* @throws Propagates database errors (network, permission denied on catalog
|
|
996
|
+
* tables, timeout) to the caller.
|
|
997
|
+
*/
|
|
998
|
+
export async function checkCurrentUserPermissions(
|
|
999
|
+
client: PgClient
|
|
1000
|
+
): Promise<PreflightPermissionResult> {
|
|
1001
|
+
const sql = `
|
|
1002
|
+
with permission_checks as (
|
|
1003
|
+
select
|
|
1004
|
+
format('connect on database %I', current_database()) as permission_name,
|
|
1005
|
+
'required' as status,
|
|
1006
|
+
has_database_privilege(current_user, current_database(), 'connect') as granted
|
|
1007
|
+
|
|
1008
|
+
union all
|
|
1009
|
+
|
|
1010
|
+
select
|
|
1011
|
+
'pg_monitor role membership' as permission_name,
|
|
1012
|
+
'required' as status,
|
|
1013
|
+
-- CASE guarantees evaluation order: pg_has_role() is only called if the
|
|
1014
|
+
-- pg_monitor role exists, avoiding ERROR on PostgreSQL < 10 or when dropped.
|
|
1015
|
+
case
|
|
1016
|
+
when not exists (select from pg_roles where rolname = 'pg_monitor')
|
|
1017
|
+
then false
|
|
1018
|
+
else pg_has_role(current_user, 'pg_monitor', 'member')
|
|
1019
|
+
end as granted
|
|
1020
|
+
|
|
1021
|
+
union all
|
|
1022
|
+
|
|
1023
|
+
select
|
|
1024
|
+
'select on pg_catalog.pg_index' as permission_name,
|
|
1025
|
+
'required' as status,
|
|
1026
|
+
has_table_privilege(current_user, 'pg_catalog.pg_index', 'select') as granted
|
|
1027
|
+
|
|
1028
|
+
union all
|
|
1029
|
+
|
|
1030
|
+
select
|
|
1031
|
+
'postgres_ai.pg_statistic view exists' as permission_name,
|
|
1032
|
+
'optional' as status,
|
|
1033
|
+
case
|
|
1034
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1035
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1036
|
+
end as granted
|
|
1037
|
+
|
|
1038
|
+
union all
|
|
1039
|
+
|
|
1040
|
+
select
|
|
1041
|
+
'select on postgres_ai.pg_statistic' as permission_name,
|
|
1042
|
+
'optional' as status,
|
|
1043
|
+
case
|
|
1044
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1045
|
+
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
1046
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
1047
|
+
end as granted
|
|
1048
|
+
)
|
|
1049
|
+
select
|
|
1050
|
+
permission_name,
|
|
1051
|
+
status,
|
|
1052
|
+
granted,
|
|
1053
|
+
case
|
|
1054
|
+
when status = 'required' and not coalesce(granted, false) then
|
|
1055
|
+
case
|
|
1056
|
+
when permission_name like 'connect%' then
|
|
1057
|
+
format('grant connect on database %I to %I;', current_database(), current_user)
|
|
1058
|
+
when permission_name = 'pg_monitor role membership' then
|
|
1059
|
+
format('grant pg_monitor to %I;', current_user)
|
|
1060
|
+
when permission_name like 'select on pg_catalog.pg_index' then
|
|
1061
|
+
format('grant select on pg_catalog.pg_index to %I;', current_user)
|
|
1062
|
+
end
|
|
1063
|
+
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
|
|
1064
|
+
'-- create postgres_ai.pg_statistic view (see setup script)'
|
|
1065
|
+
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
|
|
1066
|
+
format('grant select on postgres_ai.pg_statistic to %I;', current_user)
|
|
1067
|
+
else null
|
|
1068
|
+
end as fix_command
|
|
1069
|
+
from permission_checks
|
|
1070
|
+
order by
|
|
1071
|
+
case status when 'required' then 1 else 2 end,
|
|
1072
|
+
permission_name;
|
|
1073
|
+
`;
|
|
1074
|
+
|
|
1075
|
+
const res = await client.query(sql);
|
|
1076
|
+
const rows: PermissionCheckRow[] = res.rows;
|
|
1077
|
+
|
|
1078
|
+
// Required: treat null (skipped) as not-granted — fail safe.
|
|
1079
|
+
// Optional: only explicit false counts as missing; null means the check was
|
|
1080
|
+
// skipped (e.g., view doesn't exist) and is not actionable.
|
|
1081
|
+
const missingRequired = rows.filter((r) => r.status === "required" && r.granted !== true);
|
|
1082
|
+
const missingOptional = rows.filter((r) => r.status === "optional" && r.granted === false);
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
ok: missingRequired.length === 0,
|
|
1086
|
+
rows,
|
|
1087
|
+
missingRequired,
|
|
1088
|
+
missingOptional,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Format permission check results into user-facing error/warning lines.
|
|
1094
|
+
*
|
|
1095
|
+
* @returns An object with `warnings` (for optional misses), `errors` (for
|
|
1096
|
+
* required misses including fix SQL), and `failed` (whether required
|
|
1097
|
+
* permissions are missing).
|
|
1098
|
+
*/
|
|
1099
|
+
export function formatPermissionCheckMessages(result: PreflightPermissionResult): {
|
|
1100
|
+
failed: boolean;
|
|
1101
|
+
warnings: string[];
|
|
1102
|
+
errors: string[];
|
|
1103
|
+
} {
|
|
1104
|
+
const warnings: string[] = [];
|
|
1105
|
+
const errors: string[] = [];
|
|
1106
|
+
|
|
1107
|
+
for (const row of result.missingOptional) {
|
|
1108
|
+
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
|
|
1109
|
+
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
|
|
1110
|
+
}
|
|
939
1111
|
|
|
1112
|
+
if (!result.ok) {
|
|
1113
|
+
errors.push("Error: the database user is missing required permissions.\n");
|
|
1114
|
+
errors.push("Missing permissions:");
|
|
1115
|
+
for (const row of result.missingRequired) {
|
|
1116
|
+
errors.push(` - ${row.permission_name}`);
|
|
1117
|
+
}
|
|
1118
|
+
const fixes = result.missingRequired
|
|
1119
|
+
.map((r) => r.fix_command)
|
|
1120
|
+
.filter(Boolean);
|
|
1121
|
+
if (fixes.length > 0) {
|
|
1122
|
+
errors.push("\nTo fix, run the following as a superuser:\n");
|
|
1123
|
+
for (const fix of fixes) {
|
|
1124
|
+
errors.push(` ${fix}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
errors.push("\nAlternatively, run 'postgresai prepare-db' to set up permissions automatically.");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return { failed: !result.ok, warnings, errors };
|
|
1131
|
+
}
|
package/lib/instances.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for managing instances.yml (the pgwatch monitoring target list)
|
|
3
|
+
* and for opening pg connections that honor libpq sslmode semantics.
|
|
4
|
+
*
|
|
5
|
+
* These helpers exist as a single source of truth so that `mon targets
|
|
6
|
+
* add/remove/test` and `mon local-install` (which has its own inline copies
|
|
7
|
+
* of the same logic) stay consistent — and so unit tests exercise the same
|
|
8
|
+
* code path the CLI actually runs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as yaml from "js-yaml";
|
|
13
|
+
import { parse as parseConnString } from "pg-connection-string";
|
|
14
|
+
import type { ClientConfig } from "pg";
|
|
15
|
+
|
|
16
|
+
export interface Instance {
|
|
17
|
+
name: string;
|
|
18
|
+
conn_str?: string;
|
|
19
|
+
preset_metrics?: string;
|
|
20
|
+
custom_metrics?: any;
|
|
21
|
+
is_enabled?: boolean;
|
|
22
|
+
group?: string;
|
|
23
|
+
custom_tags?: Record<string, any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class InstancesParseError extends Error {
|
|
27
|
+
constructor(file: string, cause: unknown) {
|
|
28
|
+
const causeMsg = cause instanceof Error ? cause.message : String(cause);
|
|
29
|
+
super(`Failed to parse ${file}: ${causeMsg}`);
|
|
30
|
+
this.name = "InstancesParseError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read instances.yml as an array of Instance.
|
|
36
|
+
*
|
|
37
|
+
* Returns `[]` for a missing/empty file (this is normal — fresh installs and
|
|
38
|
+
* just-after-`remove` states). Throws InstancesParseError on a corrupted file
|
|
39
|
+
* so callers can surface the corruption to the user instead of silently
|
|
40
|
+
* overwriting it (the previous append-text behavior could erase several
|
|
41
|
+
* targets — including their conn_strs with credentials — if the file had a
|
|
42
|
+
* partial write or hand-edit problem).
|
|
43
|
+
*/
|
|
44
|
+
export function loadInstances(file: string): Instance[] {
|
|
45
|
+
if (!fs.existsSync(file)) return [];
|
|
46
|
+
if (fs.lstatSync(file).isDirectory()) return [];
|
|
47
|
+
const text = fs.readFileSync(file, "utf8");
|
|
48
|
+
if (text.trim() === "") return [];
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = yaml.load(text);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new InstancesParseError(file, err);
|
|
54
|
+
}
|
|
55
|
+
if (parsed === null || parsed === undefined) return [];
|
|
56
|
+
if (!Array.isArray(parsed)) {
|
|
57
|
+
throw new InstancesParseError(file, "expected a YAML list at the document root");
|
|
58
|
+
}
|
|
59
|
+
return parsed as Instance[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildInstance(name: string, connStr: string): Instance {
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
conn_str: connStr,
|
|
66
|
+
preset_metrics: "full",
|
|
67
|
+
custom_metrics: null,
|
|
68
|
+
is_enabled: true,
|
|
69
|
+
group: "default",
|
|
70
|
+
custom_tags: {
|
|
71
|
+
env: "production",
|
|
72
|
+
cluster: "default",
|
|
73
|
+
node_name: name,
|
|
74
|
+
// Sed-substituted placeholder by config/scripts/generate-pgwatch-sources.sh.
|
|
75
|
+
// js-yaml emits this unquoted on dump (~ is only special at the start of a
|
|
76
|
+
// scalar in the right context); sed s/~sink_type~/.../g still hits it as
|
|
77
|
+
// raw text regardless.
|
|
78
|
+
sink_type: "~sink_type~",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse → mutate → serialize: load existing list, append, dump back.
|
|
85
|
+
*
|
|
86
|
+
* Replaces the previous text-append code path which corrupted instances.yml
|
|
87
|
+
* after `remove` had left the empty marker `[]` in the file (the append
|
|
88
|
+
* produced two YAML documents in one file → parse error on every subsequent
|
|
89
|
+
* read).
|
|
90
|
+
*
|
|
91
|
+
* Replaces files where the previous code path treated the directory created
|
|
92
|
+
* by Docker's bind-mount-into-missing-path as a target.
|
|
93
|
+
*/
|
|
94
|
+
export function addInstanceToFile(file: string, instance: Instance): void {
|
|
95
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
|
|
96
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
const existing = loadInstances(file);
|
|
99
|
+
if (existing.some((i) => i.name === instance.name)) {
|
|
100
|
+
throw new Error(`Monitoring target '${instance.name}' already exists`);
|
|
101
|
+
}
|
|
102
|
+
existing.push(instance);
|
|
103
|
+
fs.writeFileSync(file, yaml.dump(existing), "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a named instance from the file. Returns true if removed.
|
|
108
|
+
*/
|
|
109
|
+
export function removeInstanceFromFile(file: string, name: string): boolean {
|
|
110
|
+
const instances = loadInstances(file);
|
|
111
|
+
const filtered = instances.filter((i) => i.name !== name);
|
|
112
|
+
if (filtered.length === instances.length) return false;
|
|
113
|
+
fs.writeFileSync(file, yaml.dump(filtered), "utf8");
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extract `sslmode` (lowercased) from a postgresql:// URL. Returns `""` for
|
|
119
|
+
* unset or unparseable.
|
|
120
|
+
*/
|
|
121
|
+
export function extractSslmode(connStr: string): string {
|
|
122
|
+
try {
|
|
123
|
+
return (new URL(connStr).searchParams.get("sslmode") || "").toLowerCase();
|
|
124
|
+
} catch {
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map libpq sslmode values to node-postgres' `ssl` option.
|
|
131
|
+
*
|
|
132
|
+
* libpq: node-postgres ssl:
|
|
133
|
+
* disable false
|
|
134
|
+
* allow / prefer / require { rejectUnauthorized: false } (encrypt, no chain check)
|
|
135
|
+
* verify-ca { rejectUnauthorized: true, checkServerIdentity: () => undefined }
|
|
136
|
+
* verify-full { rejectUnauthorized: true }
|
|
137
|
+
* no-verify (pg extension) { rejectUnauthorized: false }
|
|
138
|
+
*
|
|
139
|
+
* Default for unset: prefer-like → no chain verification, matches what
|
|
140
|
+
* pgwatch (Go pgx) does and what users pass to psql every day.
|
|
141
|
+
*/
|
|
142
|
+
export type SslOption = false | { rejectUnauthorized: boolean; checkServerIdentity?: () => undefined };
|
|
143
|
+
|
|
144
|
+
export function sslOptionFromConnString(connStr: string): SslOption {
|
|
145
|
+
return sslOptionFromSslmode(extractSslmode(connStr));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sslOptionFromSslmode(sslmode: string): SslOption {
|
|
149
|
+
switch (sslmode) {
|
|
150
|
+
case "disable":
|
|
151
|
+
return false;
|
|
152
|
+
case "verify-ca":
|
|
153
|
+
return { rejectUnauthorized: true, checkServerIdentity: () => undefined };
|
|
154
|
+
case "verify-full":
|
|
155
|
+
return { rejectUnauthorized: true };
|
|
156
|
+
case "allow":
|
|
157
|
+
case "prefer":
|
|
158
|
+
case "require":
|
|
159
|
+
case "no-verify":
|
|
160
|
+
case "":
|
|
161
|
+
default:
|
|
162
|
+
return { rejectUnauthorized: false };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The set of sslmode values for which we DO NOT verify the certificate chain.
|
|
168
|
+
* Exposed so callers can warn users about the lax security posture.
|
|
169
|
+
*/
|
|
170
|
+
export const LAX_SSLMODES = new Set(["", "allow", "prefer", "require"]);
|
|
171
|
+
|
|
172
|
+
export function isLaxSslmode(sslmode: string): boolean {
|
|
173
|
+
return LAX_SSLMODES.has(sslmode);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Print a stderr warning when the connection string uses an sslmode that
|
|
178
|
+
* skips certificate-chain verification. Centralises the message so the
|
|
179
|
+
* three Client-construction sites stay consistent.
|
|
180
|
+
*/
|
|
181
|
+
export function warnIfLaxSslmode(connStr: string): void {
|
|
182
|
+
const sslmode = extractSslmode(connStr);
|
|
183
|
+
if (!isLaxSslmode(sslmode)) return;
|
|
184
|
+
const shown = sslmode || "(unset)";
|
|
185
|
+
console.error(
|
|
186
|
+
`⚠ sslmode=${shown}: TLS chain is NOT verified (matches libpq/psql semantics). ` +
|
|
187
|
+
`Use sslmode=verify-full for full chain+hostname verification.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build a `pg.Client` config from a connection string that ACTUALLY honors
|
|
193
|
+
* libpq sslmode semantics.
|
|
194
|
+
*
|
|
195
|
+
* This is non-trivial because node-postgres' `Client` constructor, given a
|
|
196
|
+
* `connectionString`, runs `Object.assign({}, config, parse(connectionString))`
|
|
197
|
+
* — meaning the parsed sslmode-derived `ssl` value REPLACES any explicit
|
|
198
|
+
* `ssl` you passed alongside `connectionString`. So setting
|
|
199
|
+
* `{ connectionString, ssl: { rejectUnauthorized: false } }` does not work
|
|
200
|
+
* when the URL contains `sslmode=require` (the parsed value `{}` wins, and
|
|
201
|
+
* `{}` defaults to chain verification → "self-signed certificate in
|
|
202
|
+
* certificate chain" against managed Postgres).
|
|
203
|
+
*
|
|
204
|
+
* The fix: parse the URL ourselves, pass discrete host/port/user/etc., and
|
|
205
|
+
* include our explicit `ssl` — never pass `connectionString` so nothing
|
|
206
|
+
* overrides us.
|
|
207
|
+
*
|
|
208
|
+
* We strip `sslmode` from the URL before handing it to `pg-connection-string`'s
|
|
209
|
+
* `parse()` so that:
|
|
210
|
+
* 1. its `process.emitWarning("SECURITY WARNING: SSL modes 'prefer'/'require'/
|
|
211
|
+
* 'verify-ca' are treated as aliases for 'verify-full'…")` doesn't fire
|
|
212
|
+
* on every CLI invocation against a Supabase-shaped URL, and
|
|
213
|
+
* 2. its `verify-ca` compatibility branch doesn't *throw* on us (requires
|
|
214
|
+
* sslrootcert which we don't have).
|
|
215
|
+
*
|
|
216
|
+
* We don't use the parser's `ssl` output anyway — we compute our own from
|
|
217
|
+
* `sslOptionFromSslmode(extractSslmode(originalConnStr))` — so removing the
|
|
218
|
+
* sslmode parameter before parsing is a no-op for the fields we actually use.
|
|
219
|
+
*/
|
|
220
|
+
export function buildClientConfig(
|
|
221
|
+
connStr: string,
|
|
222
|
+
extra: { connectionTimeoutMillis?: number } = {},
|
|
223
|
+
): ClientConfig {
|
|
224
|
+
const sslmode = extractSslmode(connStr);
|
|
225
|
+
const parsed = parseConnString(withoutSslmode(connStr));
|
|
226
|
+
return {
|
|
227
|
+
host: parsed.host || undefined,
|
|
228
|
+
port: parsed.port ? Number(parsed.port) : undefined,
|
|
229
|
+
user: parsed.user,
|
|
230
|
+
password: parsed.password,
|
|
231
|
+
database: parsed.database || undefined,
|
|
232
|
+
ssl: sslOptionFromSslmode(sslmode),
|
|
233
|
+
...extra,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function withoutSslmode(connStr: string): string {
|
|
238
|
+
try {
|
|
239
|
+
const u = new URL(connStr);
|
|
240
|
+
u.searchParams.delete("sslmode");
|
|
241
|
+
return u.toString();
|
|
242
|
+
} catch {
|
|
243
|
+
return connStr;
|
|
244
|
+
}
|
|
245
|
+
}
|