postgresai 0.16.0-dev.1 → 0.16.0-dev.10

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/lib/util.ts CHANGED
@@ -28,45 +28,111 @@ function isHtmlContent(text: string): boolean {
28
28
  */
29
29
  const AUTH_REMEDIATION_HINT = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
30
30
 
31
+ /**
32
+ * Standard HTTP reason phrases we should NOT treat as a server-authored
33
+ * message: when the reason phrase equals the stock text for the status, it
34
+ * carries no extra information, so we prefer the friendlier generic label.
35
+ */
36
+ const STANDARD_REASON_PHRASES: Record<number, string> = {
37
+ 400: "Bad Request",
38
+ 401: "Unauthorized",
39
+ 403: "Forbidden",
40
+ 404: "Not Found",
41
+ 408: "Request Timeout",
42
+ 409: "Conflict",
43
+ 413: "Payload Too Large",
44
+ 429: "Too Many Requests",
45
+ 500: "Internal Server Error",
46
+ 502: "Bad Gateway",
47
+ 503: "Service Unavailable",
48
+ 504: "Gateway Timeout",
49
+ };
50
+
31
51
  /**
32
52
  * Format an HTTP error response into a clean, developer-friendly message.
33
53
  * Handles HTML error pages (e.g., from Cloudflare) by showing just the status code and message.
34
54
  * For 401 responses, appends a remediation hint pointing at `postgresai auth`.
55
+ *
56
+ * The platform's PostgREST layer uses the `PTxyz` custom-status convention:
57
+ * a raised `PT403`/`PT404`/… maps to the HTTP status and delivers the RPC's
58
+ * user-facing message in the HTTP **reason phrase** (`response.statusText`),
59
+ * NOT the JSON body — the body carries only `hint`/`details` (no `message`).
60
+ * So callers pass `statusText` and it is preferred over the built-in generic
61
+ * label. Headline precedence: JSON body `message` → custom reason phrase →
62
+ * generic label; the JSON `details` (plural, PostgREST's spelling) is shown as
63
+ * a supplementary line.
35
64
  */
36
- export function formatHttpError(operation: string, status: number, responseBody?: string): string {
37
- const statusMessage = HTTP_STATUS_MESSAGES[status] || "Request failed";
38
- let errMsg = `${operation}: HTTP ${status} - ${statusMessage}`;
65
+ export function formatHttpError(
66
+ operation: string,
67
+ status: number,
68
+ responseBody?: string,
69
+ statusText?: string
70
+ ): string {
71
+ const generic = HTTP_STATUS_MESSAGES[status] || "Request failed";
39
72
  const remediation = status === 401 ? `\n${AUTH_REMEDIATION_HINT}` : "";
40
73
 
41
- if (responseBody) {
42
- // If it's HTML (like Cloudflare error pages), don't dump the raw HTML
43
- if (isHtmlContent(responseBody)) {
44
- // Just use the status message, don't append HTML
45
- return errMsg + remediation;
46
- }
74
+ let bodyMessage: string | undefined;
75
+ let bodyDetails: string | undefined;
47
76
 
48
- // Try to parse as JSON for structured error info
77
+ if (responseBody && !isHtmlContent(responseBody)) {
78
+ // If it's HTML (like Cloudflare error pages), we fall through with no
79
+ // parsed fields and never dump the raw HTML.
49
80
  try {
50
81
  const errObj = JSON.parse(responseBody);
51
- // Extract common error message fields
52
- const message = errObj.message || errObj.error || errObj.detail;
53
- if (message && typeof message === "string") {
54
- errMsg += `\n${message}`;
55
- } else {
56
- errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
82
+ const message = errObj.message ?? errObj.error;
83
+ if (typeof message === "string" && message.trim().length > 0) {
84
+ bodyMessage = message.trim();
85
+ }
86
+ // PostgREST spells it `details` (plural); accept `detail` too.
87
+ const details = errObj.details ?? errObj.detail;
88
+ if (typeof details === "string" && details.trim().length > 0) {
89
+ bodyDetails = details.trim();
57
90
  }
58
91
  } catch {
59
- // Plain text error - append it if it's short and useful
92
+ // Plain text error - treat it as the details line if short and useful.
60
93
  const trimmed = responseBody.trim();
61
94
  if (trimmed.length > 0 && trimmed.length < 500) {
62
- errMsg += `\n${trimmed}`;
95
+ bodyDetails = trimmed;
63
96
  }
64
97
  }
65
98
  }
66
99
 
100
+ // A custom reason phrase (PTxyz message) is meaningful only when it differs
101
+ // from the stock HTTP reason phrase for this status.
102
+ const trimmedReason = statusText?.trim();
103
+ const reasonPhrase =
104
+ trimmedReason &&
105
+ trimmedReason !== STANDARD_REASON_PHRASES[status] &&
106
+ trimmedReason !== generic
107
+ ? trimmedReason
108
+ : undefined;
109
+
110
+ const headline = bodyMessage ?? reasonPhrase ?? generic;
111
+ let errMsg = `${operation}: HTTP ${status} - ${headline}`;
112
+ if (bodyDetails && bodyDetails !== headline) {
113
+ errMsg += `\n${bodyDetails}`;
114
+ }
115
+
67
116
  return errMsg + remediation;
68
117
  }
69
118
 
119
+ /**
120
+ * Turn a low-level `fetch` failure into an actionable message. Node's fetch
121
+ * (undici) throws a `TypeError('fetch failed')` and stashes the real cause
122
+ * (`ECONNREFUSED`, DNS failure, `bad port`, TLS error, …) in `err.cause` — the
123
+ * opaque top-level message on its own tells the user nothing. This surfaces the
124
+ * cause and the URL that could not be reached, e.g.
125
+ * "Failed to list projects: could not reach http://127.0.0.1:1 (ECONNREFUSED)"
126
+ */
127
+ export function describeFetchError(operation: string, url: string, err: unknown): string {
128
+ const cause = (err as { cause?: { code?: string; message?: string } } | null | undefined)?.cause;
129
+ const detail =
130
+ cause?.code ||
131
+ cause?.message ||
132
+ (err instanceof Error && err.message ? err.message : String(err));
133
+ return `${operation}: could not reach ${url} (${detail})`;
134
+ }
135
+
70
136
  export function maskSecret(secret: string): string {
71
137
  if (!secret) return "";
72
138
  if (secret.length <= 8) return "****";
@@ -74,6 +140,47 @@ export function maskSecret(secret: string): string {
74
140
  return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`;
75
141
  }
76
142
 
143
+ /** Keys whose values are credentials: any `password`-containing key (db.password,
144
+ * db_password, dbPassword, …) and the DBLab connection string (`connStr`, which
145
+ * embeds the password). Matched case-insensitively at any depth. */
146
+ const SENSITIVE_LOG_KEY = /password|connstr/i;
147
+
148
+ /**
149
+ * Redact known credential fields from a serialized JSON payload before it is
150
+ * written to a debug log — the body-side counterpart of the `maskSecret`
151
+ * masking the `access-token` header already gets. Debug logging is reachable
152
+ * by MCP callers (`debug: true` is a caller-controlled tool argument), and
153
+ * DBLab bodies carry live credentials: `--db-password` rides in the clone
154
+ * create request (`data.db.password`), and clone create/status replies return
155
+ * the clone's `db.password` / `db.connStr`.
156
+ *
157
+ * A fixed placeholder is used instead of `maskSecret` because short passwords
158
+ * would leak most of their characters through partial masking. Non-JSON input
159
+ * is returned unchanged.
160
+ */
161
+ export function redactSecretsForLog(text: string): string {
162
+ let parsed: unknown;
163
+ try {
164
+ parsed = JSON.parse(text);
165
+ } catch {
166
+ return text;
167
+ }
168
+ const walk = (node: unknown): unknown => {
169
+ if (Array.isArray(node)) {
170
+ return node.map(walk);
171
+ }
172
+ if (node && typeof node === "object") {
173
+ const out: Record<string, unknown> = {};
174
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
175
+ out[key] = SENSITIVE_LOG_KEY.test(key) && value != null ? "[REDACTED]" : walk(value);
176
+ }
177
+ return out;
178
+ }
179
+ return node;
180
+ };
181
+ return JSON.stringify(walk(parsed));
182
+ }
183
+
77
184
 
78
185
  export interface RootOptsLike {
79
186
  apiBaseUrl?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.1",
3
+ "version": "0.16.0-dev.10",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -2,126 +2,6 @@
2
2
  -- These functions use SECURITY DEFINER to allow the monitoring user to perform
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
- /*
6
- * explain_generic
7
- *
8
- * Function to get generic explain plans with optional HypoPG index testing.
9
- * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
- *
11
- * Security notes:
12
- * - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13
- * - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14
- * - Input validation uses a simple heuristic to detect multiple statements
15
- * (Note: may reject valid queries containing semicolons in string literals)
16
- *
17
- * Usage examples:
18
- * -- Basic generic plan
19
- * select postgres_ai.explain_generic('select * from users where id = $1');
20
- *
21
- * -- JSON format
22
- * select postgres_ai.explain_generic('select * from users where id = $1', 'json');
23
- *
24
- * -- Test a hypothetical index
25
- * select postgres_ai.explain_generic(
26
- * 'select * from users where email = $1',
27
- * 'text',
28
- * 'create index on users (email)'
29
- * );
30
- */
31
- create or replace function postgres_ai.explain_generic(
32
- in query text,
33
- in format text default 'text',
34
- in hypopg_index text default null,
35
- out result text
36
- )
37
- language plpgsql
38
- security definer
39
- set search_path = pg_catalog, public
40
- as $$
41
- declare
42
- v_line record;
43
- v_lines text[] := '{}';
44
- v_explain_query text;
45
- v_hypo_result record;
46
- v_version int;
47
- v_hypopg_available boolean;
48
- v_clean_query text;
49
- begin
50
- -- Check PostgreSQL version (generic_plan requires 16+)
51
- select current_setting('server_version_num')::int into v_version;
52
-
53
- if v_version < 160000 then
54
- raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
55
- current_setting('server_version');
56
- end if;
57
-
58
- -- Input validation: reject empty queries
59
- if query is null or trim(query) = '' then
60
- raise exception 'query cannot be empty';
61
- end if;
62
-
63
- -- Input validation: detect multiple statements (defense-in-depth)
64
- -- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65
- -- Limitation: Queries with semicolons inside string literals will be rejected
66
- v_clean_query := trim(query);
67
- if v_clean_query like '%;%' then
68
- -- Strip trailing semicolon if present (common user convenience)
69
- v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70
- -- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71
- if v_clean_query like '%;%' then
72
- raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73
- end if;
74
- end if;
75
-
76
- -- Check if HypoPG extension is available
77
- if hypopg_index is not null then
78
- select exists(
79
- select 1 from pg_extension where extname = 'hypopg'
80
- ) into v_hypopg_available;
81
-
82
- if not v_hypopg_available then
83
- raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
84
- end if;
85
-
86
- -- Create hypothetical index
87
- select * into v_hypo_result from hypopg_create_index(hypopg_index);
88
- raise notice 'Created hypothetical index: % (oid: %)',
89
- v_hypo_result.indexname, v_hypo_result.indexrelid;
90
- end if;
91
-
92
- -- Build and execute EXPLAIN query
93
- -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
94
- begin
95
- if lower(format) = 'json' then
96
- execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
- into result;
98
- else
99
- for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
100
- v_lines := array_append(v_lines, v_line."QUERY PLAN");
101
- end loop;
102
- result := array_to_string(v_lines, e'\n');
103
- end if;
104
- exception when others then
105
- -- Clean up hypothetical index before re-raising
106
- if hypopg_index is not null then
107
- perform hypopg_reset();
108
- end if;
109
- raise;
110
- end;
111
-
112
- -- Clean up hypothetical index
113
- if hypopg_index is not null then
114
- perform hypopg_reset();
115
- end if;
116
- end;
117
- $$;
118
-
119
- comment on function postgres_ai.explain_generic(text, text, text) is
120
- 'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
121
-
122
- -- Grant execute to the monitoring user
123
- grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
124
-
125
5
  /*
126
6
  * table_describe
127
7
  *
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
435
315
  'Returns comprehensive table information in compact text format for LLM analysis';
436
316
 
437
317
  grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
438
-
439
-