postgresai 0.16.0-dev.10 → 0.16.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/lib/util.ts CHANGED
@@ -58,9 +58,14 @@ const STANDARD_REASON_PHRASES: Record<number, string> = {
58
58
  * user-facing message in the HTTP **reason phrase** (`response.statusText`),
59
59
  * NOT the JSON body — the body carries only `hint`/`details` (no `message`).
60
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.
61
+ * label. Behind an h2/h3 proxy the reason phrase is dropped entirely
62
+ * (`statusText` is empty), so the JSON body's `code`, `details`, and `hint`
63
+ * are all we get — they must be surfaced too (CLI half of
64
+ * https://gitlab.com/postgres-ai/platform-all/-/issues/537).
65
+ * Headline precedence: JSON body `message` → custom reason phrase → generic
66
+ * label, suffixed with the JSON `code` (e.g. `PT403`) when present; the JSON
67
+ * `details` (plural, PostgREST's spelling) is shown as a supplementary line,
68
+ * and the JSON `hint` (the remediation) as a trailing `Hint:` line.
64
69
  */
65
70
  export function formatHttpError(
66
71
  operation: string,
@@ -73,6 +78,8 @@ export function formatHttpError(
73
78
 
74
79
  let bodyMessage: string | undefined;
75
80
  let bodyDetails: string | undefined;
81
+ let bodyCode: string | undefined;
82
+ let bodyHint: string | undefined;
76
83
 
77
84
  if (responseBody && !isHtmlContent(responseBody)) {
78
85
  // If it's HTML (like Cloudflare error pages), we fall through with no
@@ -81,18 +88,37 @@ export function formatHttpError(
81
88
  const errObj = JSON.parse(responseBody);
82
89
  const message = errObj.message ?? errObj.error;
83
90
  if (typeof message === "string" && message.trim().length > 0) {
84
- bodyMessage = message.trim();
91
+ bodyMessage = redactTextSecrets(message.trim());
85
92
  }
86
93
  // PostgREST spells it `details` (plural); accept `detail` too.
87
94
  const details = errObj.details ?? errObj.detail;
88
95
  if (typeof details === "string" && details.trim().length > 0) {
89
- bodyDetails = details.trim();
96
+ bodyDetails = redactTextSecrets(details.trim());
97
+ }
98
+ if (typeof errObj.code === "string" && errObj.code.trim().length > 0) {
99
+ bodyCode = errObj.code.trim();
100
+ }
101
+ if (typeof errObj.hint === "string" && errObj.hint.trim().length > 0) {
102
+ bodyHint = redactTextSecrets(errObj.hint.trim());
103
+ }
104
+ if (
105
+ bodyMessage === undefined &&
106
+ bodyDetails === undefined &&
107
+ bodyCode === undefined &&
108
+ bodyHint === undefined
109
+ ) {
110
+ // A JSON body with none of the known fields (message/error/details/
111
+ // detail/code/hint) still carries the only diagnostic there is —
112
+ // surface it (redacted, compact) instead of silently dropping it.
113
+ bodyDetails = redactSecretsForLog(JSON.stringify(errObj));
90
114
  }
91
115
  } catch {
92
116
  // Plain text error - treat it as the details line if short and useful.
117
+ // Scrubbed: raw error bodies can echo credentials (e.g. a connStr) and
118
+ // this string ends up in thrown Errors / MCP isError responses.
93
119
  const trimmed = responseBody.trim();
94
120
  if (trimmed.length > 0 && trimmed.length < 500) {
95
- bodyDetails = trimmed;
121
+ bodyDetails = redactTextSecrets(trimmed);
96
122
  }
97
123
  }
98
124
  }
@@ -106,12 +132,17 @@ export function formatHttpError(
106
132
  trimmedReason !== generic
107
133
  ? trimmedReason
108
134
  : undefined;
135
+ const safeReasonPhrase = reasonPhrase ? redactTextSecrets(reasonPhrase) : undefined;
109
136
 
110
- const headline = bodyMessage ?? reasonPhrase ?? generic;
111
- let errMsg = `${operation}: HTTP ${status} - ${headline}`;
137
+ const headline = bodyMessage ?? safeReasonPhrase ?? generic;
138
+ const codeSuffix = bodyCode && !headline.includes(bodyCode) ? ` (${bodyCode})` : "";
139
+ let errMsg = `${operation}: HTTP ${status} - ${headline}${codeSuffix}`;
112
140
  if (bodyDetails && bodyDetails !== headline) {
113
141
  errMsg += `\n${bodyDetails}`;
114
142
  }
143
+ if (bodyHint) {
144
+ errMsg += `\nHint: ${bodyHint}`;
145
+ }
115
146
 
116
147
  return errMsg + remediation;
117
148
  }
@@ -133,6 +164,49 @@ export function describeFetchError(operation: string, url: string, err: unknown)
133
164
  return `${operation}: could not reach ${url} (${detail})`;
134
165
  }
135
166
 
167
+ /**
168
+ * An HTTP error response with the status attached, so callers can classify
169
+ * retryability without string-matching the formatted message.
170
+ */
171
+ export class HttpStatusError extends Error {
172
+ readonly status: number;
173
+ constructor(message: string, status: number) {
174
+ super(message);
175
+ this.name = "HttpStatusError";
176
+ this.status = status;
177
+ }
178
+ }
179
+
180
+ /** Transient statuses worth retrying/resuming on: server errors and rate limits. */
181
+ export function isRetryableHttpStatus(status: number): boolean {
182
+ return status >= 500 || status === 429;
183
+ }
184
+
185
+ /** Hard upper bound for a single platform request, even outside Joe polling. */
186
+ export const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 25_000;
187
+
188
+ export class HttpRequestTimeoutError extends Error {
189
+ constructor(operation: string, timeoutMs: number) {
190
+ super(`${operation}: request timed out after ${timeoutMs}ms`);
191
+ this.name = "HttpRequestTimeoutError";
192
+ }
193
+ }
194
+
195
+ /** Return a finite AbortSignal timeout acceptable to both Node and Bun fetch. */
196
+ export function requestTimeoutSignal(timeoutMs?: number): { signal: AbortSignal; timeoutMs: number } {
197
+ const requested =
198
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
199
+ ? timeoutMs
200
+ : DEFAULT_HTTP_REQUEST_TIMEOUT_MS;
201
+ const bounded = Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, Math.floor(requested)));
202
+ return { signal: AbortSignal.timeout(bounded), timeoutMs: bounded };
203
+ }
204
+
205
+ export function isFetchTimeout(err: unknown): boolean {
206
+ const name = (err as { name?: unknown } | null)?.name;
207
+ return name === "AbortError" || name === "TimeoutError";
208
+ }
209
+
136
210
  export function maskSecret(secret: string): string {
137
211
  if (!secret) return "";
138
212
  if (secret.length <= 8) return "****";
@@ -140,45 +214,87 @@ export function maskSecret(secret: string): string {
140
214
  return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`;
141
215
  }
142
216
 
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;
217
+ /**
218
+ * Credential-bearing field names. Match complete normalized names rather than
219
+ * substrings: MCP results can contain ordinary SQL columns such as `author_id`,
220
+ * `token_type`, `tokens`, and `credited_at`, and corrupting those values is worse
221
+ * than leaving an unfamiliar key untouched. CamelCase is normalized so DBLab's
222
+ * `connStr` and `dbPassword` remain covered.
223
+ */
224
+ function isSensitiveLogKey(key: string): boolean {
225
+ const normalized = key
226
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
227
+ .replace(/[-\s]+/g, "_")
228
+ .toLowerCase();
229
+ return /^(?:password|passwd|db_(?:pass|password)|conn_?str|secret|token|api_key|private_key|access_key|access_token|refresh_token|auth|auth_key|auth_token|authorization|credentials?|dsn)$/.test(normalized);
230
+ }
231
+
232
+ /**
233
+ * Return a deep copy with DBLab credential fields removed.
234
+ *
235
+ * Best-effort hygiene only, NOT a security barrier: redaction is key-name
236
+ * based, so secret VALUES under non-matching keys (e.g.
237
+ * `select rolpassword as x from pg_authid`) pass through untouched. The
238
+ * joe:exec scope and the org execution policy are the actual controls.
239
+ */
240
+ export function redactSecrets(value: unknown): unknown {
241
+ if (Array.isArray(value)) {
242
+ return value.map(redactSecrets);
243
+ }
244
+ if (value && typeof value === "object") {
245
+ const out: Record<string, unknown> = {};
246
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
247
+ out[key] = isSensitiveLogKey(key) && child != null
248
+ ? "[REDACTED]"
249
+ : redactSecrets(child);
250
+ }
251
+ return out;
252
+ }
253
+ return value;
254
+ }
147
255
 
148
256
  /**
149
257
  * Redact known credential fields from a serialized JSON payload before it is
150
258
  * written to a debug log — the body-side counterpart of the `maskSecret`
151
259
  * masking the `access-token` header already gets. Debug logging is reachable
152
260
  * by MCP callers (`debug: true` is a caller-controlled tool argument), and
153
- * DBLab bodies carry live credentials: `--db-password` rides in the clone
261
+ * DBLab bodies carry live credentials: the clone DB password rides in the clone
154
262
  * create request (`data.db.password`), and clone create/status replies return
155
263
  * the clone's `db.password` / `db.connStr`.
156
264
  *
157
265
  * A fixed placeholder is used instead of `maskSecret` because short passwords
158
266
  * would leak most of their characters through partial masking. Non-JSON input
159
- * is returned unchanged.
267
+ * cannot be redacted by key name, so it falls back to the pattern-based
268
+ * `redactTextSecrets` scrub — error paths (parse failures, plain-text error
269
+ * bodies) embed raw response text in thrown Errors that reach MCP `isError`
270
+ * responses and CLI stderr, and must not bypass redaction.
160
271
  */
161
272
  export function redactSecretsForLog(text: string): string {
162
273
  let parsed: unknown;
163
274
  try {
164
275
  parsed = JSON.parse(text);
165
276
  } catch {
166
- return text;
277
+ return redactTextSecrets(text);
167
278
  }
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));
279
+ return JSON.stringify(redactSecrets(parsed));
280
+ }
281
+
282
+ /** URL userinfo credentials: `scheme://user:password@host` → password redacted. */
283
+ const TEXT_URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+):[^\s/@]+@/gi;
284
+
285
+ /** `key=value` / `key: value` / `"key": "value"` pairs for credential-named keys. */
286
+ const TEXT_SENSITIVE_PAIR =
287
+ /((?:password|passwd|db[-_]?pass|connstr|secret|token|api[-_]?key|private[-_]?key|access[-_]?key|cred(?:ential)?s?|dsn)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
288
+
289
+ /**
290
+ * Best-effort scrub of credential-looking patterns in plain (non-JSON) text.
291
+ * Used for raw response text that ends up in thrown error messages, where the
292
+ * key-based `redactSecrets` cannot apply. Hygiene only — not an egress control.
293
+ */
294
+ export function redactTextSecrets(text: string): string {
295
+ return text
296
+ .replace(TEXT_URL_USERINFO, "$1:[REDACTED]@")
297
+ .replace(TEXT_SENSITIVE_PAIR, "$1[REDACTED]");
182
298
  }
183
299
 
184
300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.10",
3
+ "version": "0.16.0-dev.11",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
package/test/auth.test.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { resolve } from "path";
2
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join, resolve } from "path";
3
5
 
4
6
  import * as util from "../lib/util";
5
7
  import * as pkce from "../lib/pkce";
@@ -212,6 +214,33 @@ describe("Auth callback server", () => {
212
214
  });
213
215
 
214
216
  describe("CLI auth commands", () => {
217
+ test("cli: login --help shows all options", () => {
218
+ const r = runCli(["login", "--help"]);
219
+ expect(r.status).toBe(0);
220
+ expect(r.stdout).toMatch(/--set-key/);
221
+ expect(r.stdout).toMatch(/--debug/);
222
+ });
223
+
224
+ test("cli: login --set-key aliases auth login", () => {
225
+ const home = mkdtempSync(join(tmpdir(), "postgresai-login-"));
226
+
227
+ try {
228
+ const r = runCli(["login", "--set-key", "test-token"], {
229
+ HOME: home,
230
+ XDG_CONFIG_HOME: join(home, ".config"),
231
+ });
232
+ expect(r.status).toBe(0);
233
+ expect(r.stdout).toMatch(/API key saved/);
234
+
235
+ const saved = JSON.parse(
236
+ readFileSync(join(home, ".config", "postgresai", "config.json"), "utf8")
237
+ );
238
+ expect(saved.apiKey).toBe("test-token");
239
+ } finally {
240
+ rmSync(home, { recursive: true, force: true });
241
+ }
242
+ });
243
+
215
244
  test("cli: auth login --help shows all options", () => {
216
245
  const r = runCli(["auth", "login", "--help"]);
217
246
  expect(r.status).toBe(0);
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
14
14
  import Ajv2020 from "ajv/dist/2020";
15
15
 
16
16
  import * as checkup from "../lib/checkup";
17
+ import { checkCurrentUserPermissions } from "../lib/init";
17
18
 
18
19
  const ajv = new Ajv2020({ allErrors: true, strict: false });
19
20
  const schemasDir = resolve(import.meta.dir, "../../reporter/schemas");
@@ -191,6 +192,36 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
191
192
  // Test all checks supported by express mode
192
193
  const expressChecks = Object.keys(checkup.CHECK_INFO);
193
194
 
195
+ test("vanilla database preflight is non-fatal and full CLI output marks F004/F005 degraded", async () => {
196
+ const permissions = await checkCurrentUserPermissions(client);
197
+ expect(permissions.ok).toBe(true);
198
+ expect(permissions.missingOptional.some(
199
+ (row) => row.permission_name === "postgres_ai schema exists"
200
+ )).toBe(true);
201
+
202
+ const connString = `postgresql://postgres@localhost:${pg.port}/postgres?host=${encodeURIComponent(pg.socketDir)}`;
203
+ const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
204
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
205
+ const result = Bun.spawnSync(
206
+ [bunBin, cliPath, "checkup", connString, "--no-upload", "--json"],
207
+ { env: { ...process.env, XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" } }
208
+ );
209
+
210
+ const stderr = new TextDecoder().decode(result.stderr);
211
+ if (result.exitCode !== 0) {
212
+ throw new Error(`CLI exited ${result.exitCode}: ${stderr}`);
213
+ }
214
+ const reports = JSON.parse(new TextDecoder().decode(result.stdout));
215
+ expect(Object.keys(reports)).toHaveLength(17);
216
+ expect(stderr).toContain("optional: postgres_ai schema not found");
217
+ for (const checkId of ["F004", "F005"]) {
218
+ const dbEntry = reports[checkId].results["node-01"].data.postgres;
219
+ expect(dbEntry.status.ok).toBe(false);
220
+ expect(dbEntry.status.reason).toBe("missing_schema");
221
+ expect(dbEntry.status.error).toMatch(/schema "postgres_ai" does not exist/i);
222
+ }
223
+ }, { timeout: 60000 });
224
+
194
225
  for (const checkId of expressChecks) {
195
226
  test(`${checkId} report validates against shared schema`, async () => {
196
227
  const generator = checkup.REPORT_GENERATORS[checkId];
@@ -379,25 +410,4 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
379
410
  }
380
411
  });
381
412
 
382
- test("CLI --markdown flag works without API key", async () => {
383
- // Test that --markdown works even without an API key
384
- const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
385
- const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
386
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
387
-
388
- const result = Bun.spawnSync(
389
- [bunBin, cliPath, "checkup", connString, "--check-id", "H002", "--markdown", "--no-upload"],
390
- {
391
- env: {
392
- ...process.env,
393
- XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config",
394
- },
395
- }
396
- );
397
-
398
- const stderr = new TextDecoder().decode(result.stderr);
399
-
400
- // Should not complain about missing API key
401
- expect(stderr).not.toMatch(/API key is required/i);
402
- });
403
413
  });
@@ -1539,12 +1539,13 @@ describe("CLI tests", () => {
1539
1539
  const r = runCli(["checkup", "--help"]);
1540
1540
  expect(r.status).toBe(0);
1541
1541
  expect(r.stdout).toMatch(/--markdown/);
1542
- expect(r.stdout).toMatch(/output markdown to stdout/i);
1542
+ expect(r.stdout).toMatch(/PostgresAI API/i);
1543
+ expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
1543
1544
  });
1544
1545
 
1545
1546
  test("checkup --markdown is recognized as valid option", () => {
1546
1547
  // Should not produce "unknown option" error for --markdown
1547
- const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown", "--no-upload"]);
1548
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
1548
1549
  // Connection will fail, but option parsing should succeed
1549
1550
  expect(r.stderr).not.toMatch(/unknown option/i);
1550
1551
  expect(r.stderr).not.toMatch(/did you mean/i);
@@ -1554,11 +1555,13 @@ describe("CLI tests", () => {
1554
1555
  // Use empty config dir to ensure no API key is configured
1555
1556
  const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
1556
1557
  // --markdown should work even without API key
1557
- const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown", "--no-upload"], env);
1558
+ const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
1558
1559
  // Connection will fail, but --markdown flag should be recognized
1559
1560
  expect(r.status).not.toBe(0);
1560
1561
  expect(r.stderr).not.toMatch(/unknown option/i);
1561
1562
  expect(r.stderr).not.toMatch(/API key is required/i);
1563
+ expect(r.stderr).toMatch(/full report JSON will still be sent/i);
1564
+ expect(r.stderr).toMatch(/markdown conversion/i);
1562
1565
  });
1563
1566
 
1564
1567
  test("checkup with --no-upload and no output flags shows summary", () => {
@@ -1663,6 +1666,21 @@ describe("checkup auth pre-flight (CLI)", () => {
1663
1666
  // failure mentions this address.
1664
1667
  const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
1665
1668
 
1669
+ test("--no-upload rejects --markdown before database or API work", () => {
1670
+ const env = {
1671
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
1672
+ PGAI_API_KEY: "configured-token",
1673
+ PGAI_API_BASE_URL: "http://127.0.0.1:1",
1674
+ };
1675
+ const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
1676
+
1677
+ expect(r.status).not.toBe(0);
1678
+ expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
1679
+ expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
1680
+ expect(r.stderr).toMatch(/use --json or --output/i);
1681
+ expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
1682
+ });
1683
+
1666
1684
  test("no API key: prominent notice, run continues locally", () => {
1667
1685
  const env = {
1668
1686
  XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
@@ -2173,6 +2191,31 @@ describe("checkup-summary", () => {
2173
2191
  );
2174
2192
  });
2175
2193
 
2194
+ for (const checkId of ["F004", "F005"]) {
2195
+ test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
2196
+ const report = {
2197
+ results: {
2198
+ node1: {
2199
+ data: {
2200
+ db1: {
2201
+ status: {
2202
+ ok: false,
2203
+ reason: "missing_schema",
2204
+ error: 'schema "postgres_ai" does not exist',
2205
+ },
2206
+ total_count: 0,
2207
+ },
2208
+ },
2209
+ },
2210
+ },
2211
+ };
2212
+
2213
+ const result = summary.generateCheckSummary(checkId, report);
2214
+ expect(result.status).toBe("warning");
2215
+ expect(result.message).toMatch(/degraded.*missing schema/i);
2216
+ });
2217
+ }
2218
+
2176
2219
  test("generateCheckSummary for H001 with no issues", () => {
2177
2220
  const report = {
2178
2221
  results: {
@@ -3371,6 +3414,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
3371
3414
  autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
3372
3415
  });
3373
3416
  expect(reports.F004.results["test-node"].data.testdb).toEqual({
3417
+ status: { ok: true, reason: null, error: null },
3374
3418
  bloated_tables: [],
3375
3419
  total_count: 0,
3376
3420
  total_bloat_size_bytes: 0,
@@ -3379,6 +3423,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
3379
3423
  database_size_pretty: "1.00 GiB",
3380
3424
  });
3381
3425
  expect(reports.F005.results["test-node"].data.testdb).toEqual({
3426
+ status: { ok: true, reason: null, error: null },
3382
3427
  bloated_indexes: [],
3383
3428
  total_count: 0,
3384
3429
  total_bloat_size_bytes: 0,
package/test/init.test.ts CHANGED
@@ -1489,6 +1489,24 @@ describe("checkCurrentUserPermissions", () => {
1489
1489
  ).rejects.toThrow("permission denied for relation pg_roles");
1490
1490
  });
1491
1491
 
1492
+ test("guards optional postgres_ai privilege probes when the schema is absent", async () => {
1493
+ let capturedSql = "";
1494
+ const client = {
1495
+ query: async (sql: string) => {
1496
+ capturedSql = sql;
1497
+ return { rows: [] };
1498
+ },
1499
+ };
1500
+
1501
+ await init.checkCurrentUserPermissions(client as any);
1502
+
1503
+ expect(capturedSql).toContain("to_regnamespace('postgres_ai') is null");
1504
+ expect(capturedSql).toContain("'postgres_ai schema exists' as permission_name");
1505
+ expect(capturedSql).toMatch(
1506
+ /when to_regnamespace\('postgres_ai'\) is null then null\s+when not has_schema_privilege/
1507
+ );
1508
+ });
1509
+
1492
1510
  test("returns all rows for inspection", async () => {
1493
1511
  const rows: init.PermissionCheckRow[] = [
1494
1512
  { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
@@ -1545,6 +1563,23 @@ describe("formatPermissionCheckMessages", () => {
1545
1563
  expect(messages.errors).toHaveLength(0);
1546
1564
  });
1547
1565
 
1566
+ test("explains that a missing postgres_ai schema only degrades F004/F005", () => {
1567
+ const result: init.PreflightPermissionResult = {
1568
+ ok: true,
1569
+ rows: [],
1570
+ missingRequired: [],
1571
+ missingOptional: [
1572
+ { permission_name: "postgres_ai schema exists", status: "optional", granted: false, fix_command: null },
1573
+ ],
1574
+ };
1575
+
1576
+ const messages = init.formatPermissionCheckMessages(result);
1577
+ expect(messages.failed).toBe(false);
1578
+ expect(messages.warnings).toEqual([
1579
+ "Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.",
1580
+ ]);
1581
+ });
1582
+
1548
1583
  test("returns errors with fix commands for missing required permissions", () => {
1549
1584
  const result: init.PreflightPermissionResult = {
1550
1585
  ok: false,