@@ -65,6 +65,15 @@ type SupabaseApiResponse = {
6565 // The API returns the result directly (array) on success
6666} | Record < string , unknown > [ ] ;
6767
68+ /**
69+ * Validate Supabase project reference format.
70+ * Project refs are typically 20 lowercase alphanumeric characters.
71+ */
72+ function isValidProjectRef ( ref : string ) : boolean {
73+ // Supabase project refs are alphanumeric, typically 20 chars, lowercase
74+ return / ^ [ a - z 0 - 9 ] { 10 , 30 } $ / i. test ( ref ) ;
75+ }
76+
6877/**
6978 * Supabase Management API client for executing SQL queries.
7079 */
@@ -78,19 +87,24 @@ export class SupabaseClient {
7887 if ( ! config . accessToken ) {
7988 throw new Error ( "Supabase access token is required" ) ;
8089 }
90+ // Validate project ref format to prevent path traversal
91+ if ( ! isValidProjectRef ( config . projectRef ) ) {
92+ throw new Error ( `Invalid Supabase project reference format: "${ config . projectRef } ". Expected 10-30 alphanumeric characters.` ) ;
93+ }
8194 this . config = config ;
8295 }
8396
8497 /**
8598 * Execute a SQL query via the Supabase Management API.
8699 *
87100 * @param sql The SQL query to execute
88- * @param readOnly Whether this is a read-only query (default: false for DDL/DML)
89- * @returns Query result with rows and rowCount
101+ * @param readOnly If true, uses read_only flag in API request (default: false for DDL/DML operations )
102+ * @returns Query result with rows and rowCount (rowCount is array length for SELECT queries)
90103 * @throws PgCompatibleError on failure
91104 */
92105 async query ( sql : string , readOnly = false ) : Promise < SupabaseQueryResult > {
93- const url = `${ SUPABASE_API_BASE } /v1/projects/${ this . config . projectRef } /database/query` ;
106+ // URL-encode projectRef for safety (validated in constructor, but defense in depth)
107+ const url = `${ SUPABASE_API_BASE } /v1/projects/${ encodeURIComponent ( this . config . projectRef ) } /database/query` ;
94108
95109 const response = await fetch ( url , {
96110 method : "POST" ,
@@ -356,7 +370,9 @@ export function resolveSupabaseConfig(opts: {
356370/**
357371 * Extract project reference from a Supabase database URL.
358372 * Supabase database URLs typically look like:
359- * postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
373+ * - Direct: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
374+ * - Pooler (modern): postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
375+ * - Pooler (legacy): postgresql://postgres:[PASSWORD]@[PROJECT_REF].pooler.supabase.com:6543/postgres
360376 *
361377 * @param dbUrl PostgreSQL connection URL
362378 * @returns Project reference if found, undefined otherwise
@@ -366,15 +382,25 @@ export function extractProjectRefFromUrl(dbUrl: string): string | undefined {
366382 const url = new URL ( dbUrl ) ;
367383 const host = url . hostname ;
368384
369- // Match db.<ref>.supabase.co or <ref>.supabase.co patterns
385+ // Match db.<ref>.supabase.co or <ref>.supabase.co patterns (direct connection)
370386 const match = host . match ( / ^ (?: d b \. ) ? ( [ ^ . ] + ) \. s u p a b a s e \. c o $ / i) ;
371387 if ( match && match [ 1 ] ) {
372388 return match [ 1 ] ;
373389 }
374390
375- // Also check for pooler URLs: <project-ref>.pooler.supabase.com
376- const poolerMatch = host . match ( / ^ ( [ ^ . ] + ) \. p o o l e r \. s u p a b a s e \. c o m $ / i) ;
377- if ( poolerMatch && poolerMatch [ 1 ] ) {
391+ // Modern pooler URLs: project ref is in the username as postgres.<ref>
392+ // Example: postgresql://postgres.abcdefghij:password@aws -0-us-east-1.pooler.supabase.com:6543/postgres
393+ if ( host . includes ( "pooler.supabase.com" ) ) {
394+ const username = url . username ;
395+ const userMatch = username . match ( / ^ p o s t g r e s \. ( [ a - z 0 - 9 ] + ) $ / i) ;
396+ if ( userMatch && userMatch [ 1 ] ) {
397+ return userMatch [ 1 ] ;
398+ }
399+ }
400+
401+ // Legacy pooler URLs: <project-ref>.pooler.supabase.com (fallback)
402+ const poolerMatch = host . match ( / ^ ( [ a - z 0 - 9 ] + ) \. p o o l e r \. s u p a b a s e \. c o m $ / i) ;
403+ if ( poolerMatch && poolerMatch [ 1 ] && ! poolerMatch [ 1 ] . startsWith ( "aws-" ) ) {
378404 return poolerMatch [ 1 ] ;
379405 }
380406
@@ -411,21 +437,11 @@ export async function applyInitPlanViaSupabase(params: {
411437 sql : string ;
412438 optional ?: boolean ;
413439 } ) : Promise < void > => {
414- // Supabase API handles transactions automatically for single statements
415- // For multi-statement SQL, we wrap in a transaction
440+ // Wrap in explicit transaction for atomic execution.
441+ // Note: Supabase API uses pooled connections, so if the transaction fails,
442+ // PostgreSQL automatically rolls it back - no separate ROLLBACK needed.
416443 const wrappedSql = `BEGIN;\n${ step . sql } \nCOMMIT;` ;
417-
418- try {
419- await params . client . query ( wrappedSql , false ) ;
420- } catch ( e ) {
421- // On error, attempt rollback (may already be rolled back by Supabase)
422- try {
423- await params . client . query ( "ROLLBACK;" , false ) ;
424- } catch {
425- // ignore rollback errors
426- }
427- throw e ;
428- }
444+ await params . client . query ( wrappedSql , false ) ;
429445 } ;
430446
431447 // Apply non-optional steps first
@@ -498,6 +514,12 @@ export async function applyInitPlanViaSupabase(params: {
498514/**
499515 * Verify init setup via Supabase Management API.
500516 * Mirrors the behavior of verifyInitSetup() in init.ts but uses Supabase API.
517+ *
518+ * @param params.client - Supabase client for API calls
519+ * @param params.database - Database name to verify
520+ * @param params.monitoringUser - Role name to check permissions for
521+ * @param params.includeOptionalPermissions - Whether to check optional permissions
522+ * @returns Object with ok status and arrays of missing required/optional items
501523 */
502524export async function verifyInitSetupViaSupabase ( params : {
503525 client : SupabaseClient ;
@@ -515,6 +537,11 @@ export async function verifyInitSetupViaSupabase(params: {
515537 const role = params . monitoringUser ;
516538 const db = params . database ;
517539
540+ // Validate role name to prevent SQL injection
541+ if ( ! isValidIdentifier ( role ) ) {
542+ throw new Error ( `Invalid monitoring user name: "${ role } ". Must be a valid PostgreSQL identifier (letters, digits, underscores, max 63 chars, starting with letter or underscore).` ) ;
543+ }
544+
518545 // Check if role exists
519546 const roleRes = await params . client . query (
520547 `SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${ escapeLiteral ( role ) } '` ,
@@ -554,13 +581,22 @@ export async function verifyInitSetupViaSupabase(params: {
554581 missingRequired . push ( "SELECT on pg_catalog.pg_index" ) ;
555582 }
556583
557- // Check postgres_ai schema
584+ // Check postgres_ai schema exists and has USAGE privilege
585+ // First check if schema exists to avoid has_schema_privilege throwing error
558586 const schemaExistsRes = await params . client . query (
559- ` SELECT has_schema_privilege(' ${ escapeLiteral ( role ) } ', 'postgres_ai', 'USAGE') as ok` ,
587+ " SELECT nspname FROM pg_namespace WHERE nspname = 'postgres_ai'" ,
560588 true
561589 ) ;
562- if ( ! schemaExistsRes . rows ?. [ 0 ] ?. ok ) {
563- missingRequired . push ( "USAGE on schema postgres_ai" ) ;
590+ if ( schemaExistsRes . rowCount === 0 ) {
591+ missingRequired . push ( "schema postgres_ai exists" ) ;
592+ } else {
593+ const schemaPrivRes = await params . client . query (
594+ `SELECT has_schema_privilege('${ escapeLiteral ( role ) } ', 'postgres_ai', 'USAGE') as ok` ,
595+ true
596+ ) ;
597+ if ( ! schemaPrivRes . rows ?. [ 0 ] ?. ok ) {
598+ missingRequired . push ( "USAGE on schema postgres_ai" ) ;
599+ }
564600 }
565601
566602 // Check pg_statistic view
@@ -613,23 +649,39 @@ export async function verifyInitSetupViaSupabase(params: {
613649 }
614650 }
615651
616- // Check helper functions
617- const explainFnRes = await params . client . query (
618- ` SELECT has_function_privilege(' ${ escapeLiteral ( role ) } ', 'postgres_ai. explain_generic(text, text, text)', 'EXECUTE') as ok` ,
652+ // Check helper functions - first verify they exist to avoid has_function_privilege errors
653+ const explainFnExistsRes = await params . client . query (
654+ " SELECT oid FROM pg_proc WHERE proname = ' explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')" ,
619655 true
620656 ) ;
621- if ( ! explainFnRes . rows ?. [ 0 ] ?. ok ) {
622- missingRequired . push (
623- "EXECUTE on postgres_ai.explain_generic(text, text, text)"
657+ if ( explainFnExistsRes . rowCount === 0 ) {
658+ missingRequired . push ( "function postgres_ai.explain_generic exists" ) ;
659+ } else {
660+ const explainFnRes = await params . client . query (
661+ `SELECT has_function_privilege('${ escapeLiteral ( role ) } ', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok` ,
662+ true
624663 ) ;
664+ if ( ! explainFnRes . rows ?. [ 0 ] ?. ok ) {
665+ missingRequired . push (
666+ "EXECUTE on postgres_ai.explain_generic(text, text, text)"
667+ ) ;
668+ }
625669 }
626670
627- const tableDescribeFnRes = await params . client . query (
628- ` SELECT has_function_privilege(' ${ escapeLiteral ( role ) } ', 'postgres_ai. table_describe(text)', 'EXECUTE') as ok` ,
671+ const tableDescribeFnExistsRes = await params . client . query (
672+ " SELECT oid FROM pg_proc WHERE proname = ' table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')" ,
629673 true
630674 ) ;
631- if ( ! tableDescribeFnRes . rows ?. [ 0 ] ?. ok ) {
632- missingRequired . push ( "EXECUTE on postgres_ai.table_describe(text)" ) ;
675+ if ( tableDescribeFnExistsRes . rowCount === 0 ) {
676+ missingRequired . push ( "function postgres_ai.table_describe exists" ) ;
677+ } else {
678+ const tableDescribeFnRes = await params . client . query (
679+ `SELECT has_function_privilege('${ escapeLiteral ( role ) } ', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok` ,
680+ true
681+ ) ;
682+ if ( ! tableDescribeFnRes . rows ?. [ 0 ] ?. ok ) {
683+ missingRequired . push ( "EXECUTE on postgres_ai.table_describe(text)" ) ;
684+ }
633685 }
634686
635687 // Optional permissions
@@ -642,28 +694,37 @@ export async function verifyInitSetupViaSupabase(params: {
642694 if ( extRes . rowCount === 0 ) {
643695 missingOptional . push ( "extension rds_tools" ) ;
644696 } else {
645- const fnRes = await params . client . query (
646- `SELECT has_function_privilege('${ escapeLiteral ( role ) } ', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok` ,
647- true
648- ) ;
649- if ( ! fnRes . rows ?. [ 0 ] ?. ok ) {
697+ try {
698+ const fnRes = await params . client . query (
699+ `SELECT has_function_privilege('${ escapeLiteral ( role ) } ', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok` ,
700+ true
701+ ) ;
702+ if ( ! fnRes . rows ?. [ 0 ] ?. ok ) {
703+ missingOptional . push ( "EXECUTE on rds_tools.pg_ls_multixactdir()" ) ;
704+ }
705+ } catch {
650706 missingOptional . push ( "EXECUTE on rds_tools.pg_ls_multixactdir()" ) ;
651707 }
652708 }
653709
654- // Self-managed extras
710+ // Self-managed extras (these are hardcoded constants, safe to use directly)
655711 const optionalFns = [
656712 "pg_catalog.pg_stat_file(text)" ,
657713 "pg_catalog.pg_stat_file(text, boolean)" ,
658714 "pg_catalog.pg_ls_dir(text)" ,
659715 "pg_catalog.pg_ls_dir(text, boolean, boolean)" ,
660716 ] ;
661717 for ( const fn of optionalFns ) {
662- const fnRes = await params . client . query (
663- `SELECT has_function_privilege('${ escapeLiteral ( role ) } ', '${ fn } ', 'EXECUTE') as ok` ,
664- true
665- ) ;
666- if ( ! fnRes . rows ?. [ 0 ] ?. ok ) {
718+ try {
719+ const fnRes = await params . client . query (
720+ `SELECT has_function_privilege('${ escapeLiteral ( role ) } ', '${ fn } ', 'EXECUTE') as ok` ,
721+ true
722+ ) ;
723+ if ( ! fnRes . rows ?. [ 0 ] ?. ok ) {
724+ missingOptional . push ( `EXECUTE on ${ fn } ` ) ;
725+ }
726+ } catch {
727+ // Function may not exist on this PostgreSQL version
667728 missingOptional . push ( `EXECUTE on ${ fn } ` ) ;
668729 }
669730 }
@@ -676,11 +737,25 @@ export async function verifyInitSetupViaSupabase(params: {
676737 } ;
677738}
678739
740+ /**
741+ * Validate that a string is a valid PostgreSQL identifier.
742+ * PostgreSQL identifiers can contain letters, digits, and underscores,
743+ * must start with a letter or underscore, and are max 63 characters.
744+ */
745+ function isValidIdentifier ( name : string ) : boolean {
746+ return / ^ [ a - z A - Z _ ] [ a - z A - Z 0 - 9 _ ] { 0 , 62 } $ / . test ( name ) ;
747+ }
748+
679749/**
680750 * Escape a string literal for use in SQL.
751+ * Handles null bytes and single quotes for safe SQL interpolation.
681752 * Note: This is for dynamic query building where parameterized queries aren't possible.
682753 */
683754function escapeLiteral ( value : string ) : string {
755+ // Reject null bytes which can cause string truncation
756+ if ( value . includes ( "\0" ) ) {
757+ throw new Error ( "SQL literal cannot contain null bytes" ) ;
758+ }
684759 // Escape single quotes by doubling them
685760 return value . replace ( / ' / g, "''" ) ;
686761}
0 commit comments