diff --git a/cli/bin/postgres-ai.ts b/cli/bin/postgres-ai.ts index 74487a0..3a8b769 100644 --- a/cli/bin/postgres-ai.ts +++ b/cli/bin/postgres-ai.ts @@ -13,13 +13,12 @@ import { Client } from "pg"; import { startMcpServer } from "../lib/mcp-server"; import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues"; import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports"; -import { resolveBaseUrls } from "../lib/util"; +import { maskSecret, resolveBaseUrls } from "../lib/util"; import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage"; import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init"; import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase"; import * as pkce from "../lib/pkce"; import * as authServer from "../lib/auth-server"; -import { maskSecret } from "../lib/util"; import { createInterface } from "readline"; import * as childProcess from "child_process"; import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup"; diff --git a/cli/lib/init.ts b/cli/lib/init.ts index e210e5d..a34849b 100644 --- a/cli/lib/init.ts +++ b/cli/lib/init.ts @@ -27,6 +27,30 @@ const SKIP_ALTER_USER_PROVIDERS = ["supabase"]; /** Providers where we skip search_path verification (not set via ALTER USER). */ const SKIP_SEARCH_PATH_CHECK_PROVIDERS = ["supabase"]; +/** + * The set of node-postgres error fields we propagate when wrapping a database + * error in a higher-level message. Mirrors the diagnostic columns from + * PostgreSQL's error response so callers can produce better hints. Exported so + * other modules (e.g., lib/supabase.ts) share one source of truth. + */ +export const PG_ERROR_FIELDS = [ + "code", + "detail", + "hint", + "position", + "internalPosition", + "internalQuery", + "where", + "schema", + "table", + "column", + "dataType", + "constraint", + "file", + "line", + "routine", +] as const; + /** Check if a provider is known and return a warning message if not. */ export function validateProvider(provider: string | undefined): string | null { if (!provider || KNOWN_PROVIDERS.includes(provider as any)) return null; @@ -587,6 +611,30 @@ end $$;`; return { monitoringUser, database, steps }; } +/** + * Run one InitStep inside an explicit BEGIN/COMMIT block. On failure issues a + * ROLLBACK (errors from rollback are swallowed so they don't mask the original + * failure) and re-throws. + * + * Shared by both applyInitPlan and applyUninitPlan — previously each had its + * own identical local helper. + */ +async function executeStepInTransaction(client: PgClient, step: InitStep): Promise { + await client.query("begin;"); + try { + await client.query(step.sql, step.params as any); + await client.query("commit;"); + } catch (e) { + // Rollback errors should never mask the original failure. + try { + await client.query("rollback;"); + } catch { + // ignore + } + throw e; + } +} + export async function applyInitPlan(params: { client: PgClient; plan: InitPlan; @@ -595,22 +643,7 @@ export async function applyInitPlan(params: { const applied: string[] = []; const skippedOptional: string[] = []; - // Helper to wrap a step execution in begin/commit - const executeStep = async (step: InitStep): Promise => { - await params.client.query("begin;"); - try { - await params.client.query(step.sql, step.params as any); - await params.client.query("commit;"); - } catch (e) { - // Rollback errors should never mask the original failure. - try { - await params.client.query("rollback;"); - } catch { - // ignore - } - throw e; - } - }; + const executeStep = (step: InitStep): Promise => executeStepInTransaction(params.client, step); // Apply non-optional steps, each in its own transaction for (const step of params.plan.steps.filter((s) => !s.optional)) { @@ -622,25 +655,8 @@ export async function applyInitPlan(params: { const errAny = e as any; const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`); // Preserve useful Postgres error fields so callers can provide better hints / diagnostics. - const pgErrorFields = [ - "code", - "detail", - "hint", - "position", - "internalPosition", - "internalQuery", - "where", - "schema", - "table", - "column", - "dataType", - "constraint", - "file", - "line", - "routine", - ] as const; if (errAny && typeof errAny === "object") { - for (const field of pgErrorFields) { + for (const field of PG_ERROR_FIELDS) { if (errAny[field] !== undefined) wrapped[field] = errAny[field]; } } @@ -764,21 +780,7 @@ export async function applyUninitPlan(params: { const applied: string[] = []; const errors: string[] = []; - // Helper to wrap a step execution in begin/commit - const executeStep = async (step: InitStep): Promise => { - await params.client.query("begin;"); - try { - await params.client.query(step.sql, step.params as any); - await params.client.query("commit;"); - } catch (e) { - try { - await params.client.query("rollback;"); - } catch { - // ignore - } - throw e; - } - }; + const executeStep = (step: InitStep): Promise => executeStepInTransaction(params.client, step); // Apply steps in order - unlike init, uninit steps are not optional // but we continue on errors to clean up as much as possible diff --git a/cli/lib/issues.ts b/cli/lib/issues.ts index e940aaf..92f9adb 100644 --- a/cli/lib/issues.ts +++ b/cli/lib/issues.ts @@ -1,4 +1,4 @@ -import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util"; +import { buildApiHeaders, debugLogRequest, debugLogResponse, formatHttpError, normalizeBaseUrl } from "./util"; /** * Issue status constants. @@ -11,6 +11,12 @@ export const IssueStatus = { CLOSED: 1, } as const; +/** + * UUID v4-ish regex used to validate IDs passed to PostgREST queries (defense + * against PostgREST filter injection by limiting allowed characters). + */ +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** * Represents a PostgreSQL configuration parameter change recommendation. * Used in action items to suggest config tuning. @@ -121,30 +127,16 @@ export async function fetchIssues(params: FetchIssuesParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -179,30 +171,16 @@ export async function fetchIssueComments(params: FetchIssueCommentsParams): Prom const base = normalizeBaseUrl(apiBaseUrl); const url = new URL(`${base}/issue_comments?issue_id=eq.${encodeURIComponent(issueId)}`); - const headers: Record = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -239,30 +217,16 @@ export async function fetchIssue(params: FetchIssueParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -360,21 +324,9 @@ export async function createIssue(params: CreateIssueParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: POST URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - console.error(`Debug: Request body: ${body}`); - } + debugLogRequest(debug, { base, method: "POST", url: url.toString(), headers, apiKey, body }); const response = await fetch(url.toString(), { method: "POST", @@ -382,10 +334,7 @@ export async function createIssue(params: CreateIssueParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: POST URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - console.error(`Debug: Request body: ${body}`); - } + debugLogRequest(debug, { base, method: "POST", url: url.toString(), headers, apiKey, body }); const response = await fetch(url.toString(), { method: "POST", @@ -455,10 +392,7 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom body, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -541,21 +475,9 @@ export async function updateIssue(params: UpdateIssueParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: POST URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - console.error(`Debug: Request body: ${body}`); - } + debugLogRequest(debug, { base, method: "POST", url: url.toString(), headers, apiKey, body }); const response = await fetch(url.toString(), { method: "POST", @@ -563,10 +485,7 @@ export async function updateIssue(params: UpdateIssueParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: POST URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - console.error(`Debug: Request body: ${body}`); - } + debugLogRequest(debug, { base, method: "POST", url: url.toString(), headers, apiKey, body }); const response = await fetch(url.toString(), { method: "POST", @@ -652,10 +559,7 @@ export async function updateIssueComment(params: UpdateIssueCommentParams): Prom body, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -706,14 +610,12 @@ export async function fetchActionItem(params: FetchActionItemParams): Promise id != null && typeof id === "string") .map(id => id.trim()) - .filter(id => id.length > 0 && uuidPattern.test(id)); + .filter(id => id.length > 0 && UUID_PATTERN.test(id)); if (validIds.length === 0) { throw new Error("actionItemId is required and must be a valid UUID"); } @@ -727,30 +629,16 @@ export async function fetchActionItem(params: FetchActionItemParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -796,8 +684,7 @@ export async function fetchActionItems(params: FetchActionItemsParams): Promise< throw new Error("issueId is required"); } // Validate UUID format to prevent PostgREST injection - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidPattern.test(issueId.trim())) { + if (!UUID_PATTERN.test(issueId.trim())) { throw new Error("issueId must be a valid UUID"); } @@ -805,30 +692,16 @@ export async function fetchActionItems(params: FetchActionItemsParams): Promise< const url = new URL(`${base}/issue_action_items`); url.searchParams.set("issue_id", `eq.${issueId.trim()}`); - const headers: Record = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -878,8 +751,7 @@ export async function createActionItem(params: CreateActionItemParams): Promise< throw new Error("issueId is required"); } // Validate UUID format - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidPattern.test(issueId.trim())) { + if (!UUID_PATTERN.test(issueId.trim())) { throw new Error("issueId must be a valid UUID"); } if (!title) { @@ -904,21 +776,9 @@ export async function createActionItem(params: CreateActionItemParams): Promise< } const body = JSON.stringify(bodyObj); - const headers: Record = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: POST URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - console.error(`Debug: Request body: ${body}`); - } + debugLogRequest(debug, { base, method: "POST", url: url.toString(), headers, apiKey, body }); const response = await fetch(url.toString(), { method: "POST", @@ -926,10 +786,7 @@ export async function createActionItem(params: CreateActionItemParams): Promise< body, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -984,8 +841,7 @@ export async function updateActionItem(params: UpdateActionItemParams): Promise< throw new Error("actionItemId is required"); } // Validate UUID format - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidPattern.test(actionItemId.trim())) { + if (!UUID_PATTERN.test(actionItemId.trim())) { throw new Error("actionItemId must be a valid UUID"); } @@ -1026,21 +882,9 @@ export async function updateActionItem(params: UpdateActionItemParams): Promise< } const body = JSON.stringify(bodyObj); - const headers: Record = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; + const headers = buildApiHeaders(apiKey); - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: POST URL: ${url.toString()}`); - console.error(`Debug: Auth scheme: access-token`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - console.error(`Debug: Request body: ${body}`); - } + debugLogRequest(debug, { base, method: "POST", url: url.toString(), headers, apiKey, body }); const response = await fetch(url.toString(), { method: "POST", @@ -1048,10 +892,7 @@ export async function updateActionItem(params: UpdateActionItemParams): Promise< body, }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); - } + debugLogResponse(debug, response); if (!response.ok) { const data = await response.text(); diff --git a/cli/lib/reports.ts b/cli/lib/reports.ts index 55b782c..89b96fc 100644 --- a/cli/lib/reports.ts +++ b/cli/lib/reports.ts @@ -1,4 +1,4 @@ -import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util"; +import { buildApiHeaders, debugLogRequest, debugLogResponse, formatHttpError, normalizeBaseUrl } from "./util"; // ============================================================================ // Types @@ -134,25 +134,13 @@ export async function fetchReports(params: FetchReportsParams): Promise = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; - - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + const headers = buildApiHeaders(apiKey); + + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -211,25 +199,13 @@ export async function fetchReportFiles(params: FetchReportFilesParams): Promise< url.searchParams.set("check_id", `eq.${checkId}`); } - const headers: Record = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; - - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + const headers = buildApiHeaders(apiKey); + + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - } + debugLogResponse(debug, response); const data = await response.text(); @@ -266,25 +242,13 @@ export async function fetchReportFileData(params: FetchReportFileDataParams): Pr url.searchParams.set("check_id", `eq.${checkId}`); } - const headers: Record = { - "access-token": apiKey, - "Prefer": "return=representation", - "Content-Type": "application/json", - "Connection": "close", - }; - - if (debug) { - const debugHeaders: Record = { ...headers, "access-token": maskSecret(apiKey) }; - console.error(`Debug: Resolved API base URL: ${base}`); - console.error(`Debug: GET URL: ${url.toString()}`); - console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); - } + const headers = buildApiHeaders(apiKey); + + debugLogRequest(debug, { base, method: "GET", url: url.toString(), headers, apiKey }); const response = await fetch(url.toString(), { method: "GET", headers }); - if (debug) { - console.error(`Debug: Response status: ${response.status}`); - } + debugLogResponse(debug, response); const data = await response.text(); diff --git a/cli/lib/supabase.ts b/cli/lib/supabase.ts index f025622..8b58667 100644 --- a/cli/lib/supabase.ts +++ b/cli/lib/supabase.ts @@ -8,6 +8,8 @@ * Endpoint: POST /v1/projects/{ref}/database/query */ +import { PG_ERROR_FIELDS } from "./init"; + const SUPABASE_API_BASE = "https://api.supabase.com"; export type SupabaseConfig = { @@ -531,28 +533,11 @@ export async function applyInitPlanViaSupabase(params: { `Failed at step "${step.name}": ${msg}` ) as PgCompatibleError; - // Preserve PostgreSQL error fields for consistent error handling - const pgErrorFields = [ - "code", - "detail", - "hint", - "position", - "internalPosition", - "internalQuery", - "where", - "schema", - "table", - "column", - "dataType", - "constraint", - "file", - "line", - "routine", - "httpStatus", - "supabaseErrorCode", - ] as const; - - for (const field of pgErrorFields) { + // Preserve PostgreSQL error fields for consistent error handling. + // Use the shared PG_ERROR_FIELDS list plus Supabase-specific fields. + const supabaseExtraFields = ["httpStatus", "supabaseErrorCode"] as const; + const allFields = [...PG_ERROR_FIELDS, ...supabaseExtraFields] as const; + for (const field of allFields) { if (errAny[field] !== undefined) { (wrapped as unknown as Record)[field] = errAny[field]; } diff --git a/cli/lib/util.ts b/cli/lib/util.ts index 4259243..0b36fa0 100644 --- a/cli/lib/util.ts +++ b/cli/lib/util.ts @@ -66,6 +66,62 @@ export function maskSecret(secret: string): string { return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`; } +/** + * Build the standard PostgREST-compatible request headers used across the + * lib/issues.ts, lib/reports.ts API call sites. Centralizes the (previously + * duplicated) header object so future header changes only need one edit. + * + * Caller may pass additional headers via `extra` which are merged on top. + */ +export function buildApiHeaders(apiKey: string, extra?: Record): Record { + return { + "access-token": apiKey, + "Prefer": "return=representation", + "Content-Type": "application/json", + "Connection": "close", + ...(extra || {}), + }; +} + +/** + * Emit standard debug logs for an outgoing API request. + * No-op when `debug` is falsy. + * + * Replaces the previously duplicated "Debug: Resolved API base URL / METHOD URL / + * Auth scheme / Request headers / Request body" blocks scattered across + * lib/issues.ts and lib/reports.ts. + */ +export function debugLogRequest( + debug: boolean | undefined, + params: { + base: string; + method: string; + url: string; + headers: Record; + apiKey: string; + body?: string; + } +): void { + if (!debug) return; + const debugHeaders: Record = { ...params.headers, "access-token": maskSecret(params.apiKey) }; + console.error(`Debug: Resolved API base URL: ${params.base}`); + console.error(`Debug: ${params.method} URL: ${params.url}`); + console.error(`Debug: Auth scheme: access-token`); + console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); + if (params.body !== undefined) { + console.error(`Debug: Request body: ${params.body}`); + } +} + +/** + * Emit standard debug logs for an incoming API response. + * No-op when `debug` is falsy. + */ +export function debugLogResponse(debug: boolean | undefined, response: Response): void { + if (!debug) return; + console.error(`Debug: Response status: ${response.status}`); + console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); +} export interface RootOptsLike { apiBaseUrl?: string;