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/storage.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
|
|
4
|
+
|
|
5
|
+
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
6
|
+
const MAX_DOWNLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
7
|
+
|
|
8
|
+
const MIME_TYPES: Record<string, string> = {
|
|
9
|
+
".png": "image/png",
|
|
10
|
+
".jpg": "image/jpeg",
|
|
11
|
+
".jpeg": "image/jpeg",
|
|
12
|
+
".gif": "image/gif",
|
|
13
|
+
".webp": "image/webp",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".bmp": "image/bmp",
|
|
16
|
+
".ico": "image/x-icon",
|
|
17
|
+
".pdf": "application/pdf",
|
|
18
|
+
".json": "application/json",
|
|
19
|
+
".xml": "application/xml",
|
|
20
|
+
".zip": "application/zip",
|
|
21
|
+
".gz": "application/gzip",
|
|
22
|
+
".tar": "application/x-tar",
|
|
23
|
+
".csv": "text/csv",
|
|
24
|
+
".txt": "text/plain",
|
|
25
|
+
".log": "text/plain",
|
|
26
|
+
".sql": "application/sql",
|
|
27
|
+
".html": "text/html",
|
|
28
|
+
".css": "text/css",
|
|
29
|
+
".js": "application/javascript",
|
|
30
|
+
".ts": "application/typescript",
|
|
31
|
+
".md": "text/markdown",
|
|
32
|
+
".yaml": "application/x-yaml",
|
|
33
|
+
".yml": "application/x-yaml",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface UploadFileMetadata {
|
|
37
|
+
originalName: string;
|
|
38
|
+
size: number;
|
|
39
|
+
mimeType: string;
|
|
40
|
+
uploadedAt: string;
|
|
41
|
+
duration: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UploadResult {
|
|
45
|
+
success: boolean;
|
|
46
|
+
url: string;
|
|
47
|
+
metadata: UploadFileMetadata;
|
|
48
|
+
requestId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UploadFileParams {
|
|
52
|
+
apiKey: string;
|
|
53
|
+
storageBaseUrl: string;
|
|
54
|
+
filePath: string;
|
|
55
|
+
debug?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Upload a file to the storage service.
|
|
60
|
+
*
|
|
61
|
+
* @param params.apiKey - API token for authentication
|
|
62
|
+
* @param params.storageBaseUrl - Storage service base URL
|
|
63
|
+
* @param params.filePath - Local file path to upload
|
|
64
|
+
* @param params.debug - Enable debug logging
|
|
65
|
+
* @returns Upload result with URL and metadata
|
|
66
|
+
*/
|
|
67
|
+
export async function uploadFile(params: UploadFileParams): Promise<UploadResult> {
|
|
68
|
+
const { apiKey, storageBaseUrl, filePath, debug } = params;
|
|
69
|
+
if (!apiKey) {
|
|
70
|
+
throw new Error("API key is required");
|
|
71
|
+
}
|
|
72
|
+
if (!storageBaseUrl) {
|
|
73
|
+
throw new Error("storageBaseUrl is required");
|
|
74
|
+
}
|
|
75
|
+
if (!filePath) {
|
|
76
|
+
throw new Error("filePath is required");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const resolvedPath = path.resolve(filePath);
|
|
80
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
81
|
+
throw new Error(`File not found: ${resolvedPath}`);
|
|
82
|
+
}
|
|
83
|
+
const stat = fs.statSync(resolvedPath);
|
|
84
|
+
if (!stat.isFile()) {
|
|
85
|
+
throw new Error(`Not a file: ${resolvedPath}`);
|
|
86
|
+
}
|
|
87
|
+
if (stat.size > MAX_UPLOAD_SIZE) {
|
|
88
|
+
throw new Error(`File too large: ${stat.size} bytes (max ${MAX_UPLOAD_SIZE})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const base = normalizeBaseUrl(storageBaseUrl);
|
|
92
|
+
// Warn but don't reject HTTP — CLI needs to work with localhost during development.
|
|
93
|
+
// Rejecting would require an --allow-insecure flag that adds friction for local setups.
|
|
94
|
+
if (new URL(base).protocol === "http:") {
|
|
95
|
+
console.error("Warning: storage URL uses HTTP — API key will be sent unencrypted");
|
|
96
|
+
}
|
|
97
|
+
const url = `${base}/upload`;
|
|
98
|
+
|
|
99
|
+
// readFileSync is intentional: FormData/Blob API requires the full buffer anyway,
|
|
100
|
+
// and the 500MB size check above prevents excessive memory use. Streaming would
|
|
101
|
+
// need a custom multipart encoder (no native stream support in fetch FormData).
|
|
102
|
+
const fileBuffer = fs.readFileSync(resolvedPath);
|
|
103
|
+
const fileName = path.basename(resolvedPath);
|
|
104
|
+
|
|
105
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
106
|
+
const mimeType = MIME_TYPES[ext] || "application/octet-stream";
|
|
107
|
+
|
|
108
|
+
const formData = new FormData();
|
|
109
|
+
formData.append("file", new Blob([fileBuffer], { type: mimeType }), fileName);
|
|
110
|
+
|
|
111
|
+
const headers: Record<string, string> = {
|
|
112
|
+
"access-token": apiKey,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (debug) {
|
|
116
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
117
|
+
console.error(`Debug: Storage base URL: ${base}`);
|
|
118
|
+
console.error(`Debug: POST URL: ${url}`);
|
|
119
|
+
console.error(`Debug: File: ${resolvedPath} (${stat.size} bytes, ${mimeType})`);
|
|
120
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const response = await fetch(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers,
|
|
126
|
+
body: formData,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (debug) {
|
|
130
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
131
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const data = await response.text();
|
|
135
|
+
|
|
136
|
+
if (response.ok) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(data) as UploadResult;
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error(`Failed to parse upload response: ${data}`);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
throw new Error(formatHttpError("Failed to upload file", response.status, data));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface DownloadFileParams {
|
|
148
|
+
apiKey: string;
|
|
149
|
+
storageBaseUrl: string;
|
|
150
|
+
fileUrl: string;
|
|
151
|
+
outputPath?: string;
|
|
152
|
+
debug?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface DownloadResult {
|
|
156
|
+
savedTo: string;
|
|
157
|
+
size: number;
|
|
158
|
+
mimeType: string | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Download a file from the storage service.
|
|
163
|
+
*
|
|
164
|
+
* @param params.apiKey - API token for authentication
|
|
165
|
+
* @param params.storageBaseUrl - Storage service base URL
|
|
166
|
+
* @param params.fileUrl - File URL path (e.g. /files/123/xxx.png) or full URL
|
|
167
|
+
* @param params.outputPath - Local path to save the file (default: derive from URL)
|
|
168
|
+
* @param params.debug - Enable debug logging
|
|
169
|
+
* @returns Download result with saved path and size
|
|
170
|
+
*/
|
|
171
|
+
export async function downloadFile(params: DownloadFileParams): Promise<DownloadResult> {
|
|
172
|
+
const { apiKey, storageBaseUrl, fileUrl, outputPath, debug } = params;
|
|
173
|
+
if (!apiKey) {
|
|
174
|
+
throw new Error("API key is required");
|
|
175
|
+
}
|
|
176
|
+
if (!storageBaseUrl) {
|
|
177
|
+
throw new Error("storageBaseUrl is required");
|
|
178
|
+
}
|
|
179
|
+
if (!fileUrl) {
|
|
180
|
+
throw new Error("fileUrl is required");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const base = normalizeBaseUrl(storageBaseUrl);
|
|
184
|
+
// Warn but don't reject HTTP — same rationale as uploadFile (localhost dev).
|
|
185
|
+
if (new URL(base).protocol === "http:") {
|
|
186
|
+
console.error("Warning: storage URL uses HTTP — API key will be sent unencrypted");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Support both full URLs and relative paths
|
|
190
|
+
let fullUrl: string;
|
|
191
|
+
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
|
|
192
|
+
if (!fileUrl.startsWith(base + "/")) {
|
|
193
|
+
throw new Error(`URL must be under storage base URL: ${base}`);
|
|
194
|
+
}
|
|
195
|
+
fullUrl = fileUrl;
|
|
196
|
+
} else {
|
|
197
|
+
// Relative path like /files/123/xxx.png
|
|
198
|
+
const relativePath = fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`;
|
|
199
|
+
fullUrl = `${base}${relativePath}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Derive output filename from URL if not specified
|
|
203
|
+
const urlFilename = path.basename(new URL(fullUrl).pathname);
|
|
204
|
+
if (!urlFilename) {
|
|
205
|
+
throw new Error("Cannot derive filename from URL; please specify --output");
|
|
206
|
+
}
|
|
207
|
+
const saveTo = outputPath ? path.resolve(outputPath) : path.resolve(urlFilename);
|
|
208
|
+
|
|
209
|
+
// Path traversal guard only for URL-derived filenames (untrusted input).
|
|
210
|
+
// Explicit --output (-o) is trusted: the user intentionally chose the path,
|
|
211
|
+
// and restricting it to cwd would break legitimate use (e.g. -o /tmp/file.png).
|
|
212
|
+
if (!outputPath) {
|
|
213
|
+
const normalizedSave = path.normalize(saveTo);
|
|
214
|
+
const cwd = path.normalize(process.cwd());
|
|
215
|
+
// Append path.sep so that cwd "/home/u/proj" doesn't allow a sibling
|
|
216
|
+
// "/home/u/proj-evil/x" via plain prefix match.
|
|
217
|
+
if (normalizedSave !== cwd && !normalizedSave.startsWith(cwd + path.sep)) {
|
|
218
|
+
throw new Error("Derived output path escapes current directory; please specify --output");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const headers: Record<string, string> = {
|
|
223
|
+
"access-token": apiKey,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (debug) {
|
|
227
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
228
|
+
console.error(`Debug: Storage base URL: ${base}`);
|
|
229
|
+
console.error(`Debug: GET URL: ${fullUrl}`);
|
|
230
|
+
console.error(`Debug: Output: ${saveTo}`);
|
|
231
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const response = await fetch(fullUrl, {
|
|
235
|
+
method: "GET",
|
|
236
|
+
headers,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (debug) {
|
|
240
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
241
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
const data = await response.text();
|
|
246
|
+
throw new Error(formatHttpError("Failed to download file", response.status, data));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const contentLength = response.headers.get("content-length");
|
|
250
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_SIZE) {
|
|
251
|
+
throw new Error(`File too large: ${contentLength} bytes (max ${MAX_DOWNLOAD_SIZE})`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
255
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
256
|
+
|
|
257
|
+
// Create parent dirs for the output path. recursive:true is intentional —
|
|
258
|
+
// the user may specify -o deeply/nested/path.png and expect it to work,
|
|
259
|
+
// same as curl --create-dirs or wget -P.
|
|
260
|
+
const parentDir = path.dirname(saveTo);
|
|
261
|
+
if (!fs.existsSync(parentDir)) {
|
|
262
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fs.writeFileSync(saveTo, buffer);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
savedTo: saveTo,
|
|
269
|
+
size: buffer.length,
|
|
270
|
+
mimeType: response.headers.get("content-type"),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build a markdown link for a file URL.
|
|
278
|
+
* Returns `` for images, `[name](url)` for other files.
|
|
279
|
+
*/
|
|
280
|
+
export function buildMarkdownLink(fileUrl: string, storageBaseUrl: string, filename?: string): string {
|
|
281
|
+
const base = normalizeBaseUrl(storageBaseUrl);
|
|
282
|
+
const normalizedFileUrl = fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`;
|
|
283
|
+
const fullUrl = (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) ? fileUrl : `${base}${normalizedFileUrl}`;
|
|
284
|
+
const name = filename || path.basename(new URL(fullUrl).pathname);
|
|
285
|
+
const safeName = name.replace(/[\[\]()]/g, "\\$&");
|
|
286
|
+
const ext = path.extname(name).toLowerCase();
|
|
287
|
+
// fullUrl is not escaped — storage URLs are server-generated (UUID-based paths
|
|
288
|
+
// like /files/641/1770646021425_019c42ba.png) and never contain parentheses.
|
|
289
|
+
// Escaping would break the URL for renderers that don't decode %29 in href.
|
|
290
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
291
|
+
return ``;
|
|
292
|
+
}
|
|
293
|
+
return `[${safeName}](${fullUrl})`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface UploadedAttachment {
|
|
297
|
+
path: string;
|
|
298
|
+
url: string;
|
|
299
|
+
markdown: string;
|
|
300
|
+
metadata: UploadFileMetadata;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface UploadAttachmentsParams {
|
|
304
|
+
apiKey: string;
|
|
305
|
+
storageBaseUrl: string;
|
|
306
|
+
attachmentPaths: string[];
|
|
307
|
+
debug?: boolean;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Upload a list of local files to storage and return one markdown link per file.
|
|
312
|
+
*
|
|
313
|
+
* Shared by both the CLI `--attach` flag and the MCP `attachments` parameter so
|
|
314
|
+
* that the two surfaces produce identical output. Uploads are sequential (not
|
|
315
|
+
* parallel) so that on failure of file N, the error from `uploadFile` (which
|
|
316
|
+
* includes the resolved path, e.g. `File not found: /abs/path`) pinpoints
|
|
317
|
+
* which file failed. Note: any files already uploaded successfully before
|
|
318
|
+
* the failure remain on the storage server; a retry of the same call will
|
|
319
|
+
* re-upload them.
|
|
320
|
+
*
|
|
321
|
+
* Returns an empty array if `attachmentPaths` is empty (callers don't have to
|
|
322
|
+
* guard).
|
|
323
|
+
*/
|
|
324
|
+
export async function uploadAttachments(params: UploadAttachmentsParams): Promise<UploadedAttachment[]> {
|
|
325
|
+
const { apiKey, storageBaseUrl, attachmentPaths, debug } = params;
|
|
326
|
+
if (!attachmentPaths || attachmentPaths.length === 0) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const out: UploadedAttachment[] = [];
|
|
330
|
+
for (const attachmentPath of attachmentPaths) {
|
|
331
|
+
const result = await uploadFile({
|
|
332
|
+
apiKey,
|
|
333
|
+
storageBaseUrl,
|
|
334
|
+
filePath: attachmentPath,
|
|
335
|
+
debug,
|
|
336
|
+
});
|
|
337
|
+
const markdown = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
|
|
338
|
+
out.push({
|
|
339
|
+
path: attachmentPath,
|
|
340
|
+
url: result.url,
|
|
341
|
+
markdown,
|
|
342
|
+
metadata: result.metadata,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Append uploaded-attachment markdown links to a body of content.
|
|
350
|
+
*
|
|
351
|
+
* - If `attachments` is empty, returns `content` unchanged.
|
|
352
|
+
* - If `content` is empty/whitespace, returns just the links.
|
|
353
|
+
* - Otherwise: `${content}\n\n${links joined by \n}`.
|
|
354
|
+
*
|
|
355
|
+
* One link per line keeps the renderer happy whether the surface is GFM
|
|
356
|
+
* (which collapses adjacent lines) or strict CommonMark.
|
|
357
|
+
*/
|
|
358
|
+
export function appendAttachmentsToContent(content: string, attachments: UploadedAttachment[]): string {
|
|
359
|
+
if (!attachments || attachments.length === 0) {
|
|
360
|
+
return content;
|
|
361
|
+
}
|
|
362
|
+
const links = attachments.map((a) => a.markdown).join("\n");
|
|
363
|
+
if (!content || !content.trim()) {
|
|
364
|
+
return links;
|
|
365
|
+
}
|
|
366
|
+
return `${content}\n\n${links}`;
|
|
367
|
+
}
|
package/lib/supabase.ts
CHANGED
|
@@ -350,6 +350,10 @@ export async function fetchPoolerDatabaseUrl(
|
|
|
350
350
|
config: SupabaseConfig,
|
|
351
351
|
username: string
|
|
352
352
|
): Promise<string | null> {
|
|
353
|
+
// Validate projectRef format to prevent SSRF via crafted project references
|
|
354
|
+
if (!isValidProjectRef(config.projectRef)) {
|
|
355
|
+
throw new Error(`Invalid Supabase project reference format: "${config.projectRef}". Expected 10-30 alphanumeric characters.`);
|
|
356
|
+
}
|
|
353
357
|
const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(config.projectRef)}/config/database/pooler`;
|
|
354
358
|
|
|
355
359
|
// For Supabase pooler connections, the username must include the project ref:
|
|
@@ -669,7 +673,10 @@ export async function verifyInitSetupViaSupabase(params: {
|
|
|
669
673
|
|
|
670
674
|
// Check pg_statistic view
|
|
671
675
|
const viewExistsRes = await params.client.query(
|
|
672
|
-
|
|
676
|
+
`SELECT CASE
|
|
677
|
+
WHEN NOT has_schema_privilege(current_user, 'postgres_ai', 'USAGE') THEN NULL
|
|
678
|
+
ELSE to_regclass('postgres_ai.pg_statistic') IS NOT NULL
|
|
679
|
+
END as ok`,
|
|
673
680
|
true
|
|
674
681
|
);
|
|
675
682
|
if (!viewExistsRes.rows?.[0]?.ok) {
|
package/lib/util.ts
CHANGED
|
@@ -70,15 +70,18 @@ export function maskSecret(secret: string): string {
|
|
|
70
70
|
export interface RootOptsLike {
|
|
71
71
|
apiBaseUrl?: string;
|
|
72
72
|
uiBaseUrl?: string;
|
|
73
|
+
storageBaseUrl?: string;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
export interface ConfigLike {
|
|
76
77
|
baseUrl?: string | null;
|
|
78
|
+
storageBaseUrl?: string | null;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export interface ResolvedBaseUrls {
|
|
80
82
|
apiBaseUrl: string;
|
|
81
83
|
uiBaseUrl: string;
|
|
84
|
+
storageBaseUrl: string;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
@@ -105,17 +108,20 @@ export function normalizeBaseUrl(value: string): string {
|
|
|
105
108
|
export function resolveBaseUrls(
|
|
106
109
|
opts?: RootOptsLike,
|
|
107
110
|
cfg?: ConfigLike,
|
|
108
|
-
defaults: { apiBaseUrl?: string; uiBaseUrl?: string } = {}
|
|
111
|
+
defaults: { apiBaseUrl?: string; uiBaseUrl?: string; storageBaseUrl?: string } = {}
|
|
109
112
|
): ResolvedBaseUrls {
|
|
110
113
|
const defApi = defaults.apiBaseUrl || "https://postgres.ai/api/general/";
|
|
111
114
|
const defUi = defaults.uiBaseUrl || "https://console.postgres.ai";
|
|
115
|
+
const defStorage = defaults.storageBaseUrl || "https://postgres.ai/storage";
|
|
112
116
|
|
|
113
117
|
const apiCandidate = (opts?.apiBaseUrl || process.env.PGAI_API_BASE_URL || cfg?.baseUrl || defApi) as string;
|
|
114
118
|
const uiCandidate = (opts?.uiBaseUrl || process.env.PGAI_UI_BASE_URL || defUi) as string;
|
|
119
|
+
const storageCandidate = (opts?.storageBaseUrl || process.env.PGAI_STORAGE_BASE_URL || cfg?.storageBaseUrl || defStorage) as string;
|
|
115
120
|
|
|
116
121
|
return {
|
|
117
122
|
apiBaseUrl: normalizeBaseUrl(apiCandidate),
|
|
118
123
|
uiBaseUrl: normalizeBaseUrl(uiCandidate),
|
|
124
|
+
storageBaseUrl: normalizeBaseUrl(storageCandidate),
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postgresai",
|
|
3
|
-
"version": "0.15.0-dev.
|
|
3
|
+
"version": "0.15.0-dev.11",
|
|
4
4
|
"description": "postgres_ai CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"embed-metrics": "bun run scripts/embed-metrics.ts",
|
|
29
29
|
"embed-checkup-dictionary": "bun run scripts/embed-checkup-dictionary.ts",
|
|
30
30
|
"embed-all": "bun run embed-metrics && bun run embed-checkup-dictionary",
|
|
31
|
-
"build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql",
|
|
31
|
+
"build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql && cp ../instances.demo.yml ./instances.demo.yml",
|
|
32
32
|
"prepublishOnly": "npm run build",
|
|
33
33
|
"start": "bun ./bin/postgres-ai.ts --help",
|
|
34
34
|
"start:node": "node ./dist/bin/postgres-ai.js --help",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
44
|
-
"commander": "^
|
|
44
|
+
"commander": "^14.0.3",
|
|
45
45
|
"js-yaml": "^4.1.0",
|
|
46
46
|
"pg": "^8.16.3"
|
|
47
47
|
},
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@types/pg": "^8.15.6",
|
|
52
52
|
"ajv": "^8.17.1",
|
|
53
53
|
"ajv-formats": "^3.0.1",
|
|
54
|
-
"typescript": "^
|
|
54
|
+
"typescript": "^6.0.2"
|
|
55
55
|
},
|
|
56
56
|
"publishConfig": {
|
|
57
57
|
"access": "public"
|
|
@@ -51,7 +51,16 @@ function generateTypeScript(data: CheckupDictionaryEntry[], sourceUrl: string):
|
|
|
51
51
|
return lines.join("\n");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Allowed hosts for fetch requests to prevent SSRF
|
|
55
|
+
const ALLOWED_HOSTS = ["postgres.ai"];
|
|
56
|
+
|
|
54
57
|
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
|
|
58
|
+
// Validate URL against allowlist to prevent SSRF
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
|
|
61
|
+
throw new Error(`Fetch blocked: host "${parsed.hostname}" is not in the allowlist`);
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
const controller = new AbortController();
|
|
56
65
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
57
66
|
|
package/scripts/embed-metrics.ts
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Permission Check Test Summary
|
|
2
|
+
|
|
3
|
+
## Changes Made
|
|
4
|
+
|
|
5
|
+
Changed all references from `public.pg_statistic` to `postgres_ai.pg_statistic` in:
|
|
6
|
+
- `cli/lib/init.ts` - Permission check SQL query
|
|
7
|
+
- `cli/test/init.test.ts` - All test expectations (28 occurrences)
|
|
8
|
+
|
|
9
|
+
## Key Fix: Safe Schema Checking
|
|
10
|
+
|
|
11
|
+
**Before (883fa95):**
|
|
12
|
+
```sql
|
|
13
|
+
exists (
|
|
14
|
+
select from pg_views
|
|
15
|
+
where schemaname = 'public' and viewname = 'pg_statistic'
|
|
16
|
+
) as granted
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**After (6db79f6) - INCORRECT, caused crashes:**
|
|
20
|
+
```sql
|
|
21
|
+
to_regclass('postgres_ai.pg_statistic') is not null as granted
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Current (this fix):**
|
|
25
|
+
```sql
|
|
26
|
+
case
|
|
27
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
28
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
29
|
+
end as granted
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Why this fix matters
|
|
33
|
+
|
|
34
|
+
**Issue with bare `to_regclass()`:**
|
|
35
|
+
- Returns NULL when the schema doesn't exist ✓
|
|
36
|
+
- Returns NULL when the view doesn't exist ✓
|
|
37
|
+
- **Throws error** when the schema exists but user lacks USAGE privilege ✗
|
|
38
|
+
|
|
39
|
+
**Fix:**
|
|
40
|
+
- Check `has_schema_privilege()` first to avoid the permission error
|
|
41
|
+
- Returns NULL safely in all cases where we can't check the view
|
|
42
|
+
- Prevents crashes when postgres_ai schema exists but user lacks USAGE
|
|
43
|
+
|
|
44
|
+
## Test Results
|
|
45
|
+
|
|
46
|
+
### Unit Tests ✅
|
|
47
|
+
```
|
|
48
|
+
✓ 95 tests passed across 3 files
|
|
49
|
+
- 84 tests in init.test.ts (including 9 checkCurrentUserPermissions tests)
|
|
50
|
+
- 2 tests in config-consistency.test.ts
|
|
51
|
+
- 9 tests in permission-check-sql.test.ts
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Expected Behavior by Scenario
|
|
55
|
+
|
|
56
|
+
| Scenario | User Permissions | postgres_ai Schema | Expected Result |
|
|
57
|
+
|----------|-----------------|-------------------|-----------------|
|
|
58
|
+
| 1. Superuser | superuser + postgres_ai.pg_statistic | ✓ Exists | ✅ PASS (clean) |
|
|
59
|
+
| 2. pg_monitor, no schema access | pg_monitor only | ✗ No USAGE | ✅ PASS (warning) |
|
|
60
|
+
| 3. No pg_monitor | minimal permissions | ✗ Doesn't exist | ✅ PASS (error + fix SQL) |
|
|
61
|
+
| 8. After prepare-db | pg_monitor + postgres_ai grants | ✓ Exists + SELECT | ✅ PASS (clean) |
|
|
62
|
+
|
|
63
|
+
### SQL Behavior Verification
|
|
64
|
+
|
|
65
|
+
**Scenario 2 & 3: Schema doesn't exist or no USAGE**
|
|
66
|
+
```sql
|
|
67
|
+
-- Check privilege first, then to_regclass (no crash)
|
|
68
|
+
case
|
|
69
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
70
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
71
|
+
end → NULL
|
|
72
|
+
|
|
73
|
+
-- SELECT check is skipped (returns NULL, not treated as missing optional)
|
|
74
|
+
case
|
|
75
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
76
|
+
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
77
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
78
|
+
end → NULL
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Scenario 1 & 8: Schema exists with proper grants**
|
|
82
|
+
```sql
|
|
83
|
+
-- User has USAGE, to_regclass returns OID (view is visible)
|
|
84
|
+
case
|
|
85
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
86
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
87
|
+
end → TRUE
|
|
88
|
+
|
|
89
|
+
-- SELECT check is performed
|
|
90
|
+
has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select') → TRUE/FALSE
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Integration Test Limitations
|
|
94
|
+
|
|
95
|
+
Integration tests cannot run due to locale configuration issues with `initdb`:
|
|
96
|
+
```
|
|
97
|
+
error: initdb: error: invalid locale settings; check LANG and LC_* environment variables
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
However, unit tests provide comprehensive coverage of the permission check logic, including:
|
|
101
|
+
- All permission scenarios (granted, denied, skipped)
|
|
102
|
+
- Multiple missing permissions
|
|
103
|
+
- Error propagation
|
|
104
|
+
- Fix command generation
|
|
105
|
+
- Message formatting
|
|
106
|
+
|
|
107
|
+
## Schema Consistency
|
|
108
|
+
|
|
109
|
+
The change ensures consistency across the codebase:
|
|
110
|
+
- ✅ `cli/lib/init.ts` - now checks postgres_ai.pg_statistic
|
|
111
|
+
- ✅ `cli/lib/supabase.ts` - already checks postgres_ai.pg_statistic
|
|
112
|
+
- ✅ `cli/sql/03.permissions.sql` - creates postgres_ai.pg_statistic
|
|
113
|
+
- ✅ `config/target-db/init.sql` - creates postgres_ai.pg_statistic
|
|
114
|
+
- ✅ `config/pgwatch-prometheus/metrics.yml` - references postgres_ai.pg_statistic
|
|
115
|
+
|
|
116
|
+
## Commits
|
|
117
|
+
|
|
118
|
+
1. **955cff2** - `fix: change public.pg_statistic to postgres_ai.pg_statistic`
|
|
119
|
+
- Updated permission check queries
|
|
120
|
+
- Updated all test expectations
|
|
121
|
+
|
|
122
|
+
2. **6db79f6** - `fix: use to_regclass() for safe postgres_ai.pg_statistic check`
|
|
123
|
+
- Replaced pg_views query with to_regclass()
|
|
124
|
+
- ⚠️ This introduced a bug: crashes when schema exists but user lacks USAGE
|
|
125
|
+
|
|
126
|
+
3. **[current]** - `fix: wrap to_regclass() with has_schema_privilege() check`
|
|
127
|
+
- Fixed crash when postgres_ai schema exists but user lacks USAGE privilege
|
|
128
|
+
- Added privilege check before calling to_regclass() in all locations
|
|
129
|
+
- Updated in: init.ts (3 places) and supabase.ts (1 place)
|
|
130
|
+
|
|
131
|
+
## Verification Command
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Run all permission-related tests
|
|
135
|
+
bun test test/init.test.ts test/config-consistency.test.ts test/permission-check-sql.test.ts
|
|
136
|
+
|
|
137
|
+
# Verify no public.pg_statistic references remain (except in comments)
|
|
138
|
+
git grep -n 'public\.pg_statistic' cli/
|
|
139
|
+
```
|