From d4c83bf77fe0780f252c6f65dafaa30614541047 Mon Sep 17 00:00:00 2001 From: Florian Gilde Date: Tue, 20 Jan 2026 15:55:47 +0100 Subject: [PATCH 1/4] Create project for supabase from Nextended --- .../Builders/AuthBuilderExtensions.cs | 89 ++ .../Builders/DatabaseBuilderExtensions.cs | 100 +++ .../Builders/EdgeRuntimeBuilderExtensions.cs | 53 ++ .../Builders/KongBuilderExtensions.cs | 52 ++ .../Builders/MetaBuilderExtensions.cs | 41 + .../Builders/RestBuilderExtensions.cs | 53 ++ .../Builders/StorageBuilderExtensions.cs | 65 ++ .../Builders/StudioBuilderExtensions.cs | 60 ++ .../Builders/SupabaseBuilderExtensions.cs | 383 +++++++++ .../Builders/SupabaseStackExtensions.cs | 502 +++++++++++ ...nityToolkit.Aspire.Hosting.Supabase.csproj | 12 + .../Helpers/EdgeFunctionRouter.cs | 202 +++++ .../Helpers/SupabaseSqlGenerator.cs | 736 ++++++++++++++++ .../README.md | 464 +++++++++++ .../Resources/SupabaseAuthResource.cs | 47 ++ .../Resources/SupabaseDatabaseResource.cs | 32 + .../Resources/SupabaseEdgeRuntimeResource.cs | 37 + .../Resources/SupabaseKongResource.cs | 32 + .../Resources/SupabaseMetaResource.cs | 27 + .../Resources/SupabaseRestResource.cs | 32 + .../Resources/SupabaseStackResource.cs | 165 ++++ .../Resources/SupabaseStorageResource.cs | 37 + .../Sync/ProjectSyncExtensions.cs | 126 +++ .../Sync/SyncOptions.cs | 86 ++ .../Sync/SyncService.cs | 787 ++++++++++++++++++ CommunityToolkit.Aspire.slnx | 7 +- 26 files changed, 4224 insertions(+), 3 deletions(-) create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/README.md create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs create mode 100644 CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs new file mode 100644 index 000000000..5afb26816 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs @@ -0,0 +1,89 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Auth (GoTrue). +/// +public static class AuthBuilderExtensions +{ + /// + /// Configures the GoTrue authentication settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the auth resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureAuth( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.Auth == null) + throw new InvalidOperationException("Auth not configured. Ensure AddSupabase() has been called."); + + configure(stack.Auth); + return builder; + } + + /// + /// Sets the site URL for authentication redirects. + /// + public static IResourceBuilder WithSiteUrl( + this IResourceBuilder builder, + string url) + { + builder.Resource.SiteUrl = url; + builder.WithEnvironment("GOTRUE_SITE_URL", url); + return builder; + } + + /// + /// Enables or disables auto-confirmation of email addresses. + /// + public static IResourceBuilder WithAutoConfirm( + this IResourceBuilder builder, + bool enabled = true) + { + builder.Resource.AutoConfirm = enabled; + builder.WithEnvironment("GOTRUE_MAILER_AUTOCONFIRM", enabled ? "true" : "false"); + return builder; + } + + /// + /// Enables or disables user signup. + /// + public static IResourceBuilder WithDisableSignup( + this IResourceBuilder builder, + bool disabled = true) + { + builder.Resource.DisableSignup = disabled; + builder.WithEnvironment("GOTRUE_DISABLE_SIGNUP", disabled ? "true" : "false"); + return builder; + } + + /// + /// Enables or disables anonymous users. + /// + public static IResourceBuilder WithAnonymousUsers( + this IResourceBuilder builder, + bool enabled = true) + { + builder.Resource.AnonymousUsersEnabled = enabled; + builder.WithEnvironment("GOTRUE_ANONYMOUS_USERS_ENABLED", enabled ? "true" : "false"); + return builder; + } + + /// + /// Sets the JWT expiration time in seconds. + /// + public static IResourceBuilder WithJwtExpiration( + this IResourceBuilder builder, + int seconds) + { + builder.Resource.JwtExpiration = seconds; + builder.WithEnvironment("GOTRUE_JWT_EXP", seconds.ToString()); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs new file mode 100644 index 000000000..3d3fb047e --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs @@ -0,0 +1,100 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Helpers; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Database (PostgreSQL). +/// +public static class DatabaseBuilderExtensions +{ + private const int PostgresPort = 5432; + + /// + /// Configures the PostgreSQL database settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the database resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureDatabase( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.Database == null) + throw new InvalidOperationException("Database not configured. Ensure AddSupabase() has been called."); + + configure(stack.Database); + return builder; + } + + /// + /// Sets the PostgreSQL password and updates all dependent containers. + /// + public static IResourceBuilder WithPassword( + this IResourceBuilder builder, + string password) + { + var resource = builder.Resource; + resource.Password = password; + + var stack = resource.Stack; + if (stack == null) + throw new InvalidOperationException("Stack not configured on database resource."); + + var containerPrefix = stack.Name; + + // Update environment variables on all containers that use the password + builder.WithEnvironment("POSTGRES_PASSWORD", password); + + // Auth container - update DB URL + var authDbUrl = $"postgres://supabase_auth_admin:{password}@{containerPrefix}-db:{PostgresPort}/postgres?search_path=auth"; + stack.Auth?.WithEnvironment("GOTRUE_DB_DATABASE_URL", authDbUrl); + + // Rest container - update DB URI + var restDbUri = $"postgres://authenticator:{password}@{containerPrefix}-db:{PostgresPort}/postgres"; + stack.Rest?.WithEnvironment("PGRST_DB_URI", restDbUri); + + // Storage container - update DB URL + var storageDatabaseUrl = $"postgres://supabase_storage_admin:{password}@{containerPrefix}-db:{PostgresPort}/postgres"; + stack.Storage?.WithEnvironment("DATABASE_URL", storageDatabaseUrl); + + // Meta container - update password + stack.Meta?.WithEnvironment("PG_META_DB_PASSWORD", password); + + // Studio container (which is the stack itself) - update password + stack.StackBuilder?.WithEnvironment("POSTGRES_PASSWORD", password); + + // Re-generate SQL files with the new password + if (!string.IsNullOrEmpty(stack.InitSqlPath)) + { + // Update 00_init.sql with new password + SupabaseSqlGenerator.WriteInitSql(stack.InitSqlPath, password); + + // Update post_init.sh with new password + var scriptsDir = Path.Combine(Path.GetDirectoryName(stack.InitSqlPath)!, "scripts"); + var postInitShPath = Path.Combine(scriptsDir, "post_init.sh"); + if (Directory.Exists(scriptsDir)) + { + SupabaseSqlGenerator.WritePostInitScript(postInitShPath, $"{containerPrefix}-db", password); + } + + Console.WriteLine($"[Supabase] Database password updated in all containers and SQL files"); + } + + return builder; + } + + /// + /// Sets the external PostgreSQL port. + /// + public static IResourceBuilder WithPort( + this IResourceBuilder builder, + int port) + { + builder.Resource.ExternalPort = port; + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs new file mode 100644 index 000000000..e25fc46ec --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs @@ -0,0 +1,53 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Edge Runtime. +/// +public static class EdgeRuntimeBuilderExtensions +{ + /// + /// Configures the Edge Runtime settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the Edge Runtime resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureEdgeRuntime( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.EdgeRuntime == null) + throw new InvalidOperationException("EdgeRuntime not configured. Ensure WithEdgeFunctions() has been called."); + + configure(stack.EdgeRuntime); + return builder; + } + + /// + /// Sets the internal Edge Runtime port. + /// + public static IResourceBuilder WithPort( + this IResourceBuilder builder, + int port) + { + builder.Resource.Port = port; + builder.WithEnvironment("EDGE_RUNTIME_PORT", port.ToString()); + return builder; + } + + /// + /// Sets a custom environment variable for the Edge Runtime. + /// + public static IResourceBuilder WithCustomEnvironment( + this IResourceBuilder builder, + string name, + string value) + { + builder.WithEnvironment(name, value); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs new file mode 100644 index 000000000..790142e15 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs @@ -0,0 +1,52 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Kong API Gateway. +/// +public static class KongBuilderExtensions +{ + /// + /// Configures the Kong API Gateway settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the Kong resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureKong( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.Kong == null) + throw new InvalidOperationException("Kong not configured. Ensure AddSupabase() has been called."); + + configure(stack.Kong); + return builder; + } + + /// + /// Sets the external Kong port. + /// + public static IResourceBuilder WithPort( + this IResourceBuilder builder, + int port) + { + builder.Resource.ExternalPort = port; + return builder; + } + + /// + /// Sets the Kong plugins to enable. + /// + public static IResourceBuilder WithPlugins( + this IResourceBuilder builder, + params string[] plugins) + { + builder.Resource.Plugins = plugins; + builder.WithEnvironment("KONG_PLUGINS", string.Join(",", plugins)); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs new file mode 100644 index 000000000..4d0c7a751 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs @@ -0,0 +1,41 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Postgres-Meta service. +/// +public static class MetaBuilderExtensions +{ + /// + /// Configures the Postgres-Meta settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the Meta resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureMeta( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.Meta == null) + throw new InvalidOperationException("Meta not configured. Ensure AddSupabase() has been called."); + + configure(stack.Meta); + return builder; + } + + /// + /// Sets the internal Postgres-Meta port. + /// + public static IResourceBuilder WithPort( + this IResourceBuilder builder, + int port) + { + builder.Resource.Port = port; + builder.WithEnvironment("PG_META_PORT", port.ToString()); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs new file mode 100644 index 000000000..61dcfa352 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs @@ -0,0 +1,53 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase REST API (PostgREST). +/// +public static class RestBuilderExtensions +{ + /// + /// Configures the PostgREST settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the REST resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureRest( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.Rest == null) + throw new InvalidOperationException("Rest not configured. Ensure AddSupabase() has been called."); + + configure(stack.Rest); + return builder; + } + + /// + /// Sets the database schemas exposed by PostgREST. + /// + public static IResourceBuilder WithSchemas( + this IResourceBuilder builder, + params string[] schemas) + { + builder.Resource.Schemas = schemas; + builder.WithEnvironment("PGRST_DB_SCHEMAS", string.Join(",", schemas)); + return builder; + } + + /// + /// Sets the anonymous role name for unauthenticated requests. + /// + public static IResourceBuilder WithAnonRole( + this IResourceBuilder builder, + string role) + { + builder.Resource.AnonRole = role; + builder.WithEnvironment("PGRST_DB_ANON_ROLE", role); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs new file mode 100644 index 000000000..32a992de7 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs @@ -0,0 +1,65 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Storage API. +/// +public static class StorageBuilderExtensions +{ + /// + /// Configures the Storage API settings. + /// + /// The Supabase stack resource builder. + /// Configuration action for the Storage resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureStorage( + this IResourceBuilder builder, + Action> configure) + { + var stack = builder.Resource; + if (stack.Storage == null) + throw new InvalidOperationException("Storage not configured. Ensure AddSupabase() has been called."); + + configure(stack.Storage); + return builder; + } + + /// + /// Sets the maximum file size limit in bytes. + /// + public static IResourceBuilder WithFileSizeLimit( + this IResourceBuilder builder, + long bytes) + { + builder.Resource.FileSizeLimit = bytes; + builder.WithEnvironment("FILE_SIZE_LIMIT", bytes.ToString()); + return builder; + } + + /// + /// Sets the storage backend type. + /// + public static IResourceBuilder WithBackend( + this IResourceBuilder builder, + string backend) + { + builder.Resource.Backend = backend; + builder.WithEnvironment("STORAGE_BACKEND", backend); + return builder; + } + + /// + /// Enables or disables image transformation. + /// + public static IResourceBuilder WithImageTransformation( + this IResourceBuilder builder, + bool enabled = true) + { + builder.Resource.EnableImageTransformation = enabled; + builder.WithEnvironment("ENABLE_IMAGE_TRANSFORMATION", enabled ? "true" : "false"); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs new file mode 100644 index 000000000..c2afcac7a --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs @@ -0,0 +1,60 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for configuring the Supabase Studio Dashboard. +/// Note: The SupabaseStackResource IS the Studio container. +/// +public static class StudioBuilderExtensions +{ + /// + /// Configures the Studio Dashboard settings. + /// Since the SupabaseStackResource IS the Studio container, this configures the stack itself. + /// + /// The Supabase stack resource builder. + /// Configuration action for the Studio (stack) resource builder. + /// The Supabase stack resource builder for chaining. + public static IResourceBuilder ConfigureStudio( + this IResourceBuilder builder, + Action> configure) + { + configure(builder); + return builder; + } + + /// + /// Sets the external Studio port. + /// + public static IResourceBuilder WithStudioPort( + this IResourceBuilder builder, + int port) + { + builder.Resource.StudioPort = port; + return builder; + } + + /// + /// Sets the organization name displayed in Studio. + /// + public static IResourceBuilder WithOrganizationName( + this IResourceBuilder builder, + string name) + { + builder.WithEnvironment("DEFAULT_ORGANIZATION_NAME", name); + return builder; + } + + /// + /// Sets the project name displayed in Studio. + /// + public static IResourceBuilder WithProjectName( + this IResourceBuilder builder, + string name) + { + builder.WithEnvironment("DEFAULT_PROJECT_NAME", name); + return builder; + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs new file mode 100644 index 000000000..518f83763 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs @@ -0,0 +1,383 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Helpers; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides the main extension method for adding Supabase to an Aspire application. +/// +public static class SupabaseBuilderExtensions +{ + #region Constants + + internal static class Images + { + public const string Postgres = "supabase/postgres"; + public const string PostgresTag = "15.1.1.78"; + public const string GoTrue = "supabase/gotrue"; + public const string GoTrueTag = "v2.185.0"; + public const string PostgREST = "postgrest/postgrest"; + public const string PostgRESTTag = "v12.2.0"; + public const string StorageApi = "supabase/storage-api"; + public const string StorageApiTag = "v1.11.13"; + public const string Kong = "kong"; + public const string KongTag = "2.8.1"; + public const string PostgresMeta = "supabase/postgres-meta"; + public const string PostgresMetaTag = "v0.84.2"; + public const string Studio = "supabase/studio"; + public const string StudioTag = "latest"; + public const string EdgeRuntime = "supabase/edge-runtime"; + public const string EdgeRuntimeTag = "v1.67.4"; + } + + internal static class Ports + { + public const int Postgres = 5432; + public const int GoTrue = 9999; + public const int PostgREST = 3000; + public const int StorageApi = 5000; + public const int Kong = 8000; + public const int PostgresMeta = 8080; + public const int Studio = 3000; + public const int EdgeRuntime = 9000; + } + + internal static class Defaults + { + public const string JwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; + public const string AnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"; + public const string ServiceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"; + public const string Password = "postgres-insecure-dev-password"; + public const int ExternalPostgresPort = 54322; + public const int ExternalKongPort = 8000; + public const int ExternalStudioPort = 54323; + } + + #endregion + + #region Clear Infrastructure + + /// + /// Clears all Supabase infrastructure (Docker containers, volumes, and data files). + /// Call this before AddSupabase() for a clean start. + /// + public static IDistributedApplicationBuilder ClearSupabase( + this IDistributedApplicationBuilder builder, + string containerPrefix = "supabase") + { + Console.WriteLine("[Supabase Clear] Clearing Supabase infrastructure..."); + + var containerNames = new[] + { + containerPrefix, + $"{containerPrefix}-db", + $"{containerPrefix}-auth", + $"{containerPrefix}-rest", + $"{containerPrefix}-storage", + $"{containerPrefix}-kong", + $"{containerPrefix}-meta", + $"{containerPrefix}-edge", + $"{containerPrefix}-init" + }; + + foreach (var container in containerNames) + { + try + { + var stopProcess = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "docker", + Arguments = $"rm -f {container}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + stopProcess?.WaitForExit(5000); + } + catch { /* Ignore errors if container doesn't exist */ } + } + + var infraDir = Path.Combine(builder.AppHostDirectory, "..", "infra", "supabase"); + if (Directory.Exists(infraDir)) + { + try + { + Directory.Delete(infraDir, recursive: true); + Console.WriteLine($"[Supabase Clear] Directory deleted: {infraDir}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Clear] WARNING: Could not delete directory: {ex.Message}"); + } + } + + Console.WriteLine("[Supabase Clear] Cleanup completed."); + return builder; + } + + #endregion + + #region Main Entry Point + + /// + /// Adds a complete Supabase stack to the application. + /// The returned resource IS the Studio Dashboard container and serves as the visual parent + /// for all other Supabase containers in the Aspire dashboard. + /// + /// The distributed application builder. + /// The name of the Supabase resource (will appear as "supabase" in dashboard). + /// A resource builder for further configuration. + public static IResourceBuilder AddSupabase( + this IDistributedApplicationBuilder builder, + string name) + { + // Create the main stack resource (which IS the Studio container) + var stack = new SupabaseStackResource(name) + { + JwtSecret = Defaults.JwtSecret, + AnonKey = Defaults.AnonKey, + ServiceRoleKey = Defaults.ServiceRoleKey, + AppBuilder = builder + }; + + // Ensure directories exist + var rootDir = Path.Combine(builder.AppHostDirectory, "..", "infra", "supabase"); + var dirs = EnsureDirectories(rootDir); + stack.InfraRootDir = rootDir; + stack.InitSqlPath = dirs.Init; + + var containerPrefix = name; + + // --- Create typed container resources --- + + // DATABASE + var dbResource = new SupabaseDatabaseResource($"{containerPrefix}-db") + { + Password = Defaults.Password, + ExternalPort = Defaults.ExternalPostgresPort, + Stack = stack + }; + + // Create initial configuration files with default password + SupabaseSqlGenerator.WriteInitSql(dirs.Init, dbResource.Password); + + stack.Database = builder.AddResource(dbResource) + .WithImage(Images.Postgres, Images.PostgresTag) + .WithContainerName($"{containerPrefix}-db") + .WithEnvironment("POSTGRES_PASSWORD", dbResource.Password) + .WithEnvironment("POSTGRES_DB", "postgres") + .WithEnvironment("POSTGRES_HOST_AUTH_METHOD", "trust") + .WithBindMount(dirs.Data, "/var/lib/postgresql/data") + .WithBindMount(dirs.Init, "/docker-entrypoint-initdb.d", isReadOnly: true) + .WithEndpoint(port: dbResource.ExternalPort, targetPort: Ports.Postgres, name: "tcp", scheme: "tcp"); + + // AUTH (GoTrue) + var authResource = new SupabaseAuthResource($"{containerPrefix}-auth") { Stack = stack }; + var authDbUrl = $"postgres://supabase_auth_admin:{dbResource.Password}@{containerPrefix}-db:{Ports.Postgres}/postgres?search_path=auth"; + + stack.Auth = builder.AddResource(authResource) + .WithImage(Images.GoTrue, Images.GoTrueTag) + .WithContainerName($"{containerPrefix}-auth") + .WithEnvironment("GOTRUE_API_HOST", "0.0.0.0") + .WithEnvironment("GOTRUE_API_PORT", Ports.GoTrue.ToString()) + .WithEnvironment("GOTRUE_DB_DRIVER", "postgres") + .WithEnvironment("GOTRUE_DB_DATABASE_URL", authDbUrl) + .WithEnvironment("GOTRUE_DB_NAMESPACE", "auth") + .WithEnvironment("GOTRUE_SITE_URL", authResource.SiteUrl) + .WithEnvironment("API_EXTERNAL_URL", $"http://localhost:{Defaults.ExternalKongPort.ToString()}") + .WithEnvironment("GOTRUE_URI_ALLOW_LIST", "*") + .WithEnvironment("GOTRUE_JWT_SECRET", stack.JwtSecret) + .WithEnvironment("GOTRUE_JWT_EXP", authResource.JwtExpiration.ToString()) + .WithEnvironment("GOTRUE_JWT_DEFAULT_GROUP_NAME", "authenticated") + .WithEnvironment("GOTRUE_JWT_ADMIN_ROLES", "service_role") + .WithEnvironment("GOTRUE_JWT_AUD", "authenticated") + .WithEnvironment("GOTRUE_EXTERNAL_EMAIL_ENABLED", "true") + .WithEnvironment("GOTRUE_MAILER_AUTOCONFIRM", authResource.AutoConfirm ? "true" : "false") + .WithEnvironment("GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED", "false") + .WithEnvironment("GOTRUE_DISABLE_SIGNUP", authResource.DisableSignup ? "true" : "false") + .WithEnvironment("GOTRUE_ANONYMOUS_USERS_ENABLED", authResource.AnonymousUsersEnabled ? "true" : "false") + .WithEnvironment("GOTRUE_RATE_LIMIT_HEADER", "X-Forwarded-For") + .WithEnvironment("GOTRUE_RATE_LIMIT_EMAIL_SENT", "100") + .WithHttpEndpoint(targetPort: Ports.GoTrue, name: "http") + .WithContainerRuntimeArgs("--restart=on-failure:10") + .WaitFor(stack.Database); + + // REST (PostgREST) + var restResource = new SupabaseRestResource($"{containerPrefix}-rest") { Stack = stack }; + var restDbUri = $"postgres://authenticator:{dbResource.Password}@{containerPrefix}-db:{Ports.Postgres}/postgres"; + + stack.Rest = builder.AddResource(restResource) + .WithImage(Images.PostgREST, Images.PostgRESTTag) + .WithContainerName($"{containerPrefix}-rest") + .WithEnvironment("PGRST_DB_URI", restDbUri) + .WithEnvironment("PGRST_DB_SCHEMAS", string.Join(",", restResource.Schemas)) + .WithEnvironment("PGRST_DB_ANON_ROLE", restResource.AnonRole) + .WithEnvironment("PGRST_JWT_SECRET", stack.JwtSecret) + .WithEnvironment("PGRST_DB_USE_LEGACY_GUCS", "false") + .WithHttpEndpoint(targetPort: Ports.PostgREST, name: "http") + .WithContainerRuntimeArgs("--restart=on-failure:10") + .WaitFor(stack.Database); + + // STORAGE + var storageResource = new SupabaseStorageResource($"{containerPrefix}-storage") { Stack = stack }; + var storageDatabaseUrl = $"postgres://supabase_storage_admin:{dbResource.Password}@{containerPrefix}-db:{Ports.Postgres}/postgres"; + + stack.Storage = builder.AddResource(storageResource) + .WithImage(Images.StorageApi, Images.StorageApiTag) + .WithContainerName($"{containerPrefix}-storage") + .WithEnvironment("ANON_KEY", stack.AnonKey) + .WithEnvironment("SERVICE_KEY", stack.ServiceRoleKey) + .WithEnvironment("POSTGREST_URL", $"http://{containerPrefix}-rest:{Ports.PostgREST.ToString()}") + .WithEnvironment("PGRST_JWT_SECRET", stack.JwtSecret) + .WithEnvironment("DATABASE_URL", storageDatabaseUrl) + .WithEnvironment("FILE_STORAGE_BACKEND_PATH", "/var/lib/storage") + .WithEnvironment("STORAGE_BACKEND", storageResource.Backend) + .WithEnvironment("FILE_SIZE_LIMIT", storageResource.FileSizeLimit.ToString()) + .WithEnvironment("TENANT_ID", "stub") + .WithEnvironment("REGION", "local") + .WithEnvironment("GLOBAL_S3_BUCKET", "stub") + .WithEnvironment("IS_MULTITENANT", "false") + .WithEnvironment("ENABLE_IMAGE_TRANSFORMATION", storageResource.EnableImageTransformation ? "true" : "false") + .WithBindMount(dirs.Storage, "/var/lib/storage") + .WithHttpEndpoint(targetPort: Ports.StorageApi, name: "http") + .WithContainerRuntimeArgs("--restart=on-failure:10") + .WaitFor(stack.Database) + .WaitFor(stack.Rest); + + // KONG (API Gateway) + var kongResource = new SupabaseKongResource($"{containerPrefix}-kong") + { + ExternalPort = Defaults.ExternalKongPort, + Stack = stack + }; + + // Generate Kong config + SupabaseSqlGenerator.WriteKongConfig( + Path.Combine(dirs.Config, "kong.yml"), + stack.AnonKey, + stack.ServiceRoleKey, + containerPrefix, + Ports.GoTrue, + Ports.PostgREST, + Ports.StorageApi, + Ports.PostgresMeta, + Ports.EdgeRuntime); + + stack.Kong = builder.AddResource(kongResource) + .WithImage(Images.Kong, Images.KongTag) + .WithContainerName($"{containerPrefix}-kong") + .WithEnvironment("KONG_DATABASE", "off") + .WithEnvironment("KONG_DECLARATIVE_CONFIG", "/home/kong/kong.yml") + .WithEnvironment("KONG_DNS_ORDER", "LAST,A,CNAME") + .WithEnvironment("KONG_PLUGINS", string.Join(",", kongResource.Plugins)) + .WithEnvironment("KONG_NGINX_PROXY_PROXY_BUFFER_SIZE", "160k") + .WithEnvironment("KONG_NGINX_PROXY_PROXY_BUFFERS", "64 160k") + .WithBindMount(Path.Combine(dirs.Config, "kong.yml"), "/home/kong/kong.yml", isReadOnly: true) + .WithHttpEndpoint(port: kongResource.ExternalPort, targetPort: Ports.Kong, name: "http") + .WaitFor(stack.Auth) + .WaitFor(stack.Rest) + .WaitFor(stack.Storage); + + // META (Postgres-Meta) + var metaResource = new SupabaseMetaResource($"{containerPrefix}-meta") { Stack = stack }; + + stack.Meta = builder.AddResource(metaResource) + .WithImage(Images.PostgresMeta, Images.PostgresMetaTag) + .WithContainerName($"{containerPrefix}-meta") + .WithEnvironment("PG_META_PORT", metaResource.Port.ToString()) + .WithEnvironment("PG_META_DB_HOST", $"{containerPrefix}-db") + .WithEnvironment("PG_META_DB_PORT", Ports.Postgres.ToString()) + .WithEnvironment("PG_META_DB_NAME", "postgres") + .WithEnvironment("PG_META_DB_USER", "supabase_admin") + .WithEnvironment("PG_META_DB_PASSWORD", dbResource.Password) + .WithHttpEndpoint(targetPort: Ports.PostgresMeta, name: "http") + .WaitFor(stack.Database); + + // STUDIO - Configure the stack resource itself as the Studio container + stack.StudioPort = Defaults.ExternalStudioPort; + + var stackBuilder = builder.AddResource(stack) + .WithImage(Images.Studio, Images.StudioTag) + .WithContainerName(name) + .WithEnvironment("STUDIO_PG_META_URL", $"http://{containerPrefix}-meta:{Ports.PostgresMeta.ToString()}") + .WithEnvironment("POSTGRES_PASSWORD", dbResource.Password) + .WithEnvironment("POSTGRES_HOST", $"{containerPrefix}-db") + .WithEnvironment("POSTGRES_PORT", Ports.Postgres.ToString()) + .WithEnvironment("POSTGRES_DB", "postgres") + .WithEnvironment("POSTGRES_USER", "supabase_admin") + .WithEnvironment("DEFAULT_ORGANIZATION_NAME", "Default Organization") + .WithEnvironment("DEFAULT_PROJECT_NAME", "Default Project") + .WithEnvironment("SUPABASE_URL", $"http://{containerPrefix}-kong:{Ports.Kong.ToString()}") + .WithEnvironment("SUPABASE_PUBLIC_URL", $"http://localhost:{kongResource.ExternalPort.ToString()}") + .WithEnvironment("SUPABASE_ANON_KEY", stack.AnonKey) + .WithEnvironment("SUPABASE_SERVICE_KEY", stack.ServiceRoleKey) + .WithEnvironment("GOTRUE_URL", $"http://{containerPrefix}-auth:{Ports.GoTrue.ToString()}") + .WithEnvironment("AUTH_JWT_SECRET", stack.JwtSecret) + .WithEnvironment("LOGFLARE_API_KEY", "") + .WithEnvironment("LOGFLARE_URL", "") + .WithEnvironment("NEXT_PUBLIC_ENABLE_LOGS", "false") + .WithEnvironment("NEXT_ANALYTICS_BACKEND_PROVIDER", "") + .WithEnvironment("SNIPPETS_MANAGEMENT_FOLDER", "/tmp/snippets") + .WithHttpEndpoint(port: stack.StudioPort, targetPort: Ports.Studio, name: "http") + .WaitFor(stack.Meta) + .WaitFor(stack.Kong) + .WaitFor(stack.Auth); + + stack.StackBuilder = stackBuilder; + + // Set parent relationships - Stack (Studio) is the visual parent for all containers + stack.Database.WithParentRelationship(stack); + stack.Auth.WithParentRelationship(stack); + stack.Rest.WithParentRelationship(stack); + stack.Storage.WithParentRelationship(stack); + stack.Kong.WithParentRelationship(stack); + stack.Meta.WithParentRelationship(stack); + + // POST-INIT CONTAINER + var scriptsDir = Path.Combine(Path.GetDirectoryName(dirs.Init)!, "scripts"); + Directory.CreateDirectory(scriptsDir); + + var postInitSqlPath = Path.Combine(scriptsDir, "post_init.sql"); + SupabaseSqlGenerator.WritePostInitSql(postInitSqlPath); + + var postInitShPath = Path.Combine(scriptsDir, "post_init.sh"); + SupabaseSqlGenerator.WritePostInitScript(postInitShPath, $"{containerPrefix}-db", dbResource.Password); + + builder.AddContainer($"{containerPrefix}-init", Images.Postgres, Images.PostgresTag) + .WithContainerName($"{containerPrefix}-init") + .WithBindMount(scriptsDir, "/scripts", isReadOnly: true) + .WithEntrypoint("/bin/bash") + .WithArgs("/scripts/post_init.sh") + .WaitFor(stack.Database) + .WaitFor(stack.Auth) + .WithParentRelationship(stack); + + return stackBuilder; + } + + #endregion + + #region Directory Management + + private record SupabaseDirs(string Init, string Storage, string Data, string Config); + + private static SupabaseDirs EnsureDirectories(string root) + { + var dirs = new SupabaseDirs( + Init: Path.Combine(root, "db-init"), + Storage: Path.Combine(root, "storage"), + Data: Path.Combine(root, "db-data"), + Config: Path.Combine(root, "config") + ); + Directory.CreateDirectory(dirs.Init); + Directory.CreateDirectory(dirs.Storage); + Directory.CreateDirectory(dirs.Data); + Directory.CreateDirectory(dirs.Config); + return dirs; + } + + #endregion +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs new file mode 100644 index 000000000..c14284d2e --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs @@ -0,0 +1,502 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Helpers; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +/// +/// Provides extension methods for the SupabaseStackResource. +/// +public static class SupabaseStackExtensions +{ + #region Edge Functions + + /// + /// Configures and creates the Edge Runtime container for Edge Functions. + /// + /// The resource builder. + /// The absolute path to the supabase/functions directory. + public static IResourceBuilder WithEdgeFunctions( + this IResourceBuilder builder, + string functionsPath) + { + if (!Directory.Exists(functionsPath)) + { + Console.WriteLine($"[Supabase] WARNING: Edge Functions directory not found: {functionsPath}"); + return builder; + } + + var stack = builder.Resource; + var appBuilder = stack.AppBuilder; + + if (appBuilder == null) + { + Console.WriteLine("[Supabase] ERROR: AppBuilder not available. Was AddSupabase() called?"); + return builder; + } + + // List available functions (only directories with index.ts) + var functionDirs = Directory.GetDirectories(functionsPath) + .Select(d => Path.GetFileName(d)) + .Where(name => !name.StartsWith("_") && !name.StartsWith(".")) + .Where(name => File.Exists(Path.Combine(functionsPath, name, "index.ts"))) + .ToList(); + + if (functionDirs.Count == 0) + { + Console.WriteLine($"[Supabase] WARNING: No Edge Functions with index.ts found in: {functionsPath}"); + return builder; + } + + Console.WriteLine($"[Supabase] Edge Functions found: {string.Join(", ", functionDirs)}"); + + stack.EdgeFunctionsPath = functionsPath; + var containerPrefix = stack.Name; + + // Create Edge Runtime container + const int PostgresPort = 5432; + const int KongPort = 8000; + const int EdgeRuntimePort = 9000; + + var dbPassword = stack.Database!.Resource.Password; + var edgeDbUrl = $"postgresql://postgres:{dbPassword}@{containerPrefix}-db:{PostgresPort}/postgres"; + + // Generate router file for multi-function support + var infraRoot = stack.InfraRootDir ?? Path.Combine(appBuilder.AppHostDirectory, "..", "infra", "supabase"); + var edgeDir = Path.Combine(infraRoot, "edge"); + Directory.CreateDirectory(edgeDir); + + var mainTsPath = Path.Combine(edgeDir, "main.ts"); + EdgeFunctionRouter.GenerateRouter(mainTsPath, functionDirs); + Console.WriteLine($"[Supabase] Edge Router generated: {mainTsPath}"); + + // Create the Edge Runtime container with typed resource + var edgeResource = new SupabaseEdgeRuntimeResource($"{containerPrefix}-edge") + { + Port = EdgeRuntimePort, + FunctionsPath = functionsPath, + Stack = stack + }; + edgeResource.FunctionNames.AddRange(functionDirs); + + stack.EdgeRuntime = appBuilder.AddResource(edgeResource) + .WithImage("denoland/deno", "alpine-2.1.4") + .WithContainerName($"{containerPrefix}-edge") + .WithEnvironment("SUPABASE_URL", $"http://{containerPrefix}-kong:{KongPort.ToString()}") + .WithEnvironment("SUPABASE_ANON_KEY", stack.AnonKey) + .WithEnvironment("SUPABASE_SERVICE_ROLE_KEY", stack.ServiceRoleKey) + .WithEnvironment("SUPABASE_DB_URL", edgeDbUrl) + .WithEnvironment("JWT_SECRET", stack.JwtSecret) + .WithEnvironment("DENO_DIR", "/tmp/deno") + .WithEnvironment("EDGE_RUNTIME_PORT", EdgeRuntimePort.ToString()) + .WithBindMount(edgeDir, "/home/deno/main", isReadOnly: true) + .WithBindMount(functionsPath, "/home/deno/functions", isReadOnly: true) + .WithHttpEndpoint(targetPort: EdgeRuntimePort, name: "http") + .WithArgs("run", "--allow-all", "--unstable-worker-options", "/home/deno/main/main.ts") + .WaitFor(stack.Database!) + .WaitFor(stack.Kong!); + + // Set parent relationship to the stack (which IS the Studio) + stack.EdgeRuntime.WithParentRelationship(stack); + + Console.WriteLine($"[Supabase] Edge Runtime container created with {functionDirs.Count} functions"); + return builder; + } + + #endregion + + #region Migrations + + /// + /// Applies database migrations from SQL files in the specified directory. + /// Migrations are executed in alphabetical order by filename AFTER GoTrue starts. + /// + /// The resource builder. + /// The absolute path to the supabase/migrations directory. + public static IResourceBuilder WithMigrations( + this IResourceBuilder builder, + string migrationsPath) + { + if (!Directory.Exists(migrationsPath)) + { + Console.WriteLine($"[Supabase] WARNING: Migrations directory not found: {migrationsPath}"); + return builder; + } + + var stack = builder.Resource; + + if (stack.InitSqlPath == null) + { + Console.WriteLine("[Supabase] ERROR: InitSqlPath not set. Was AddSupabase() called?"); + return builder; + } + + // Find all SQL files and sort by name + var sqlFiles = Directory.GetFiles(migrationsPath, "*.sql") + .OrderBy(f => Path.GetFileName(f)) + .ToList(); + + if (sqlFiles.Count == 0) + { + Console.WriteLine($"[Supabase] No migrations found in: {migrationsPath}"); + return builder; + } + + Console.WriteLine($"[Supabase] {sqlFiles.Count} migrations found"); + + // Create combined migrations file + var combinedSql = new System.Text.StringBuilder(); + combinedSql.AppendLine("-- ============================================"); + combinedSql.AppendLine("-- SUPABASE MIGRATIONS (auto-generated)"); + combinedSql.AppendLine($"-- Generated at: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + combinedSql.AppendLine($"-- Source: {migrationsPath}"); + combinedSql.AppendLine("-- ============================================"); + combinedSql.AppendLine(); + combinedSql.AppendLine("-- Wait for auth.users table (GoTrue must start first)"); + combinedSql.AppendLine("DO $$"); + combinedSql.AppendLine("DECLARE"); + combinedSql.AppendLine(" retry_count integer := 0;"); + combinedSql.AppendLine(" max_retries integer := 30;"); + combinedSql.AppendLine("BEGIN"); + combinedSql.AppendLine(" WHILE NOT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') AND retry_count < max_retries LOOP"); + combinedSql.AppendLine(" PERFORM pg_sleep(1);"); + combinedSql.AppendLine(" retry_count := retry_count + 1;"); + combinedSql.AppendLine(" RAISE NOTICE '[Migrations] Waiting for auth.users... (Attempt %/%)', retry_count, max_retries;"); + combinedSql.AppendLine(" END LOOP;"); + combinedSql.AppendLine(" IF NOT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN"); + combinedSql.AppendLine(" RAISE EXCEPTION '[Migrations] auth.users was not found after % attempts', max_retries;"); + combinedSql.AppendLine(" END IF;"); + combinedSql.AppendLine(" RAISE NOTICE '[Migrations] auth.users found, starting migrations';"); + combinedSql.AppendLine("END;"); + combinedSql.AppendLine("$$;"); + combinedSql.AppendLine(); + + foreach (var sqlFile in sqlFiles) + { + var fileName = Path.GetFileName(sqlFile); + combinedSql.AppendLine($"-- Migration: {fileName}"); + combinedSql.AppendLine($"-- ----------------------------------------"); + + try + { + var content = File.ReadAllText(sqlFile); + combinedSql.AppendLine(content); + combinedSql.AppendLine(); + Console.WriteLine($"[Supabase] + {fileName}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase] WARNING: Could not read {fileName}: {ex.Message}"); + } + } + + combinedSql.AppendLine("-- ============================================"); + combinedSql.AppendLine("-- END MIGRATIONS"); + combinedSql.AppendLine("-- ============================================"); + + // Write to scripts directory (executed by post_init.sh, AFTER GoTrue start) + var scriptsDir = Path.Combine(Path.GetDirectoryName(stack.InitSqlPath)!, "scripts"); + Directory.CreateDirectory(scriptsDir); + var migrationsOutputPath = Path.Combine(scriptsDir, "migrations.sql"); + File.WriteAllText(migrationsOutputPath, combinedSql.ToString()); + + Console.WriteLine($"[Supabase] Migrations written to: {migrationsOutputPath}"); + return builder; + } + + #endregion + + #region User Registration + + /// + /// Registers a development user that will be created on startup. + /// The user will have a profile and admin role automatically. + /// + /// The resource builder. + /// The user's email address. + /// The user's password. + /// Optional display name (defaults to email). + public static IResourceBuilder WithRegisteredUser( + this IResourceBuilder builder, + string email, + string password, + string? displayName = null) + { + var user = new RegisteredUser(email, password, displayName ?? email); + builder.Resource.RegisteredUsers.Add(user); + + var scriptsDir = builder.Resource.InitSqlPath != null + ? Path.Combine(Path.GetDirectoryName(builder.Resource.InitSqlPath)!, "scripts") + : null; + + if (scriptsDir != null) + { + Directory.CreateDirectory(scriptsDir); + var userSqlPath = Path.Combine(scriptsDir, "users.sql"); + AppendUserSql(userSqlPath, user); + Console.WriteLine($"[Supabase] User registered: {email} -> {userSqlPath}"); + } + else + { + Console.WriteLine($"[Supabase] WARNING: InitSqlPath is null, user SQL cannot be created!"); + } + + return builder; + } + + private static void AppendUserSql(string path, RegisteredUser user) + { + var email = user.Email.Replace("'", "''"); + var displayName = user.DisplayName.Replace("'", "''"); + var password = user.Password.Replace("'", "''"); + + var appMetaData = @"{""provider"": ""email"", ""providers"": [""email""]}"; + var userMetaData = @"{""display_name"": """ + displayName + @"""}"; + + var sql = $""" +-- User: {user.Email} +DO $$ +DECLARE + new_user_id uuid; + hashed_password text; +BEGIN + -- Check if user already exists + SELECT id INTO new_user_id FROM auth.users WHERE email = '{email}'; + + IF new_user_id IS NULL THEN + -- Hash password + hashed_password := extensions.crypt('{password}', extensions.gen_salt('bf', 10)); + + -- Create user in auth.users + INSERT INTO auth.users ( + instance_id, id, aud, role, email, encrypted_password, + email_confirmed_at, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at, confirmation_token, email_change, + email_change_token_new, recovery_token + ) VALUES ( + '00000000-0000-0000-0000-000000000000', + extensions.uuid_generate_v4(), + 'authenticated', 'authenticated', '{email}', hashed_password, + NOW(), '{appMetaData}'::jsonb, '{userMetaData}'::jsonb, + NOW(), NOW(), '', '', '', '' + ) + RETURNING id INTO new_user_id; + + RAISE NOTICE '[Post-Init] User created: {email} (ID: %)', new_user_id; + + -- Create profile (with exception handling) + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN + IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE user_id = new_user_id) THEN + INSERT INTO public.profiles (user_id, email, display_name, is_disabled, created_at, updated_at) + VALUES (new_user_id, '{email}', '{displayName}', false, NOW(), NOW()); + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] Profile creation failed for {email}: %', SQLERRM; + END; + + -- Create admin role (with exception handling) + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'user_roles') THEN + IF NOT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = new_user_id) THEN + INSERT INTO public.user_roles (user_id, role, created_at) + VALUES (new_user_id, 'admin', NOW()); + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] Role creation failed for {email}: %', SQLERRM; + END; + ELSE + RAISE NOTICE '[Post-Init] User already exists: {email}'; + END IF; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] User creation completely failed for {email}: %', SQLERRM; +END; +$$; + +"""; + File.AppendAllText(path, sql); + } + + #endregion + + #region Clear Command + + /// + /// Adds a "Clear All Data" command to the Kong container in the Aspire dashboard. + /// This stops all Supabase containers and deletes all data for a fresh start. + /// + public static IResourceBuilder WithClearCommand(this IResourceBuilder builder) + { + var containerPrefix = builder.Resource.Name; + var infraPath = builder.Resource.InitSqlPath != null + ? Path.GetDirectoryName(Path.GetDirectoryName(builder.Resource.InitSqlPath)) + : null; + CommandOptions options = new() + { + IconName = "Delete", + IconVariant = IconVariant.Filled, + UpdateState = context => ResourceCommandState.Enabled + }; + builder.WithCommand( + name: "clear-supabase", + displayName: "Clear All Supabase Data", + executeCommand: _ => + { + Console.WriteLine("[Supabase Clear] Deleting all data..."); + + var containerNames = new[] + { + $"{containerPrefix}-db", + $"{containerPrefix}-auth", + $"{containerPrefix}-rest", + $"{containerPrefix}-storage", + $"{containerPrefix}-kong", + $"{containerPrefix}-meta", + $"{containerPrefix}-studio", + $"{containerPrefix}-edge", + $"{containerPrefix}-init" + }; + + foreach (var container in containerNames) + { + try + { + var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "docker", + Arguments = $"rm -f {container}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + process?.WaitForExit(10000); + Console.WriteLine($"[Supabase Clear] Container removed: {container}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Clear] WARNING: {container} - {ex.Message}"); + } + } + + if (!string.IsNullOrEmpty(infraPath) && Directory.Exists(infraPath)) + { + try + { + Directory.Delete(infraPath, recursive: true); + Console.WriteLine($"[Supabase Clear] Directory deleted: {infraPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Clear] WARNING: {ex.Message}"); + } + } + + Console.WriteLine("[Supabase Clear] Cleanup completed. Please restart Aspire."); + return Task.FromResult(new ExecuteCommandResult() { Success = true}); + }, options + ); + + return builder; + } + + #endregion + + #region Getters + + /// + /// Gets the Kong API Gateway container resource. + /// + public static IResourceBuilder? GetKong(this IResourceBuilder builder) + => builder.Resource.Kong; + + /// + /// Gets the PostgreSQL Database container resource. + /// + public static IResourceBuilder? GetDatabase(this IResourceBuilder builder) + => builder.Resource.Database; + + /// + /// Gets the Auth (GoTrue) container resource. + /// + public static IResourceBuilder? GetAuth(this IResourceBuilder builder) + => builder.Resource.Auth; + + /// + /// Gets the REST (PostgREST) container resource. + /// + public static IResourceBuilder? GetRest(this IResourceBuilder builder) + => builder.Resource.Rest; + + /// + /// Gets the Storage API container resource. + /// + public static IResourceBuilder? GetStorage(this IResourceBuilder builder) + => builder.Resource.Storage; + + /// + /// Gets the Postgres-Meta container resource. + /// + public static IResourceBuilder? GetMeta(this IResourceBuilder builder) + => builder.Resource.Meta; + + /// + /// Gets the Anon Key for client-side authentication. + /// + public static string GetAnonKey(this IResourceBuilder builder) + => builder.Resource.AnonKey; + + /// + /// Gets the Service Role Key for server-side authentication. + /// + public static string GetServiceRoleKey(this IResourceBuilder builder) + => builder.Resource.ServiceRoleKey; + + /// + /// Gets the API URL for environment variable injection. + /// + public static string GetApiUrl(this IResourceBuilder builder) + => builder.Resource.GetApiUrl(); + + #endregion + + #region JWT Configuration + + /// + /// Configures the JWT secret used for token signing. + /// + public static IResourceBuilder WithJwtSecret( + this IResourceBuilder builder, + string secret) + { + builder.Resource.JwtSecret = secret; + return builder; + } + + /// + /// Configures the anonymous key for public API access. + /// + public static IResourceBuilder WithAnonKey( + this IResourceBuilder builder, + string anonKey) + { + builder.Resource.AnonKey = anonKey; + return builder; + } + + /// + /// Configures the service role key for admin API access. + /// + public static IResourceBuilder WithServiceRoleKey( + this IResourceBuilder builder, + string serviceRoleKey) + { + builder.Resource.ServiceRoleKey = serviceRoleKey; + return builder; + } + + #endregion +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj b/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj new file mode 100644 index 000000000..c13493e88 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj @@ -0,0 +1,12 @@ + + + + hosting a complete supabase stack integration + A complete Supabase stack integration for .NET Aspire, providing local development with full Supabase functionality including PostgreSQL, Auth (GoTrue), REST API (PostgREST), Storage, Kong API Gateway, Studio Dashboard, and Edge Functions. + + + + + + + diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs new file mode 100644 index 000000000..b6f2d8c85 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs @@ -0,0 +1,202 @@ +using System.Text; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Helpers; + +/// +/// Generates the Deno-based Edge Function Router for multi-function support. +/// +internal static class EdgeFunctionRouter +{ + /// + /// Generates a TypeScript router that handles requests to multiple Edge Functions. + /// The router transforms each function's code at runtime to use a specific port, + /// then spawns it as a separate Deno process and proxies requests to it. + /// + /// Path to write the main.ts file + /// List of available function names + public static void GenerateRouter(string path, List functionNames) + { + var functionsJson = JsonSerializer.Serialize(functionNames); + + var sb = new StringBuilder(); + sb.AppendLine("// Auto-generated Edge Function Router/Proxy"); + sb.AppendLine("// DO NOT EDIT - This file is regenerated on each Aspire start"); + sb.AppendLine("// This router spawns each function as a separate Deno process and proxies requests to it."); + sb.AppendLine(); + sb.AppendLine("const FUNCTIONS_DIR = \"/home/deno/functions\";"); + sb.AppendLine("const BASE_PORT = 9100; // Function worker ports start here"); + sb.AppendLine("const PROXY_PORT = parseInt(Deno.env.get(\"EDGE_RUNTIME_PORT\") || \"9000\");"); + sb.AppendLine(); + sb.AppendLine("const corsHeaders = {"); + sb.AppendLine(" 'Access-Control-Allow-Origin': '*',"); + sb.AppendLine(" 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',"); + sb.AppendLine(" 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',"); + sb.AppendLine("};"); + sb.AppendLine(); + sb.AppendLine($"const availableFunctions: string[] = {functionsJson};"); + sb.AppendLine(); + sb.AppendLine("// Track running function workers"); + sb.AppendLine("const workers: Map = new Map();"); + sb.AppendLine("let nextPort = BASE_PORT;"); + sb.AppendLine(); + + // startFunctionWorker function + sb.AppendLine("async function startFunctionWorker(functionName: string): Promise {"); + sb.AppendLine(" const existing = workers.get(functionName);"); + sb.AppendLine(" if (existing) {"); + sb.AppendLine(" return existing.port;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" const port = nextPort++;"); + sb.AppendLine(" const functionPath = FUNCTIONS_DIR + \"/\" + functionName + \"/index.ts\";"); + sb.AppendLine(); + sb.AppendLine(" console.log(\"[Router] Starting worker for '\" + functionName + \"' on port \" + port);"); + sb.AppendLine(); + sb.AppendLine(" // Read the function file and transform it to use our port"); + sb.AppendLine(" const functionCode = await Deno.readTextFile(functionPath);"); + sb.AppendLine(); + sb.AppendLine(" // Transform the code: replace serve(...) with Deno.serve({ port }, ...)"); + sb.AppendLine(" // This handles the pattern: serve(async (req) => { ... })"); + sb.AppendLine(" let transformedCode = functionCode;"); + sb.AppendLine(); + sb.AppendLine(" // Replace the serve import with Deno.serve usage"); + sb.AppendLine(" // Remove the serve import line"); + sb.AppendLine(" transformedCode = transformedCode.replace("); + sb.AppendLine(" /import\\s*\\{\\s*serve\\s*\\}\\s*from\\s*[\"']https:\\/\\/deno\\.land\\/std[^\"']*\\/http\\/server\\.ts[\"'];?/g,"); + sb.AppendLine(" '// serve import removed - using Deno.serve'"); + sb.AppendLine(" );"); + sb.AppendLine(); + sb.AppendLine(" // Replace serve( with Deno.serve({ port: PORT },"); + sb.AppendLine(" transformedCode = transformedCode.replace("); + sb.AppendLine(" /\\bserve\\s*\\(/g,"); + sb.AppendLine(" 'Deno.serve({ port: ' + port + ' }, '"); + sb.AppendLine(" );"); + sb.AppendLine(); + sb.AppendLine(" console.log('[Router] Transformed code for ' + functionName);"); + sb.AppendLine(); + sb.AppendLine(" const command = new Deno.Command(\"deno\", {"); + sb.AppendLine(" args: ["); + sb.AppendLine(" \"run\","); + sb.AppendLine(" \"--allow-all\","); + sb.AppendLine(" \"-\", // Read from stdin"); + sb.AppendLine(" ],"); + sb.AppendLine(" stdin: \"piped\","); + sb.AppendLine(" stdout: \"inherit\","); + sb.AppendLine(" stderr: \"inherit\","); + sb.AppendLine(" env: Deno.env.toObject(),"); + sb.AppendLine(" });"); + sb.AppendLine(); + sb.AppendLine(" const process = command.spawn();"); + sb.AppendLine(); + sb.AppendLine(" // Write the transformed code to stdin"); + sb.AppendLine(" const writer = process.stdin.getWriter();"); + sb.AppendLine(" await writer.write(new TextEncoder().encode(transformedCode));"); + sb.AppendLine(" await writer.close();"); + sb.AppendLine(); + sb.AppendLine(" workers.set(functionName, { process, port });"); + sb.AppendLine(); + sb.AppendLine(" // Wait for the worker to start"); + sb.AppendLine(" await new Promise((resolve) => setTimeout(resolve, 2000));"); + sb.AppendLine(); + sb.AppendLine(" return port;"); + sb.AppendLine("}"); + sb.AppendLine(); + + // proxyRequest function + sb.AppendLine("async function proxyRequest(req: Request, functionName: string, port: number): Promise {"); + sb.AppendLine(" const url = new URL(req.url);"); + sb.AppendLine(" const targetUrl = \"http://localhost:\" + port + url.pathname + url.search;"); + sb.AppendLine(); + sb.AppendLine(" console.log(\"[Router] Proxying to \" + targetUrl);"); + sb.AppendLine(); + sb.AppendLine(" try {"); + sb.AppendLine(" const headers = new Headers(req.headers);"); + sb.AppendLine(); + sb.AppendLine(" const proxyReq = new Request(targetUrl, {"); + sb.AppendLine(" method: req.method,"); + sb.AppendLine(" headers: headers,"); + sb.AppendLine(" body: req.body,"); + sb.AppendLine(" redirect: 'manual',"); + sb.AppendLine(" });"); + sb.AppendLine(); + sb.AppendLine(" const response = await fetch(proxyReq);"); + sb.AppendLine(); + sb.AppendLine(" // Add CORS headers to response"); + sb.AppendLine(" const responseHeaders = new Headers(response.headers);"); + sb.AppendLine(" Object.entries(corsHeaders).forEach(([k, v]) => responseHeaders.set(k, v));"); + sb.AppendLine(); + sb.AppendLine(" return new Response(response.body, {"); + sb.AppendLine(" status: response.status,"); + sb.AppendLine(" statusText: response.statusText,"); + sb.AppendLine(" headers: responseHeaders,"); + sb.AppendLine(" });"); + sb.AppendLine(" } catch (error) {"); + sb.AppendLine(" console.error(\"[Router] Proxy error:\", error);"); + sb.AppendLine(" return new Response(JSON.stringify({ error: 'Proxy error', details: error.message }), {"); + sb.AppendLine(" status: 502,"); + sb.AppendLine(" headers: { ...corsHeaders, 'Content-Type': 'application/json' },"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + sb.AppendLine(); + + // Main server + sb.AppendLine("console.log(\"[Router] Starting Edge Function Router on port \" + PROXY_PORT);"); + sb.AppendLine("console.log(\"[Router] Available functions: \" + availableFunctions.join(', '));"); + sb.AppendLine(); + sb.AppendLine("Deno.serve({ port: PROXY_PORT }, async (req: Request) => {"); + sb.AppendLine(" const url = new URL(req.url);"); + sb.AppendLine(" const path = url.pathname;"); + sb.AppendLine(); + sb.AppendLine(" // Handle CORS preflight"); + sb.AppendLine(" if (req.method === 'OPTIONS') {"); + sb.AppendLine(" return new Response(null, { headers: corsHeaders });"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" // Health check"); + sb.AppendLine(" if (path === '/health' || path === '/') {"); + sb.AppendLine(" return new Response(JSON.stringify({"); + sb.AppendLine(" status: 'ok',"); + sb.AppendLine(" functions: availableFunctions,"); + sb.AppendLine(" workers: Array.from(workers.keys()),"); + sb.AppendLine(" }), {"); + sb.AppendLine(" headers: { ...corsHeaders, 'Content-Type': 'application/json' },"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" // Extract function name from path: /functions/v1/{name} or /{name}"); + sb.AppendLine(" const match = path.match(/^\\/(?:functions\\/v1\\/)?([^\\/]+)/);"); + sb.AppendLine(" const functionName = match?.[1];"); + sb.AppendLine(); + sb.AppendLine(" console.log(\"[Router] Request: \" + req.method + \" \" + path + \" -> function: \" + functionName);"); + sb.AppendLine(); + sb.AppendLine(" if (!functionName) {"); + sb.AppendLine(" return new Response(JSON.stringify({ error: 'No function specified', available: availableFunctions }), {"); + sb.AppendLine(" status: 400,"); + sb.AppendLine(" headers: { ...corsHeaders, 'Content-Type': 'application/json' },"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" if (!availableFunctions.includes(functionName)) {"); + sb.AppendLine(" return new Response(JSON.stringify({ error: 'Function not found: ' + functionName, available: availableFunctions }), {"); + sb.AppendLine(" status: 404,"); + sb.AppendLine(" headers: { ...corsHeaders, 'Content-Type': 'application/json' },"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" try {"); + sb.AppendLine(" const port = await startFunctionWorker(functionName);"); + sb.AppendLine(" return await proxyRequest(req, functionName, port);"); + sb.AppendLine(" } catch (error) {"); + sb.AppendLine(" console.error(\"[Router] Error handling request for '\" + functionName + \"':\", error);"); + sb.AppendLine(" return new Response(JSON.stringify({ error: error.message || 'Internal error' }), {"); + sb.AppendLine(" status: 500,"); + sb.AppendLine(" headers: { ...corsHeaders, 'Content-Type': 'application/json' },"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine("});"); + + File.WriteAllText(path, sb.ToString()); + } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs new file mode 100644 index 000000000..969d163e8 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs @@ -0,0 +1,736 @@ +namespace CommunityToolkit.Aspire.Hosting.Supabase.Helpers; + +/// +/// Helper class for generating SQL scripts and configuration files for Supabase. +/// +internal static class SupabaseSqlGenerator +{ + #region SQL Escaping + + /// + /// Escapes a string value for safe use in SQL literals. + /// + public static string EscapeSqlLiteral(string value) => value.Replace("'", "''"); + + /// + /// Escapes a string value for safe use in SQL strings with backslash support. + /// + public static string EscapeSqlString(string value) => value.Replace("'", "''").Replace("\\", "\\\\"); + + #endregion + + #region Init SQL + + /// + /// Writes the main initialization SQL script for Supabase. + /// Creates roles, schemas, extensions, storage tables, and triggers. + /// + public static void WriteInitSql(string initDir, string password) + { + var pw = EscapeSqlLiteral(password); + + var sql = $""" +-- ============================================ +-- SUPABASE INITIALIZATION SCRIPT +-- ============================================ + +-- 1. Rollen erstellen +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_admin') THEN + CREATE ROLE supabase_admin LOGIN PASSWORD '{pw}' SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS; + ELSE + ALTER ROLE supabase_admin WITH PASSWORD '{pw}'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN NOINHERIT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN NOINHERIT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator LOGIN PASSWORD '{pw}' NOINHERIT; + ELSE + ALTER ROLE authenticator WITH PASSWORD '{pw}'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_auth_admin') THEN + CREATE ROLE supabase_auth_admin LOGIN PASSWORD '{pw}' NOINHERIT CREATEROLE; + ELSE + ALTER ROLE supabase_auth_admin WITH PASSWORD '{pw}'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_storage_admin') THEN + CREATE ROLE supabase_storage_admin LOGIN PASSWORD '{pw}' NOINHERIT BYPASSRLS; + ELSE + ALTER ROLE supabase_storage_admin WITH PASSWORD '{pw}' BYPASSRLS; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_user') THEN + CREATE ROLE dashboard_user NOLOGIN; + END IF; +END +$$; + +-- 2. Rollen-Mitgliedschaften +GRANT anon, authenticated, service_role TO authenticator; +GRANT anon, authenticated, service_role TO supabase_storage_admin; +GRANT supabase_auth_admin TO supabase_admin; +GRANT supabase_storage_admin TO supabase_admin; +GRANT supabase_auth_admin TO postgres; +GRANT ALL ON DATABASE postgres TO supabase_admin; + +-- 3. Schemata erstellen +CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_auth_admin; +CREATE SCHEMA IF NOT EXISTS storage AUTHORIZATION supabase_storage_admin; +CREATE SCHEMA IF NOT EXISTS extensions AUTHORIZATION supabase_admin; +CREATE SCHEMA IF NOT EXISTS graphql_public; + +-- 4. Extensions installieren +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions; +CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA extensions; + +-- 5. Grants für public Schema +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; +GRANT ALL ON SCHEMA public TO supabase_admin, supabase_auth_admin, supabase_storage_admin; +GRANT CREATE ON SCHEMA public TO supabase_auth_admin, supabase_storage_admin; +GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated, service_role, supabase_auth_admin, supabase_storage_admin; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated, service_role, supabase_auth_admin, supabase_storage_admin; +GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated, service_role; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon, authenticated, service_role; + +-- 6. Grants für extensions Schema +GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role, supabase_admin; +GRANT ALL ON ALL FUNCTIONS IN SCHEMA extensions TO anon, authenticated, service_role; + +-- 7. Auth Enums erstellen +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname='auth' AND t.typname='factor_type') THEN + CREATE TYPE auth.factor_type AS ENUM ('totp', 'webauthn'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname='auth' AND t.typname='factor_status') THEN + CREATE TYPE auth.factor_status AS ENUM ('unverified', 'verified'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname='auth' AND t.typname='aal_level') THEN + CREATE TYPE auth.aal_level AS ENUM ('aal1', 'aal2', 'aal3'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname='auth' AND t.typname='code_challenge_method') THEN + CREATE TYPE auth.code_challenge_method AS ENUM ('s256', 'plain'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname='auth' AND t.typname='one_time_token_type') THEN + CREATE TYPE auth.one_time_token_type AS ENUM ('confirmation_token', 'reauthentication_token', 'recovery_token', 'email_change_token_new', 'email_change_token_current', 'phone_change_token'); + END IF; +END +$$; + +ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin; +ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin; +ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin; +ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin; +ALTER TYPE auth.one_time_token_type OWNER TO supabase_auth_admin; + +-- 8. Grants für auth Schema (GoTrue erstellt Tabellen selbst via Migrationen) +GRANT USAGE ON SCHEMA auth TO supabase_auth_admin, supabase_admin, service_role, postgres; +GRANT ALL ON ALL TABLES IN SCHEMA auth TO supabase_auth_admin, supabase_admin; +GRANT ALL ON ALL SEQUENCES IN SCHEMA auth TO supabase_auth_admin, supabase_admin; + +-- 9. Grants für storage Schema +GRANT USAGE ON SCHEMA storage TO supabase_storage_admin, supabase_admin, authenticated, anon, service_role; +GRANT ALL ON ALL TABLES IN SCHEMA storage TO supabase_storage_admin, supabase_admin, service_role; +GRANT SELECT ON ALL TABLES IN SCHEMA storage TO authenticated, anon; +GRANT ALL ON ALL SEQUENCES IN SCHEMA storage TO supabase_storage_admin, supabase_admin, service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT ALL ON TABLES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT ALL ON SEQUENCES TO service_role; + +-- 10. Storage Tabellen erstellen +CREATE TABLE IF NOT EXISTS storage.buckets ( + id text NOT NULL PRIMARY KEY, + name text NOT NULL UNIQUE, + owner uuid, + created_at timestamptz DEFAULT NOW() NOT NULL, + updated_at timestamptz DEFAULT NOW() NOT NULL, + public boolean DEFAULT false, + avif_autodetection boolean DEFAULT false, + file_size_limit bigint, + allowed_mime_types text[], + owner_id text +); +ALTER TABLE storage.buckets OWNER TO supabase_storage_admin; + +CREATE TABLE IF NOT EXISTS storage.objects ( + id uuid DEFAULT extensions.uuid_generate_v4() NOT NULL PRIMARY KEY, + bucket_id text REFERENCES storage.buckets(id), + name text, + owner uuid, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW(), + last_accessed_at timestamptz DEFAULT NOW(), + metadata jsonb, + path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/')) STORED, + version text, + owner_id text +); +ALTER TABLE storage.objects OWNER TO supabase_storage_admin; + +CREATE TABLE IF NOT EXISTS storage.s3_multipart_uploads ( + id text NOT NULL PRIMARY KEY, + in_progress_size bigint DEFAULT 0 NOT NULL, + upload_signature text NOT NULL, + bucket_id text NOT NULL REFERENCES storage.buckets(id), + key text NOT NULL, + version text NOT NULL, + owner_id text, + created_at timestamptz DEFAULT NOW() NOT NULL +); +ALTER TABLE storage.s3_multipart_uploads OWNER TO supabase_storage_admin; + +CREATE TABLE IF NOT EXISTS storage.s3_multipart_uploads_parts ( + id uuid DEFAULT extensions.uuid_generate_v4() NOT NULL PRIMARY KEY, + upload_id text NOT NULL REFERENCES storage.s3_multipart_uploads(id) ON DELETE CASCADE, + size bigint DEFAULT 0 NOT NULL, + part_number integer NOT NULL, + bucket_id text NOT NULL REFERENCES storage.buckets(id), + key text NOT NULL, + etag text NOT NULL, + owner_id text, + version text NOT NULL, + created_at timestamptz DEFAULT NOW() NOT NULL +); +ALTER TABLE storage.s3_multipart_uploads_parts OWNER TO supabase_storage_admin; + +CREATE TABLE IF NOT EXISTS storage.migrations ( + id integer NOT NULL PRIMARY KEY, + name varchar(100) NOT NULL UNIQUE, + hash varchar(40) NOT NULL, + executed_at timestamp DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE storage.migrations OWNER TO supabase_storage_admin; + +CREATE INDEX IF NOT EXISTS objects_bucket_id_name_idx ON storage.objects (bucket_id, name); +CREATE INDEX IF NOT EXISTS objects_owner_idx ON storage.objects (owner); + +-- 11. RLS für Storage aktivieren +ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY; +ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Public buckets are viewable by everyone" ON storage.buckets; +CREATE POLICY "Public buckets are viewable by everyone" + ON storage.buckets FOR SELECT USING (public = true); + +DROP POLICY IF EXISTS "Objects in public buckets are viewable by everyone" ON storage.objects; +CREATE POLICY "Objects in public buckets are viewable by everyone" + ON storage.objects FOR SELECT + USING (bucket_id IN (SELECT id FROM storage.buckets WHERE public = true)); + +DROP POLICY IF EXISTS "Service role has full access to buckets" ON storage.buckets; +CREATE POLICY "Service role has full access to buckets" + ON storage.buckets FOR ALL TO service_role + USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS "Service role has full access to objects" ON storage.objects; +CREATE POLICY "Service role has full access to objects" + ON storage.objects FOR ALL TO service_role + USING (true) WITH CHECK (true); + +-- Dev-Mode: Allow all für Storage (anon und authenticated) +DROP POLICY IF EXISTS "Allow all for development" ON storage.buckets; +CREATE POLICY "Allow all for development" + ON storage.buckets FOR ALL + USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS "Allow all for development" ON storage.objects; +CREATE POLICY "Allow all for development" + ON storage.objects FOR ALL + USING (true) WITH CHECK (true); + +GRANT ALL ON storage.buckets TO anon, authenticated; +GRANT ALL ON storage.objects TO anon, authenticated; + +-- 12. Search Path für PostgREST +ALTER DATABASE postgres SET search_path TO public, extensions; + +-- 13. Schema Reload Trigger +CREATE OR REPLACE FUNCTION extensions.notify_api_restart() +RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN + NOTIFY pgrst, 'reload schema'; +END; +$$; + +DROP EVENT TRIGGER IF EXISTS api_restart; +CREATE EVENT TRIGGER api_restart ON ddl_command_end + EXECUTE FUNCTION extensions.notify_api_restart(); + +-- 14. Auto-Create Profile & Role Trigger für neue User +-- WICHTIG: Mit Exception-Handling damit User-Erstellung NIEMALS blockiert wird +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER SET search_path = public, extensions +AS $$ +BEGIN + -- Profil erstellen (mit Exception-Handling) + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN + IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE user_id = NEW.id) THEN + INSERT INTO public.profiles (user_id, email, display_name, is_disabled, created_at, updated_at) + VALUES ( + NEW.id, + NEW.email, + COALESCE(NEW.raw_user_meta_data->>'display_name', NEW.email), + false, + NOW(), + NOW() + ); + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[handle_new_user] Profil-Erstellung fehlgeschlagen für %: %', NEW.email, SQLERRM; + END; + + -- Admin-Rolle erstellen (mit Exception-Handling) + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'user_roles') THEN + IF NOT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = NEW.id) THEN + INSERT INTO public.user_roles (user_id, role, created_at) + VALUES ( + NEW.id, + 'admin', + NOW() + ); + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[handle_new_user] Rollen-Erstellung fehlgeschlagen für %: %', NEW.email, SQLERRM; + END; + + -- IMMER NEW zurückgeben, damit User-Erstellung NICHT blockiert wird + RETURN NEW; +END; +$$; + +-- Trigger erstellen (falls auth.users existiert) +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN + DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; + CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.handle_new_user(); + END IF; +END; +$$; +"""; + File.WriteAllText(Path.Combine(initDir, "00_init.sql"), sql); + } + + #endregion + + #region Post-Init SQL + + /// + /// Writes the post-initialization SQL script that runs after GoTrue starts. + /// Creates triggers for new users and fills in profiles for existing users. + /// + public static void WritePostInitSql(string path) + { + var sql = """ +-- ============================================ +-- POST-INIT: User-Erstellung und Profile +-- ============================================ + +-- Kurz warten damit GoTrue die Tabellen erstellen kann +SELECT pg_sleep(2); + +-- Trigger erstellen (auth.users existiert jetzt) +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'on_auth_user_created') THEN + CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.handle_new_user(); + RAISE NOTICE '[Post-Init] Trigger on_auth_user_created erstellt'; + ELSE + RAISE NOTICE '[Post-Init] Trigger on_auth_user_created existiert bereits'; + END IF; + ELSE + RAISE NOTICE '[Post-Init] auth.users existiert noch nicht'; + END IF; +END; +$$; + +-- Erstelle Profile für weitere existierende User ohne Profil (mit Exception-Handling) +DO $$ +DECLARE + inserted_count integer; +BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') + AND EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') + THEN + BEGIN + INSERT INTO public.profiles (user_id, email, display_name, is_disabled, created_at, updated_at) + SELECT + u.id, + u.email, + COALESCE(u.raw_user_meta_data->>'display_name', u.email), + false, + NOW(), + NOW() + FROM auth.users u + WHERE NOT EXISTS (SELECT 1 FROM public.profiles p WHERE p.user_id = u.id); + + GET DIAGNOSTICS inserted_count = ROW_COUNT; + IF inserted_count > 0 THEN + RAISE NOTICE '[Post-Init] % weitere Profile erstellt', inserted_count; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] Profil-Erstellung fehlgeschlagen: %', SQLERRM; + END; + END IF; +END; +$$; + +-- Erstelle Admin-Rollen für User ohne Rolle (mit Exception-Handling) +DO $$ +DECLARE + inserted_count integer; +BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'user_roles') + AND EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') + THEN + BEGIN + INSERT INTO public.user_roles (user_id, role, created_at) + SELECT + u.id, + 'admin', + NOW() + FROM auth.users u + WHERE NOT EXISTS (SELECT 1 FROM public.user_roles r WHERE r.user_id = u.id); + + GET DIAGNOSTICS inserted_count = ROW_COUNT; + IF inserted_count > 0 THEN + RAISE NOTICE '[Post-Init] % Admin-Rollen erstellt', inserted_count; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] Rollen-Erstellung fehlgeschlagen: %', SQLERRM; + END; + END IF; +END; +$$; + +SELECT 'Post-Init abgeschlossen' as status; +"""; + File.WriteAllText(path, sql); + } + + #endregion + + #region Post-Init Shell Script + + /// + /// Writes the post-initialization shell script that waits for the database + /// and executes post_init.sql, migrations.sql, and users.sql. + /// + public static void WritePostInitScript(string path, string dbHost, string password) + { + var script = $""" +#!/bin/bash +# Post-Init Script mit Retry-Logik + +export PGPASSWORD='{password}' +DB_HOST='{dbHost}' +MAX_RETRIES=30 +RETRY_INTERVAL=2 + +echo "[Post-Init] Warte auf Datenbankverbindung..." + +for i in $(seq 1 $MAX_RETRIES); do + if pg_isready -h $DB_HOST -U postgres -q; then + echo "[Post-Init] Datenbank ist bereit (Versuch $i)" + break + fi + echo "[Post-Init] Warte auf Datenbank... (Versuch $i/$MAX_RETRIES)" + sleep $RETRY_INTERVAL +done + +# Zusätzliche Wartezeit damit GoTrue die Tabellen erstellen kann +echo "[Post-Init] Warte 10 Sekunden für GoTrue Migrationen..." +sleep 10 + +echo "[Post-Init] Führe Basis-SQL aus..." +psql -h $DB_HOST -U postgres -d postgres -f /scripts/post_init.sql + +# Migrations ausführen falls vorhanden (muss NACH GoTrue laufen wegen auth.users) +if [ -f /scripts/migrations.sql ]; then + echo "[Post-Init] Führe Migrations aus..." + psql -h $DB_HOST -U postgres -d postgres -f /scripts/migrations.sql + if [ $? -eq 0 ]; then + echo "[Post-Init] Migrations erfolgreich" + else + echo "[Post-Init] WARNUNG: Migrations hatten Fehler" + fi +fi + +# User-SQL ausführen falls vorhanden +if [ -f /scripts/users.sql ]; then + echo "[Post-Init] Führe User-SQL aus..." + psql -h $DB_HOST -U postgres -d postgres -f /scripts/users.sql +fi + +echo "[Post-Init] Erfolgreich abgeschlossen" +"""; + File.WriteAllText(path, script.Replace("\r\n", "\n")); // Unix line endings + } + + #endregion + + #region User SQL + + /// + /// Appends SQL to create a user with profile and admin role. + /// + public static void AppendUserSql(string path, string email, string password, string displayName) + { + var escapedEmail = EscapeSqlLiteral(email); + var escapedDisplayName = EscapeSqlLiteral(displayName); + var escapedPassword = EscapeSqlLiteral(password); + + var appMetaData = @"{""provider"": ""email"", ""providers"": [""email""]}"; + var userMetaData = @"{""display_name"": """ + escapedDisplayName + @"""}"; + + var sql = $""" +-- User: {email} +DO $$ +DECLARE + new_user_id uuid; + hashed_password text; +BEGIN + -- Prüfe ob User bereits existiert + SELECT id INTO new_user_id FROM auth.users WHERE email = '{escapedEmail}'; + + IF new_user_id IS NULL THEN + -- Passwort hashen + hashed_password := extensions.crypt('{escapedPassword}', extensions.gen_salt('bf', 10)); + + -- User in auth.users erstellen + INSERT INTO auth.users ( + instance_id, id, aud, role, email, encrypted_password, + email_confirmed_at, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at, confirmation_token, email_change, + email_change_token_new, recovery_token + ) VALUES ( + '00000000-0000-0000-0000-000000000000', + extensions.uuid_generate_v4(), + 'authenticated', 'authenticated', '{escapedEmail}', hashed_password, + NOW(), '{appMetaData}'::jsonb, '{userMetaData}'::jsonb, + NOW(), NOW(), '', '', '', '' + ) + RETURNING id INTO new_user_id; + + RAISE NOTICE '[Post-Init] User erstellt: {escapedEmail} (ID: %)', new_user_id; + + -- Profil erstellen (mit Exception-Handling) + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN + IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE user_id = new_user_id) THEN + INSERT INTO public.profiles (user_id, email, display_name, is_disabled, created_at, updated_at) + VALUES (new_user_id, '{escapedEmail}', '{escapedDisplayName}', false, NOW(), NOW()); + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] Profil-Erstellung fehlgeschlagen für {escapedEmail}: %', SQLERRM; + END; + + -- Admin-Rolle erstellen (mit Exception-Handling) + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'user_roles') THEN + IF NOT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = new_user_id) THEN + INSERT INTO public.user_roles (user_id, role, created_at) + VALUES (new_user_id, 'admin', NOW()); + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] Rollen-Erstellung fehlgeschlagen für {escapedEmail}: %', SQLERRM; + END; + ELSE + RAISE NOTICE '[Post-Init] User existiert bereits: {escapedEmail}'; + END IF; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING '[Post-Init] User-Erstellung komplett fehlgeschlagen für {escapedEmail}: %', SQLERRM; +END; +$$; + +"""; + File.AppendAllText(path, sql); + } + + #endregion + + #region Kong Configuration + + /// + /// Writes the Kong API Gateway configuration YAML file. + /// + public static void WriteKongConfig(string path, string anonKey, string serviceKey, string containerPrefix, int goTruePort, int postRestPort, int storagePort, int metaPort, int edgeRuntimePort) + { + var yaml = $""" +_format_version: '2.1' +_transform: true + +consumers: + - username: anon + keyauth_credentials: + - key: {anonKey} + - username: service_role + keyauth_credentials: + - key: {serviceKey} + +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +services: + - name: auth-v1-open + url: http://{containerPrefix}-auth:{goTruePort}/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + + - name: auth-v1-open-callback + url: http://{containerPrefix}-auth:{goTruePort}/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + + - name: auth-v1-open-authorize + url: http://{containerPrefix}-auth:{goTruePort}/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + - name: auth-v1 + url: http://{containerPrefix}-auth:{goTruePort} + routes: + - name: auth-v1 + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: rest-v1 + url: http://{containerPrefix}-rest:{postRestPort} + routes: + - name: rest-v1 + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: storage-v1 + url: http://{containerPrefix}-storage:{storagePort} + routes: + - name: storage-v1 + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: meta + url: http://{containerPrefix}-meta:{metaPort} + routes: + - name: meta + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + - name: functions-v1 + url: http://{containerPrefix}-edge:{edgeRuntimePort} + routes: + - name: functions-v1 + strip_path: false + paths: + - /functions/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon +"""; + File.WriteAllText(path, yaml); + } + + #endregion +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/README.md b/CommunityToolkit.Aspire.Hosting.Supabase/README.md new file mode 100644 index 000000000..022761591 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/README.md @@ -0,0 +1,464 @@ +# CommunityToolkit.Aspire.Hosting.Supabase provides Supabase for .NET Aspire + +A complete Supabase stack integration for .NET Aspire, providing local development with full Supabase functionality including PostgreSQL, Auth (GoTrue), REST API (PostgREST), Storage, Kong API Gateway, Studio Dashboard, and Edge Functions. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Configuration Options](#configuration-options) +- [Syncing from Remote Supabase Project](#syncing-from-remote-supabase-project) +- [Local Migrations](#local-migrations) +- [Edge Functions](#edge-functions) +- [Registered Users](#registered-users) +- [Sub-Resource Configuration](#sub-resource-configuration) +- [Dashboard Commands](#dashboard-commands) +- [Accessing Resources](#accessing-resources) +- [Environment Variables for Frontend](#environment-variables-for-frontend) + +--- + +## Quick Start + +The simplest way to add a complete Supabase stack to your Aspire application: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var supabase = builder.AddSupabase("supabase"); + +builder.Build().Run(); +``` + +This starts a fully functional Supabase stack with: +- PostgreSQL database (port 54322) +- GoTrue authentication +- PostgREST API +- Storage API +- Kong API Gateway (port 8000) +- Studio Dashboard (port 54323) +- Postgres Meta + +All services use sensible defaults and are ready for local development. + +--- + +## Configuration Options + +### Database Configuration + +```csharp +var supabase = builder.AddSupabase("supabase") + .ConfigureDatabase(db => db + .WithPassword("my-secure-password") + .WithPort(54322)); +``` + +### All Available Configure Methods + +Each sub-resource can be configured individually: + +```csharp +var supabase = builder.AddSupabase("supabase") + // PostgreSQL Database + .ConfigureDatabase(db => db + .WithPassword("secure-password") + .WithPort(54322)) + + // GoTrue Authentication + .ConfigureAuth(auth => auth + .WithAutoConfirm(true) + .WithDisableSignup(false) + .WithJwtExpiration(3600) + .WithSiteUrl("http://localhost:3000")) + + // PostgREST API + .ConfigureRest(rest => rest + .WithSchemas("public", "storage", "graphql_public") + .WithAnonRole("anon")) + + // Storage API + .ConfigureStorage(storage => storage + .WithFileSizeLimit(52428800)) // 50MB + + // Kong API Gateway + .ConfigureKong(kong => kong + .WithPort(8000)) + + // Postgres Meta + .ConfigureMeta(meta => meta + .WithPort(8080)) + + // Studio Dashboard + .ConfigureStudio(studio => studio + .WithPort(54323) + .WithOrganizationName("My Organization") + .WithProjectName("My Project")) + + // Edge Runtime + .ConfigureEdgeRuntime(edge => edge + .WithPort(9000)); +``` + +### Direct Container Access + +Each Configure method has an overload that provides direct access to the container builder: + +```csharp +.ConfigureDatabase( + db => db.WithPassword("password"), + container => container + .WithEnvironment("CUSTOM_VAR", "value") + .WithVolume("my-volume", "/data")) +``` + +--- + +## Syncing from Remote Supabase Project + +Synchronize schema, data, storage, and more from an existing Supabase cloud project: + +### Basic Sync + +```csharp +const string projectRef = "your-project-ref"; +const string serviceKey = "eyJhbGciOiJIUzI1NiIs..."; // service_role key + +var supabase = builder.AddSupabase("supabase") + .WithProjectSync(projectRef, serviceKey); +``` + +### Sync Options + +Control what gets synchronized using `SyncOptions`: + +```csharp +var supabase = builder.AddSupabase("supabase") + .WithProjectSync( + projectRef, + serviceKey, + SyncOptions.Schema | SyncOptions.Data | SyncOptions.StorageBuckets); +``` + +Available options: + +| Option | Description | +|--------|-------------| +| `Schema` | Table structures (columns, types, constraints) | +| `Data` | Table data | +| `Policies` | Row Level Security policies (requires DB password) | +| `Functions` | Stored procedures and functions (requires DB password) | +| `Triggers` | Database triggers (requires DB password) | +| `Types` | Custom types and enums (requires DB password) | +| `Views` | Database views (requires DB password) | +| `Indexes` | Table indexes (requires DB password) | +| `StorageBuckets` | Storage bucket definitions | +| `StorageFiles` | Storage files (downloads from remote) | +| `EdgeFunctions` | Edge Functions (requires Management API token) | +| `AllSchema` | All schema-related options | +| `AllStorage` | StorageBuckets + StorageFiles | +| `All` | Everything | + +### Full Sync with Database Password + +For complete schema sync including policies, functions, and triggers: + +```csharp +const string dbPassword = "your-database-password"; // From Dashboard → Project Settings → Database + +var supabase = builder.AddSupabase("supabase") + .WithProjectSync( + projectRef, + serviceKey, + SyncOptions.All, + dbPassword); +``` + +### Edge Functions Sync + +To sync Edge Functions, you need a Management API token: + +```csharp +const string managementApiToken = "sbp_..."; // From Dashboard → Account → Access Tokens + +var supabase = builder.AddSupabase("supabase") + .WithProjectSync( + projectRef, + serviceKey, + SyncOptions.All, + dbPassword, + managementApiToken); +``` + +> **Note:** The Supabase Management API returns compiled ESZIP bundles, not source code. The sync creates placeholder files with instructions to manually copy the source code from the Dashboard or use `supabase functions download`. + +### Where to Find Keys + +| Key | Location | +|-----|----------| +| Project Ref | Dashboard URL: `https://supabase.com/dashboard/project/{project-ref}` | +| Service Role Key | Dashboard → Project Settings → API → `service_role` (secret) | +| Database Password | Dashboard → Project Settings → Database → Database password | +| Management API Token | Dashboard → Account (top right) → Access Tokens | + +--- + +## Local Migrations + +Apply local SQL migration files to your Supabase instance: + +```csharp +var migrationsPath = Path.Combine(builder.AppHostDirectory, "..", "supabase", "migrations"); + +var supabase = builder.AddSupabase("supabase") + .WithMigrations(migrationsPath); +``` + +Migration files should follow the naming convention: `YYYYMMDDHHMMSS_description.sql` + +Example structure: +``` +supabase/ + migrations/ + 20240101000000_create_users_table.sql + 20240102000000_add_profiles.sql + 20240103000000_create_policies.sql +``` + +--- + +## Edge Functions + +### Using Local Edge Functions + +Point to your local Edge Functions directory: + +```csharp +var edgeFunctionsPath = Path.Combine(builder.AppHostDirectory, "..", "supabase", "functions"); + +var supabase = builder.AddSupabase("supabase") + .WithEdgeFunctions(edgeFunctionsPath); +``` + +Expected directory structure: +``` +supabase/ + functions/ + my-function/ + index.ts + another-function/ + index.ts +``` + +### Edge Function Format + +Each function should be in its own directory with an `index.ts` file: + +```typescript +// supabase/functions/hello-world/index.ts +import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; + +serve(async (req) => { + const { name } = await req.json(); + + return new Response( + JSON.stringify({ message: `Hello ${name}!` }), + { headers: { "Content-Type": "application/json" } } + ); +}); +``` + +### Calling Edge Functions + +Edge Functions are available through Kong at: +``` +http://localhost:8000/functions/v1/{function-name} +``` + +Example: +```bash +curl -X POST http://localhost:8000/functions/v1/hello-world \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_ANON_KEY" \ + -d '{"name": "World"}' +``` + +--- + +## Registered Users + +Pre-create users for development and testing: + +```csharp +var supabase = builder.AddSupabase("supabase") + .WithRegisteredUser("admin@example.com", "password123", "Admin User") + .WithRegisteredUser("test@example.com", "test1234", "Test User"); +``` + +These users: +- Are created with confirmed email status +- Automatically get a profile in `public.profiles` (if table exists) +- Automatically get an admin role in `public.user_roles` (if table exists) + +--- + +## Dashboard Commands + +Add a "Clear All Data" button to the Aspire dashboard: + +```csharp +var supabase = builder.AddSupabase("supabase") + .WithClearCommand(); +``` + +This adds a command in the Aspire dashboard that truncates all tables in the `public` schema. + +--- + +## Accessing Resources + +### Get Sub-Resource Builders + +Access individual container resources for advanced configuration: + +```csharp +var supabase = builder.AddSupabase("supabase"); + +var kong = supabase.GetKong(); +var studio = supabase.GetStudio(); +var database = supabase.GetDatabase(); +var auth = supabase.GetAuth(); +var rest = supabase.GetRest(); +var storage = supabase.GetStorage(); +var meta = supabase.GetMeta(); +var edge = supabase.GetEdgeRuntime(); +``` + +### Access Keys and Endpoints + +```csharp +var supabase = builder.AddSupabase("supabase"); + +// Get the anon key for client-side use +var anonKey = supabase.Resource.AnonKey; + +// Get the service role key for server-side use +var serviceRoleKey = supabase.Resource.ServiceRoleKey; + +// Get the Kong endpoint (main API gateway) +var kongEndpoint = supabase.Resource.Kong!.GetEndpoint("http"); +``` + +--- + +## Environment Variables for Frontend + +Configure your frontend application with Supabase environment variables: + +```csharp +var supabase = builder.AddSupabase("supabase"); + +var frontend = builder.AddNpmApp("frontend", "../frontend") + .WithEnvironment("VITE_SUPABASE_URL", supabase.Resource.Kong!.GetEndpoint("http")) + .WithEnvironment("VITE_SUPABASE_ANON_KEY", supabase.Resource.AnonKey); +``` + +Or for a JavaScript/TypeScript app: + +```csharp +var frontend = builder.AddJavaScriptApp("frontend", "../frontend", "dev") + .WithEnvironment("VITE_SUPABASE_URL", supabase.Resource.Kong!.GetEndpoint("http")) + .WithEnvironment("VITE_SUPABASE_PUBLISHABLE_KEY", supabase.Resource.AnonKey); +``` + +--- + +## Complete Example + +Here's a complete example combining multiple features: + +```csharp +using MandateManager.AppHost.Extensions; + +var builder = DistributedApplication.CreateBuilder(args); + +// Paths +var supabasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "supabase")); +var migrationsPath = Path.Combine(supabasePath, "migrations"); +var edgeFunctionsPath = Path.Combine(supabasePath, "functions"); + +// Supabase configuration +var supabase = builder.AddSupabase("supabase") + // Database settings + .ConfigureDatabase(db => db + .WithPassword("secure-dev-password") + .WithPort(54322)) + + // Studio settings + .ConfigureStudio(studio => studio + .WithPort(54323) + .WithProjectName("My Project")) + + // Apply local migrations + .WithMigrations(migrationsPath) + + // Enable local Edge Functions + .WithEdgeFunctions(edgeFunctionsPath) + + // Pre-create dev users + .WithRegisteredUser("admin@example.com", "Admin123!", "Admin User") + .WithRegisteredUser("user@example.com", "User123!", "Regular User") + + // Add clear data command to dashboard + .WithClearCommand(); + +// Frontend +var frontend = builder.AddJavaScriptApp("frontend", "../..", "dev") + .WithHttpEndpoint(port: 3000, targetPort: 3000) + .WithEnvironment("VITE_SUPABASE_URL", supabase.Resource.Kong!.GetEndpoint("http")) + .WithEnvironment("VITE_SUPABASE_ANON_KEY", supabase.Resource.AnonKey); + +builder.Build().Run(); +``` + +--- + +## Default Ports + +| Service | Default Port | +|---------|--------------| +| PostgreSQL | 54322 | +| Kong (API Gateway) | 8000 | +| Studio Dashboard | 54323 | +| GoTrue (Auth) | 9999 (internal) | +| PostgREST | 3000 (internal) | +| Storage API | 5000 (internal) | +| Postgres Meta | 8080 (internal) | +| Edge Runtime | 9000 (internal) | + +--- + +## Troubleshooting + +### Edge Functions not working + +1. Check that the functions directory exists and contains subdirectories with `index.ts` files +2. Verify the function follows the expected format with `serve()` from Deno std library +3. Check the Edge Runtime container logs in the Aspire dashboard + +### Database connection issues + +1. Ensure no other PostgreSQL instance is running on port 54322 +2. Check the database container logs for startup errors +3. Verify the password matches between configurations + +### Sync not working + +1. Verify the service key is the `service_role` key (starts with `eyJ...`), not the CLI key +2. For full schema sync, ensure the database password is provided +3. Check the console output for specific sync errors + +--- + +## License + +This Supabase Aspire integration is part of the MandateManager project. diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs new file mode 100644 index 000000000..c58a26790 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs @@ -0,0 +1,47 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase GoTrue authentication container resource. +/// +public sealed class SupabaseAuthResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseAuthResource. + /// + /// The name of the auth container. + public SupabaseAuthResource(string name) : base(name) + { + } + + /// + /// Gets or sets the site URL for authentication redirects. + /// + public string SiteUrl { get; internal set; } = "http://localhost:3000"; + + /// + /// Gets or sets whether email auto-confirmation is enabled. + /// + public bool AutoConfirm { get; internal set; } = true; + + /// + /// Gets or sets whether signup is disabled. + /// + public bool DisableSignup { get; internal set; } = false; + + /// + /// Gets or sets whether anonymous users are enabled. + /// + public bool AnonymousUsersEnabled { get; internal set; } = true; + + /// + /// Gets or sets the JWT expiration time in seconds. + /// + public int JwtExpiration { get; internal set; } = 3600; + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs new file mode 100644 index 000000000..5fb0b9c31 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs @@ -0,0 +1,32 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase PostgreSQL database container resource. +/// +public sealed class SupabaseDatabaseResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseDatabaseResource. + /// + /// The name of the database container. + public SupabaseDatabaseResource(string name) : base(name) + { + } + + /// + /// Gets or sets the database password. + /// + public string Password { get; internal set; } = "postgres-insecure-dev-password"; + + /// + /// Gets or sets the external port for PostgreSQL connections. + /// + public int ExternalPort { get; internal set; } = 54322; + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs new file mode 100644 index 000000000..0bfd2d4f5 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs @@ -0,0 +1,37 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase Edge Runtime container resource for Edge Functions. +/// +public sealed class SupabaseEdgeRuntimeResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseEdgeRuntimeResource. + /// + /// The name of the edge runtime container. + public SupabaseEdgeRuntimeResource(string name) : base(name) + { + } + + /// + /// Gets or sets the internal port for the edge runtime. + /// + public int Port { get; internal set; } = 9000; + + /// + /// Gets the list of function names available in this runtime. + /// + public List FunctionNames { get; } = []; + + /// + /// Gets or sets the path to the edge functions directory. + /// + public string? FunctionsPath { get; internal set; } + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs new file mode 100644 index 000000000..1b1153f98 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs @@ -0,0 +1,32 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase Kong API Gateway container resource. +/// +public sealed class SupabaseKongResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseKongResource. + /// + /// The name of the Kong container. + public SupabaseKongResource(string name) : base(name) + { + } + + /// + /// Gets or sets the external port for the API gateway. + /// + public int ExternalPort { get; internal set; } = 8000; + + /// + /// Gets or sets the Kong plugins to enable. + /// + public string[] Plugins { get; internal set; } = ["request-transformer", "cors", "key-auth", "acl", "basic-auth"]; + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs new file mode 100644 index 000000000..f8a8184de --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs @@ -0,0 +1,27 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase Postgres-Meta container resource. +/// +public sealed class SupabaseMetaResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseMetaResource. + /// + /// The name of the meta container. + public SupabaseMetaResource(string name) : base(name) + { + } + + /// + /// Gets or sets the internal port for the meta service. + /// + public int Port { get; internal set; } = 8080; + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs new file mode 100644 index 000000000..6045ae692 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs @@ -0,0 +1,32 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase PostgREST container resource. +/// +public sealed class SupabaseRestResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseRestResource. + /// + /// The name of the REST container. + public SupabaseRestResource(string name) : base(name) + { + } + + /// + /// Gets or sets the database schemas to expose. + /// + public string[] Schemas { get; internal set; } = ["public", "storage", "graphql_public"]; + + /// + /// Gets or sets the anonymous role name. + /// + public string AnonRole { get; internal set; } = "anon"; + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs new file mode 100644 index 000000000..d2f4fa5db --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs @@ -0,0 +1,165 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a registered development user. +/// +public record RegisteredUser(string Email, string Password, string DisplayName); + +/// +/// Represents a complete Supabase stack resource containing all sub-services. +/// This resource IS the Studio Dashboard container and serves as the visual parent +/// for all other Supabase containers in the Aspire dashboard. +/// +public sealed class SupabaseStackResource : ContainerResource, IResourceWithConnectionString +{ + /// + /// Creates a new instance of the SupabaseStackResource. + /// + /// The name of the Supabase stack (will be the Studio container name). + public SupabaseStackResource(string name) : base(name) + { + } + + // --- Secrets auf Stack-Ebene --- + + /// + /// Gets or sets the JWT secret used for token signing. + /// + public string JwtSecret { get; internal set; } = string.Empty; + + /// + /// Gets the Anon Key for client-side authentication. + /// + public string AnonKey { get; internal set; } = string.Empty; + + /// + /// Gets the Service Role Key for server-side authentication. + /// + public string ServiceRoleKey { get; internal set; } = string.Empty; + + // --- Typisierte Container Referenzen --- + + /// + /// Gets the PostgreSQL database container resource. + /// + public IResourceBuilder? Database { get; internal set; } + + /// + /// Gets the GoTrue authentication container resource. + /// + public IResourceBuilder? Auth { get; internal set; } + + /// + /// Gets the PostgREST container resource. + /// + public IResourceBuilder? Rest { get; internal set; } + + /// + /// Gets the Storage API container resource. + /// + public IResourceBuilder? Storage { get; internal set; } + + /// + /// Gets the Kong API Gateway container resource. + /// + public IResourceBuilder? Kong { get; internal set; } + + /// + /// Gets the Postgres-Meta container resource. + /// + public IResourceBuilder? Meta { get; internal set; } + + /// + /// Gets the Edge Runtime container resource for Edge Functions. + /// + public IResourceBuilder? EdgeRuntime { get; internal set; } + + // --- Connection String --- + + /// + /// Gets the connection string expression for the Supabase API (Kong endpoint URL). + /// + public ReferenceExpression ConnectionStringExpression + { + get + { + if (Kong == null) + throw new InvalidOperationException("Kong not configured. Ensure AddSupabase() has been called."); + + return ReferenceExpression.Create($"http://localhost:{Kong.Resource.ExternalPort.ToString()}"); + } + } + + // --- Internal Configuration --- + + /// + /// Reference to the distributed application builder for adding containers. + /// + internal IDistributedApplicationBuilder? AppBuilder { get; set; } + + /// + /// Reference to the resource builder for this stack (used for chaining). + /// + internal IResourceBuilder? StackBuilder { get; set; } + + /// + /// Root directory for infrastructure files. + /// + internal string? InfraRootDir { get; set; } + + /// + /// Path to the database initialization SQL scripts directory. + /// + internal string? InitSqlPath { get; set; } + + /// + /// Path to the Edge Functions directory. + /// + internal string? EdgeFunctionsPath { get; set; } + + /// + /// List of users to register on startup. + /// + internal List RegisteredUsers { get; } = []; + + // --- Sync Configuration --- + + internal string? SyncFromProjectRef { get; set; } + internal string? SyncServiceKey { get; set; } + internal bool SyncSchema { get; set; } = true; + internal bool SyncData { get; set; } = false; + + // --- Computed Properties --- + + /// + /// Gets the Supabase API URL (Kong endpoint). + /// + public string GetApiUrl() => + Kong != null + ? $"http://localhost:{Kong.Resource.ExternalPort}" + : throw new InvalidOperationException("Kong not configured"); + + /// + /// Gets the Studio Dashboard URL (this resource IS the Studio). + /// + public string GetStudioUrl() => + StackBuilder != null + ? $"http://localhost:{StudioPort}" + : throw new InvalidOperationException("Stack not configured"); + + /// + /// Gets the PostgreSQL connection string for external tools. + /// + public string GetPostgresConnectionString() => + Database != null + ? $"Host=localhost;Port={Database.Resource.ExternalPort};Database=postgres;Username=postgres;Password={Database.Resource.Password}" + : throw new InvalidOperationException("Database not configured"); + + /// + /// Gets or sets the external Studio port. + /// + internal int StudioPort { get; set; } = 54323; +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs new file mode 100644 index 000000000..a0fb4e4a0 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs @@ -0,0 +1,37 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +/// +/// Represents a Supabase Storage API container resource. +/// +public sealed class SupabaseStorageResource : ContainerResource +{ + /// + /// Creates a new instance of the SupabaseStorageResource. + /// + /// The name of the storage container. + public SupabaseStorageResource(string name) : base(name) + { + } + + /// + /// Gets or sets the maximum file size limit in bytes. + /// + public long FileSizeLimit { get; internal set; } = 52428800; // 50MB + + /// + /// Gets or sets the storage backend type. + /// + public string Backend { get; internal set; } = "file"; + + /// + /// Gets or sets whether image transformation is enabled. + /// + public bool EnableImageTransformation { get; internal set; } = true; + + /// + /// Gets or sets the reference to the parent stack. + /// + internal SupabaseStackResource? Stack { get; set; } +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs new file mode 100644 index 000000000..f67fc3ae0 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs @@ -0,0 +1,126 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Builders; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Sync; + +/// +/// Provides extension methods for project synchronization. +/// +public static class ProjectSyncExtensions +{ + /// + /// Enables synchronization from an online Supabase project. + /// + /// The resource builder. + /// The Supabase project reference ID. + /// The service role key for API access. + /// What to synchronize (default: All). + /// Database password for full pg_dump sync. Required for AllSchema options. + /// Supabase Management API token for Edge Functions sync. + public static IResourceBuilder WithProjectSync( + this IResourceBuilder builder, + string? projectRef, + string? serviceKey, + SyncOptions options = SyncOptions.All, + string? dbPassword = null, + string? managementApiToken = null) + { + // Validate parameters + if (string.IsNullOrWhiteSpace(projectRef)) + { + Console.WriteLine("[Supabase Sync] SKIPPED: projectRef is empty or null."); + return builder; + } + + if (string.IsNullOrWhiteSpace(serviceKey)) + { + Console.WriteLine("[Supabase Sync] SKIPPED: serviceKey is empty or null."); + return builder; + } + + // Check if key is in JWT format (not CLI format) + if (!serviceKey.StartsWith("eyJ")) + { + Console.WriteLine("[Supabase Sync] ERROR: serviceKey has wrong format!"); + Console.WriteLine(" Expected: JWT format (starts with 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...')"); + Console.WriteLine(" Found: '" + serviceKey.Substring(0, Math.Min(20, serviceKey.Length)) + "...'"); + Console.WriteLine(" Note: Use the 'service_role' key from Dashboard → Project Settings → API"); + Console.WriteLine(" NOT the 'sb_secret_...' key - that's only for the Supabase CLI!"); + return builder; + } + + // Warn if schema options require dbPassword but none provided + bool needsDbPassword = options.HasFlag(SyncOptions.Policies) || + options.HasFlag(SyncOptions.Functions) || + options.HasFlag(SyncOptions.Triggers) || + options.HasFlag(SyncOptions.Types) || + options.HasFlag(SyncOptions.Views) || + options.HasFlag(SyncOptions.Indexes); + + if (needsDbPassword && string.IsNullOrWhiteSpace(dbPassword)) + { + Console.WriteLine("[Supabase Sync] WARNING: For complete schema sync (Policies, Functions, Triggers, Views) the DB password is required!"); + Console.WriteLine(" Note: Dashboard → Project Settings → Database → Database password"); + Console.WriteLine(" The sync options Policies/Functions/Triggers/Types/Views/Indexes will be skipped."); + } + + // Warn if Edge Functions sync requires management API token + if (options.HasFlag(SyncOptions.EdgeFunctions) && string.IsNullOrWhiteSpace(managementApiToken)) + { + Console.WriteLine("[Supabase Sync] WARNING: For Edge Functions sync a Management API token is required!"); + Console.WriteLine(" Note: Dashboard → Account → Access Tokens → Generate new token"); + Console.WriteLine(" The sync option EdgeFunctions will be skipped."); + } + + // Store configuration + builder.Resource.SyncFromProjectRef = projectRef; + builder.Resource.SyncServiceKey = serviceKey; + builder.Resource.SyncSchema = options.HasFlag(SyncOptions.Schema); + builder.Resource.SyncData = options.HasFlag(SyncOptions.Data); + + // Perform sync now - the init directory should exist from AddSupabase + if (string.IsNullOrEmpty(builder.Resource.InitSqlPath)) + { + Console.WriteLine("[Supabase Sync] ERROR: InitSqlPath not set. Was AddSupabase() called?"); + return builder; + } + + // Determine storage path for file downloads + var infraDir = Path.GetDirectoryName(builder.Resource.InitSqlPath)!; + var storagePath = Path.Combine(infraDir, "storage"); + + // Determine edge functions path for Edge Functions sync + var edgeFunctionsPath = Path.Combine(infraDir, "edge-functions"); + + try + { + SyncService.SyncFromOnlineProject( + builder.Resource.InitSqlPath!, + projectRef, + serviceKey, + options, + dbPassword, + storagePath, + managementApiToken, + edgeFunctionsPath).GetAwaiter().GetResult(); + + // If Edge Functions were synced, enable them + if (options.HasFlag(SyncOptions.EdgeFunctions) && + !string.IsNullOrWhiteSpace(managementApiToken) && + Directory.Exists(edgeFunctionsPath) && + Directory.GetDirectories(edgeFunctionsPath).Length > 0) + { + Console.WriteLine($"[Supabase Sync] Enabling synchronized Edge Functions from: {edgeFunctionsPath}"); + builder.WithEdgeFunctions(edgeFunctionsPath); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] ERROR: {ex.Message}"); + } + + return builder; + } + +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs new file mode 100644 index 000000000..9242f08c3 --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs @@ -0,0 +1,86 @@ +namespace CommunityToolkit.Aspire.Hosting.Supabase.Sync; + +/// +/// Specifies what to synchronize from an online Supabase project. +/// +[Flags] +public enum SyncOptions +{ + /// + /// No synchronization. + /// + None = 0, + + /// + /// Sync table structures (columns, types, constraints). + /// + Schema = 1 << 0, + + /// + /// Sync table data. + /// + Data = 1 << 1, + + /// + /// Sync Row Level Security policies. + /// + Policies = 1 << 2, + + /// + /// Sync stored procedures and functions. + /// + Functions = 1 << 3, + + /// + /// Sync database triggers. + /// + Triggers = 1 << 4, + + /// + /// Sync storage buckets. + /// + StorageBuckets = 1 << 5, + + /// + /// Sync storage files (downloads files from remote storage). + /// + StorageFiles = 1 << 6, + + /// + /// Sync custom types and enums. + /// + Types = 1 << 7, + + /// + /// Sync views. + /// + Views = 1 << 8, + + /// + /// Sync indexes. + /// + Indexes = 1 << 9, + + /// + /// Sync Edge Functions from the remote project. + /// Requires Supabase Management API token (personal access token from Dashboard → Account → Access Tokens). + /// + EdgeFunctions = 1 << 10, + + /// + /// All schema-related options (Schema, Policies, Functions, Triggers, Types, Views, Indexes). + /// Requires database password. + /// + AllSchema = Schema | Policies | Functions | Triggers | Types | Views | Indexes, + + /// + /// All storage-related options (StorageBuckets, StorageFiles). + /// + AllStorage = StorageBuckets | StorageFiles, + + /// + /// Everything - complete sync of all database objects, data, storage, and Edge Functions. + /// Requires database password and Management API token for Edge Functions. + /// + All = AllSchema | Data | AllStorage | EdgeFunctions +} diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs b/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs new file mode 100644 index 000000000..a07119f6d --- /dev/null +++ b/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs @@ -0,0 +1,787 @@ +using CommunityToolkit.Aspire.Hosting.Supabase.Helpers; +using System.Text; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Sync; + +/// +/// Service for synchronizing data from an online Supabase project to the local development environment. +/// +internal static class SyncService +{ + /// + /// Synchronizes schema, data, storage, and edge functions from an online Supabase project. + /// + public static async Task SyncFromOnlineProject( + string initPath, + string projectRef, + string serviceKey, + SyncOptions options, + string? dbPassword, + string? storagePath, + string? managementApiToken = null, + string? edgeFunctionsPath = null) + { + Console.WriteLine($"[Supabase Sync] Synchronizing from project: {projectRef}"); + Console.WriteLine($"[Supabase Sync] Options: {options}"); + + var baseUrl = $"https://{projectRef}.supabase.co"; + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("apikey", serviceKey); + + var sqlBuilder = new StringBuilder(); + sqlBuilder.AppendLine("-- ============================================"); + sqlBuilder.AppendLine($"-- SYNCED FROM ONLINE PROJECT: {projectRef}"); + sqlBuilder.AppendLine($"-- Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + sqlBuilder.AppendLine("-- ============================================"); + sqlBuilder.AppendLine(); + + var schemaCreated = false; + if (options.HasFlag(SyncOptions.Schema)) + { + schemaCreated = await SyncSchema(httpClient, baseUrl, sqlBuilder); + } + + bool hasPgDumpOptions = !string.IsNullOrWhiteSpace(dbPassword) && + (options.HasFlag(SyncOptions.Policies) || + options.HasFlag(SyncOptions.Functions) || + options.HasFlag(SyncOptions.Triggers) || + options.HasFlag(SyncOptions.Types) || + options.HasFlag(SyncOptions.Views) || + options.HasFlag(SyncOptions.Indexes)); + + if (hasPgDumpOptions) + { + await SyncWithPgDump(initPath, projectRef, dbPassword!, options, sqlBuilder); + } + + if (options.HasFlag(SyncOptions.Data)) + { + if (!schemaCreated) + { + Console.WriteLine("[Supabase Sync] WARNING: Data sync skipped - schema was not created!"); + } + else + { + await SyncData(httpClient, baseUrl, sqlBuilder); + } + } + + if (options.HasFlag(SyncOptions.StorageBuckets) || options.HasFlag(SyncOptions.StorageFiles)) + { + var (bucketsSql, objectsSql) = await SyncStorageComplete( + baseUrl, + serviceKey, + storagePath, + options.HasFlag(SyncOptions.StorageBuckets), + options.HasFlag(SyncOptions.StorageFiles)); + + if (!string.IsNullOrEmpty(bucketsSql)) + { + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine("-- === STORAGE BUCKETS ==="); + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine(bucketsSql); + } + + if (!string.IsNullOrEmpty(objectsSql)) + { + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine("-- === STORAGE OBJECTS ==="); + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine(objectsSql); + } + } + + if (options.HasFlag(SyncOptions.EdgeFunctions) && + !string.IsNullOrWhiteSpace(managementApiToken) && + !string.IsNullOrWhiteSpace(edgeFunctionsPath)) + { + await SyncEdgeFunctions(projectRef, managementApiToken, edgeFunctionsPath); + } + + var syncSqlPath = Path.Combine(initPath, "01_sync_schema.sql"); + await File.WriteAllTextAsync(syncSqlPath, sqlBuilder.ToString()); + Console.WriteLine($"[Supabase Sync] Sync saved to: {syncSqlPath}"); + } + + private static async Task SyncSchema(HttpClient httpClient, string baseUrl, StringBuilder sqlBuilder) + { + try + { + Console.WriteLine("[Supabase Sync] Loading OpenAPI specification for schema..."); + var openApiResponse = await httpClient.GetStringAsync($"{baseUrl}/rest/v1/"); + var openApi = JsonSerializer.Deserialize(openApiResponse); + + if (!openApi.TryGetProperty("definitions", out var definitions)) + { + Console.WriteLine("[Supabase Sync] No table definitions found."); + return false; + } + + var customTypes = new HashSet(); + sqlBuilder.AppendLine("-- === TABLES (from OpenAPI spec) ==="); + sqlBuilder.AppendLine(); + + foreach (var tableDef in definitions.EnumerateObject()) + { + var tableName = tableDef.Name; + var tableSchema = tableDef.Value; + + if (tableName.StartsWith("_") || tableName == "rpc") continue; + + Console.WriteLine($"[Supabase Sync] Synchronizing table: public.{tableName}"); + + if (!tableSchema.TryGetProperty("properties", out var properties)) + continue; + + sqlBuilder.AppendLine($"-- Table: {tableName}"); + sqlBuilder.AppendLine($"CREATE TABLE IF NOT EXISTS public.{tableName} ("); + + var columnDefs = new List(); + var primaryKeys = new List(); + + var requiredFields = new HashSet(); + if (tableSchema.TryGetProperty("required", out var required)) + { + foreach (var req in required.EnumerateArray()) + requiredFields.Add(req.GetString() ?? ""); + } + + foreach (var prop in properties.EnumerateObject()) + { + var colName = prop.Name; + var colDef = prop.Value; + var pgType = MapOpenApiToPostgres(colDef, customTypes); + var isNullable = !requiredFields.Contains(colName); + var isPrimaryKey = colName == "id" || + (colDef.TryGetProperty("description", out var desc) && + desc.GetString()?.Contains("Primary") == true); + + var colLine = $" \"{colName}\" {pgType}"; + if (!isNullable) colLine += " NOT NULL"; + if (pgType == "uuid" && isPrimaryKey) + colLine += " DEFAULT extensions.uuid_generate_v4()"; + + columnDefs.Add(colLine); + if (isPrimaryKey) primaryKeys.Add($"\"{colName}\""); + } + + if (primaryKeys.Count > 0) + columnDefs.Add($" PRIMARY KEY ({string.Join(", ", primaryKeys)})"); + + sqlBuilder.AppendLine(string.Join(",\n", columnDefs)); + sqlBuilder.AppendLine(");"); + sqlBuilder.AppendLine($"ALTER TABLE public.{tableName} ENABLE ROW LEVEL SECURITY;"); + sqlBuilder.AppendLine($"DROP POLICY IF EXISTS \"Allow all for development\" ON public.{tableName};"); + sqlBuilder.AppendLine($"CREATE POLICY \"Allow all for development\" ON public.{tableName} FOR ALL USING (true) WITH CHECK (true);"); + sqlBuilder.AppendLine(); + } + + if (customTypes.Count > 0) + { + Console.WriteLine($"[Supabase Sync] WARNING: {customTypes.Count} custom types were replaced with TEXT:"); + foreach (var ct in customTypes) + Console.WriteLine($" - {ct}"); + } + + Console.WriteLine("[Supabase Sync] Schema sync completed."); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error during schema sync: {ex.Message}"); + sqlBuilder.AppendLine($"-- Error during schema sync: {ex.Message}"); + return false; + } + } + + private static async Task SyncData(HttpClient httpClient, string baseUrl, StringBuilder sqlBuilder) + { + try + { + Console.WriteLine("[Supabase Sync] Loading data..."); + + var openApiResponse = await httpClient.GetStringAsync($"{baseUrl}/rest/v1/"); + var openApi = JsonSerializer.Deserialize(openApiResponse); + + if (openApi.TryGetProperty("definitions", out var definitions)) + { + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine("-- === DATA ==="); + sqlBuilder.AppendLine(); + + foreach (var tableDef in definitions.EnumerateObject()) + { + var tableName = tableDef.Name; + if (tableName.StartsWith("_") || tableName == "rpc") continue; + + try + { + httpClient.DefaultRequestHeaders.Remove("Prefer"); + httpClient.DefaultRequestHeaders.Add("Prefer", "return=representation"); + + var dataResponse = await httpClient.GetStringAsync($"{baseUrl}/rest/v1/{tableName}?limit=1000"); + var rows = JsonSerializer.Deserialize(dataResponse); + + if (rows.ValueKind == JsonValueKind.Array && rows.GetArrayLength() > 0) + { + Console.WriteLine($"[Supabase Sync] Synchronizing {rows.GetArrayLength()} rows from: {tableName}"); + sqlBuilder.AppendLine($"-- Data for: {tableName}"); + + foreach (var row in rows.EnumerateArray()) + { + var columns = new List(); + var values = new List(); + + foreach (var prop in row.EnumerateObject()) + { + columns.Add($"\"{prop.Name}\""); + values.Add(JsonValueToSql(prop.Value)); + } + + sqlBuilder.AppendLine($"INSERT INTO public.{tableName} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", values)}) ON CONFLICT DO NOTHING;"); + } + sqlBuilder.AppendLine(); + } + } + catch (HttpRequestException) + { + // Table might not be accessible, skip it + } + } + } + + Console.WriteLine("[Supabase Sync] Data sync completed."); + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error during data sync: {ex.Message}"); + sqlBuilder.AppendLine($"-- Error during data sync: {ex.Message}"); + } + } + + private static async Task<(string bucketsSql, string objectsSql)> SyncStorageComplete( + string baseUrl, + string serviceKey, + string? storagePath, + bool syncBuckets, + bool syncFiles) + { + var bucketsSqlBuilder = new StringBuilder(); + var objectsSqlBuilder = new StringBuilder(); + + try + { + Console.WriteLine("[Supabase Sync] Loading storage data..."); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("apikey", serviceKey); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {serviceKey}"); + + var bucketsResponse = await client.GetStringAsync($"{baseUrl}/storage/v1/bucket"); + var buckets = JsonSerializer.Deserialize(bucketsResponse); + + if (buckets.ValueKind != JsonValueKind.Array) + { + Console.WriteLine("[Supabase Sync] No buckets found."); + return ("", ""); + } + + if (syncBuckets) + { + foreach (var bucket in buckets.EnumerateArray()) + { + var id = bucket.GetProperty("id").GetString(); + var name = bucket.GetProperty("name").GetString(); + var isPublic = bucket.TryGetProperty("public", out var pub) && pub.GetBoolean(); + + Console.WriteLine($"[Supabase Sync] Storage bucket: {name} (public: {isPublic})"); + bucketsSqlBuilder.AppendLine($"INSERT INTO storage.buckets (id, name, public, created_at, updated_at) VALUES ('{SupabaseSqlGenerator.EscapeSqlString(id!)}', '{SupabaseSqlGenerator.EscapeSqlString(name!)}', {isPublic.ToString().ToLower()}, NOW(), NOW()) ON CONFLICT (id) DO NOTHING;"); + } + Console.WriteLine("[Supabase Sync] Storage bucket sync completed."); + } + + if (syncFiles && !string.IsNullOrEmpty(storagePath)) + { + var totalFiles = 0; + foreach (var bucket in buckets.EnumerateArray()) + { + var bucketId = bucket.GetProperty("id").GetString(); + if (string.IsNullOrEmpty(bucketId)) continue; + + var bucketPath = Path.Combine(storagePath, bucketId); + Directory.CreateDirectory(bucketPath); + + try + { + var (filesDownloaded, objectsSql) = await SyncBucketFiles(baseUrl, serviceKey, bucketId, "", bucketPath); + totalFiles += filesDownloaded; + objectsSqlBuilder.Append(objectsSql); + + if (filesDownloaded > 0) + Console.WriteLine($"[Supabase Sync] {filesDownloaded} files downloaded from bucket '{bucketId}'."); + else + Console.WriteLine($"[Supabase Sync] Bucket '{bucketId}' is empty or not accessible."); + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error with bucket '{bucketId}': {ex.Message}"); + } + } + + Console.WriteLine($"[Supabase Sync] Storage files sync completed. {totalFiles} files total."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error during storage sync: {ex.Message}"); + } + + return (bucketsSqlBuilder.ToString(), objectsSqlBuilder.ToString()); + } + + private static async Task<(int fileCount, string sql)> SyncBucketFiles(string baseUrl, string serviceKey, string bucketId, string prefix, string localPath) + { + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("apikey", serviceKey); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {serviceKey}"); + + var fileCount = 0; + var sqlBuilder = new StringBuilder(); + + try + { + var listUrl = $"{baseUrl}/storage/v1/object/list/{bucketId}"; + var listRequest = new HttpRequestMessage(HttpMethod.Post, listUrl); + listRequest.Headers.Add("apikey", serviceKey); + listRequest.Headers.Add("Authorization", $"Bearer {serviceKey}"); + listRequest.Content = new StringContent( + JsonSerializer.Serialize(new { prefix, limit = 1000 }), + Encoding.UTF8, + "application/json"); + + var listResponse = await client.SendAsync(listRequest); + + if (!listResponse.IsSuccessStatusCode) + { + var errorContent = await listResponse.Content.ReadAsStringAsync(); + Console.WriteLine($"[Supabase Sync] Error listing {bucketId}/{prefix}: {listResponse.StatusCode} - {errorContent}"); + return (0, ""); + } + + var filesJson = await listResponse.Content.ReadAsStringAsync(); + var files = JsonSerializer.Deserialize(filesJson); + + if (files.ValueKind != JsonValueKind.Array) return (0, ""); + + foreach (var file in files.EnumerateArray()) + { + var fileName = file.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + if (string.IsNullOrEmpty(fileName)) continue; + + var isFolder = file.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.Null; + + if (isFolder) + { + var subPrefix = string.IsNullOrEmpty(prefix) ? fileName : $"{prefix}/{fileName}"; + var subPath = Path.Combine(localPath, fileName); + Directory.CreateDirectory(subPath); + var (subCount, subSql) = await SyncBucketFiles(baseUrl, serviceKey, bucketId, subPrefix, subPath); + fileCount += subCount; + sqlBuilder.Append(subSql); + } + else + { + try + { + var fullPath = string.IsNullOrEmpty(prefix) ? fileName : $"{prefix}/{fileName}"; + var fileUrl = $"{baseUrl}/storage/v1/object/{bucketId}/{Uri.EscapeDataString(fullPath)}"; + + var downloadRequest = new HttpRequestMessage(HttpMethod.Get, fileUrl); + downloadRequest.Headers.Add("apikey", serviceKey); + downloadRequest.Headers.Add("Authorization", $"Bearer {serviceKey}"); + + var downloadResponse = await client.SendAsync(downloadRequest); + if (downloadResponse.IsSuccessStatusCode) + { + var fileBytes = await downloadResponse.Content.ReadAsByteArrayAsync(); + var localFilePath = Path.Combine(localPath, fileName); + + var fileDir = Path.GetDirectoryName(localFilePath); + if (!string.IsNullOrEmpty(fileDir)) + Directory.CreateDirectory(fileDir); + + await File.WriteAllBytesAsync(localFilePath, fileBytes); + fileCount++; + + var fileId = file.TryGetProperty("id", out var fid) && fid.ValueKind == JsonValueKind.String + ? fid.GetString() + : Guid.NewGuid().ToString(); + var mimeType = file.TryGetProperty("metadata", out var meta) && + meta.TryGetProperty("mimetype", out var mime) + ? mime.GetString() ?? "application/octet-stream" + : "application/octet-stream"; + + sqlBuilder.AppendLine($"INSERT INTO storage.objects (id, bucket_id, name, metadata, created_at, updated_at) " + + $"VALUES ('{SupabaseSqlGenerator.EscapeSqlString(fileId!)}', '{SupabaseSqlGenerator.EscapeSqlString(bucketId)}', '{SupabaseSqlGenerator.EscapeSqlString(fullPath)}', " + + $"'{{\"mimetype\": \"{SupabaseSqlGenerator.EscapeSqlString(mimeType)}\", \"size\": {fileBytes.Length}}}'::jsonb, NOW(), NOW()) " + + $"ON CONFLICT (id) DO NOTHING;"); + } + else + { + Console.WriteLine($"[Supabase Sync] Error downloading {bucketId}/{fullPath}: {downloadResponse.StatusCode}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error downloading {bucketId}/{fileName}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error listing {bucketId}/{prefix}: {ex.Message}"); + } + + return (fileCount, sqlBuilder.ToString()); + } + + private static async Task SyncWithPgDump(string initPath, string projectRef, string dbPassword, SyncOptions options, StringBuilder sqlBuilder) + { + Console.WriteLine("[Supabase Sync] Starting pg_dump for complete schema sync..."); + + var connectionString = $"postgresql://postgres.{projectRef}:{Uri.EscapeDataString(dbPassword)}@aws-0-eu-central-1.pooler.supabase.com:6543/postgres"; + + var pgDumpPath = FindPgDump(); + if (string.IsNullOrEmpty(pgDumpPath)) + { + Console.WriteLine("[Supabase Sync] WARNING: pg_dump not found. Skipping complete schema sync."); + Console.WriteLine(" Install PostgreSQL client tools for complete sync."); + return; + } + + var args = new List + { + $"\"{connectionString}\"", + "--schema-only", + "--no-owner", + "--no-acl", + "--no-comments" + }; + + args.Add("--schema=public"); + + if (!options.HasFlag(SyncOptions.Schema)) + args.Add("--exclude-table=*"); + + try + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = pgDumpPath, + Arguments = string.Join(" ", args), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Environment = { ["PGPASSWORD"] = dbPassword } + }; + + Console.WriteLine($"[Supabase Sync] Executing: {pgDumpPath}"); + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process == null) + { + Console.WriteLine("[Supabase Sync] ERROR: pg_dump could not be started."); + return; + } + + var output = await process.StandardOutput.ReadToEndAsync(); + var errors = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + Console.WriteLine($"[Supabase Sync] pg_dump error (Exit {process.ExitCode}): {errors}"); + return; + } + + if (!string.IsNullOrWhiteSpace(output)) + { + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine("-- === SCHEMA FROM PG_DUMP ==="); + sqlBuilder.AppendLine(); + + var lines = output.Split('\n'); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + if (line.StartsWith("--") && !line.Contains("Table:") && !line.Contains("Function:") && !line.Contains("Policy:")) + continue; + + if (line.Contains("supabase_functions") || line.Contains("supabase_migrations")) + continue; + + sqlBuilder.AppendLine(line); + } + + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine("-- === DEV-MODE RLS POLICIES ==="); + sqlBuilder.AppendLine(); + sqlBuilder.AppendLine(@" +DO $$ +DECLARE + t record; +BEGIN + FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' + LOOP + EXECUTE format('DROP POLICY IF EXISTS ""Allow all for development"" ON public.%I', t.tablename); + EXECUTE format('CREATE POLICY ""Allow all for development"" ON public.%I FOR ALL USING (true) WITH CHECK (true)', t.tablename); + END LOOP; +END; +$$; +"); + + Console.WriteLine("[Supabase Sync] pg_dump schema sync completed."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] pg_dump error: {ex.Message}"); + } + } + + private static string? FindPgDump() + { + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + var paths = pathEnv.Split(Path.PathSeparator); + + var pgDumpNames = OperatingSystem.IsWindows() + ? new[] { "pg_dump.exe" } + : new[] { "pg_dump" }; + + foreach (var path in paths) + { + foreach (var name in pgDumpNames) + { + var fullPath = Path.Combine(path, name); + if (File.Exists(fullPath)) + return fullPath; + } + } + + var standardPaths = new List(); + + if (OperatingSystem.IsWindows()) + { + for (var ver = 17; ver >= 12; ver--) + { + standardPaths.Add($@"C:\Program Files\PostgreSQL\{ver}\bin\pg_dump.exe"); + standardPaths.Add($@"C:\Program Files (x86)\PostgreSQL\{ver}\bin\pg_dump.exe"); + } + } + else + { + standardPaths.Add("/usr/bin/pg_dump"); + standardPaths.Add("/usr/local/bin/pg_dump"); + standardPaths.Add("/opt/homebrew/bin/pg_dump"); + } + + foreach (var path in standardPaths) + { + if (File.Exists(path)) + return path; + } + + return null; + } + + private static async Task SyncEdgeFunctions(string projectRef, string managementApiToken, string edgeFunctionsPath) + { + Console.WriteLine("[Supabase Sync] Starting Edge Functions sync..."); + + try + { + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {managementApiToken}"); + + var functionsUrl = $"https://api.supabase.com/v1/projects/{projectRef}/functions"; + var response = await client.GetAsync(functionsUrl); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"[Supabase Sync] Error fetching Edge Functions: {response.StatusCode}"); + Console.WriteLine($" {errorContent}"); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + Console.WriteLine(" Note: The Management API token is invalid or expired."); + Console.WriteLine(" Create a new token at: Dashboard → Account → Access Tokens"); + } + + return; + } + + var functionsJson = await response.Content.ReadAsStringAsync(); + var functions = JsonSerializer.Deserialize(functionsJson); + + if (functions.ValueKind != JsonValueKind.Array || functions.GetArrayLength() == 0) + { + Console.WriteLine("[Supabase Sync] No Edge Functions found in project."); + return; + } + + Directory.CreateDirectory(edgeFunctionsPath); + + var syncedCount = 0; + foreach (var func in functions.EnumerateArray()) + { + var slug = func.TryGetProperty("slug", out var slugProp) ? slugProp.GetString() : null; + var name = func.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : slug; + var status = func.TryGetProperty("status", out var statusProp) ? statusProp.GetString() : "unknown"; + + if (string.IsNullOrEmpty(slug)) + continue; + + Console.WriteLine($"[Supabase Sync] Edge Function: {name} ({slug}) - Status: {status}"); + + var functionDir = Path.Combine(edgeFunctionsPath, slug); + Directory.CreateDirectory(functionDir); + + var functionDetailUrl = $"https://api.supabase.com/v1/projects/{projectRef}/functions/{slug}/body"; + var bodyResponse = await client.GetAsync(functionDetailUrl); + + string functionCode = ""; + var useSourceCode = false; + + if (bodyResponse.IsSuccessStatusCode) + { + var bodyBytes = await bodyResponse.Content.ReadAsByteArrayAsync(); + var bodyText = Encoding.UTF8.GetString(bodyBytes); + + if (bodyBytes.Length > 5 && + bodyBytes[0] == 'E' && bodyBytes[1] == 'S' && bodyBytes[2] == 'Z' && bodyBytes[3] == 'I' && bodyBytes[4] == 'P') + { + Console.WriteLine($"[Supabase Sync] → ESZIP bundle detected (compiled, not usable as source)"); + } + else if (bodyText.Contains("import") || bodyText.Contains("export") || bodyText.Contains("Deno.serve") || bodyText.Contains("serve(")) + { + functionCode = bodyText; + useSourceCode = true; + Console.WriteLine($"[Supabase Sync] → Source code downloaded"); + } + } + + if (!useSourceCode) + { + functionCode = "// Edge Function: " + name + "\n" + + "// Slug: " + slug + "\n" + + "// Status: " + status + "\n" + + "//\n" + + "// ⚠️ IMPORTANT: The Supabase API only returns compiled ESZIP bundles,\n" + + "// not the original source code. You must copy the code manually!\n" + + "//\n" + + "// Option 1 - Copy from Dashboard:\n" + + "// https://supabase.com/dashboard/project/" + projectRef + "/functions/" + slug + "\n" + + "//\n" + + "// Option 2 - Download with Supabase CLI:\n" + + "// supabase login\n" + + "// supabase functions download " + slug + " --project-ref " + projectRef + "\n" + + "//\n" + + "// Replace this placeholder with the actual code!\n\n" + + "import { serve } from \"https://deno.land/std@0.177.0/http/server.ts\";\n\n" + + "serve(async (req) => {\n" + + " return new Response(\n" + + " JSON.stringify({ error: \"Function not synced - see comments in source\" }),\n" + + " { status: 501, headers: { \"Content-Type\": \"application/json\" } }\n" + + " );\n" + + "});\n"; + Console.WriteLine($"[Supabase Sync] → Placeholder created (copy source code manually!)"); + } + + var indexPath = Path.Combine(functionDir, "index.ts"); + await File.WriteAllTextAsync(indexPath, functionCode); + + syncedCount++; + } + + if (syncedCount > 0) + { + Console.WriteLine($"[Supabase Sync] {syncedCount} Edge Function(s) synchronized to: {edgeFunctionsPath}"); + Console.WriteLine("[Supabase Sync] NOTE: Check the synchronized functions for completeness."); + Console.WriteLine(" If the source code is missing, copy it manually from the dashboard."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Supabase Sync] Error during Edge Functions sync: {ex.Message}"); + } + } + + private static string JsonValueToSql(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.Null => "NULL", + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.String => $"'{SupabaseSqlGenerator.EscapeSqlString(value.GetString() ?? "")}'", + JsonValueKind.Array => $"'{SupabaseSqlGenerator.EscapeSqlString(value.GetRawText())}'::jsonb", + JsonValueKind.Object => $"'{SupabaseSqlGenerator.EscapeSqlString(value.GetRawText())}'::jsonb", + _ => "NULL" + }; + } + + private static string MapOpenApiToPostgres(JsonElement colDef, HashSet customTypes) + { + var type = colDef.TryGetProperty("type", out var t) ? t.GetString() : "string"; + var format = colDef.TryGetProperty("format", out var f) ? f.GetString() : null; + + if (!string.IsNullOrEmpty(format)) + { + if (format.Contains(".") && !format.StartsWith("timestamp") && !format.StartsWith("time ")) + { + customTypes.Add(format); + return "text"; + } + + return format switch + { + "uuid" => "uuid", + "timestamp with time zone" => "timestamptz", + "timestamp without time zone" => "timestamp", + "date" => "date", + "time with time zone" => "timetz", + "time without time zone" => "time", + "bigint" => "bigint", + "integer" => "integer", + "smallint" => "smallint", + "numeric" => "numeric", + "real" => "real", + "double precision" => "double precision", + "boolean" => "boolean", + "jsonb" => "jsonb", + "json" => "json", + "bytea" => "bytea", + "text[]" => "text[]", + "uuid[]" => "uuid[]", + _ => format.Contains(".") ? "text" : format + }; + } + + return type switch + { + "integer" => "integer", + "number" => "numeric", + "boolean" => "boolean", + "array" => "jsonb", + "object" => "jsonb", + _ => "text" + }; + } +} diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 9d30dd237..1c4253ba0 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -183,9 +183,10 @@ - + + @@ -238,7 +239,7 @@ - + @@ -298,7 +299,7 @@ - + From 76672dc706489f7d05bd582bc8ef97b0474b957c Mon Sep 17 00:00:00 2001 From: Florian Gilde Date: Tue, 20 Jan 2026 16:17:24 +0100 Subject: [PATCH 2/4] move project in correct supfolder --- CommunityToolkit.Aspire.slnx | 2 +- .../Builders/AuthBuilderExtensions.cs | 0 .../Builders/DatabaseBuilderExtensions.cs | 0 .../Builders/EdgeRuntimeBuilderExtensions.cs | 0 .../Builders/KongBuilderExtensions.cs | 0 .../Builders/MetaBuilderExtensions.cs | 0 .../Builders/RestBuilderExtensions.cs | 0 .../Builders/StorageBuilderExtensions.cs | 0 .../Builders/StudioBuilderExtensions.cs | 0 .../Builders/SupabaseBuilderExtensions.cs | 0 .../Builders/SupabaseStackExtensions.cs | 0 .../CommunityToolkit.Aspire.Hosting.Supabase.csproj | 0 .../Helpers/EdgeFunctionRouter.cs | 0 .../Helpers/SupabaseSqlGenerator.cs | 0 .../CommunityToolkit.Aspire.Hosting.Supabase}/README.md | 0 .../Resources/SupabaseAuthResource.cs | 0 .../Resources/SupabaseDatabaseResource.cs | 0 .../Resources/SupabaseEdgeRuntimeResource.cs | 0 .../Resources/SupabaseKongResource.cs | 0 .../Resources/SupabaseMetaResource.cs | 0 .../Resources/SupabaseRestResource.cs | 0 .../Resources/SupabaseStackResource.cs | 0 .../Resources/SupabaseStorageResource.cs | 0 .../Sync/ProjectSyncExtensions.cs | 0 .../Sync/SyncOptions.cs | 0 .../Sync/SyncService.cs | 0 26 files changed, 1 insertion(+), 1 deletion(-) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/AuthBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/DatabaseBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/EdgeRuntimeBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/KongBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/MetaBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/RestBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/StorageBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/StudioBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/SupabaseBuilderExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Builders/SupabaseStackExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/CommunityToolkit.Aspire.Hosting.Supabase.csproj (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Helpers/EdgeFunctionRouter.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Helpers/SupabaseSqlGenerator.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/README.md (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseAuthResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseDatabaseResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseEdgeRuntimeResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseKongResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseMetaResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseRestResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseStackResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Resources/SupabaseStorageResource.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Sync/ProjectSyncExtensions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Sync/SyncOptions.cs (100%) rename {CommunityToolkit.Aspire.Hosting.Supabase => src/CommunityToolkit.Aspire.Hosting.Supabase}/Sync/SyncService.cs (100%) diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 1c4253ba0..afef4a1a4 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -186,7 +186,7 @@ - + diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj b/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj rename to src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/README.md b/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/README.md rename to src/CommunityToolkit.Aspire.Hosting.Supabase/README.md diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs diff --git a/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs similarity index 100% rename from CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs rename to src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs From 7996e4ff169a99d7dfe23ac649b4877272588041 Mon Sep 17 00:00:00 2001 From: Florian Gilde Date: Tue, 20 Jan 2026 16:37:00 +0100 Subject: [PATCH 3/4] add tests and example project --- .claude/settings.local.json | 8 + CommunityToolkit.Aspire.slnx | 5 + ...kit.Aspire.Hosting.Supabase.AppHost.csproj | 12 + .../Program.cs | 25 ++ .../Properties/launchSettings.json | 29 ++ .../appsettings.json | 9 + ...re.Hosting.Supabase.ServiceDefaults.csproj | 21 + .../Extensions.cs | 111 +++++ ...nityToolkit.Aspire.Hosting.Supabase.csproj | 4 + .../AddSupabaseTests.cs | 343 ++++++++++++++++ ...olkit.Aspire.Hosting.Supabase.Tests.csproj | 8 + .../ResourceConfigurationTests.cs | 381 ++++++++++++++++++ .../SupabaseTestCollection.cs | 6 + 13 files changed, 962 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/CommunityToolkit.Aspire.Hosting.Supabase.AppHost.csproj create mode 100644 examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Program.cs create mode 100644 examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Properties/launchSettings.json create mode 100644 examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/appsettings.json create mode 100644 examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults.csproj create mode 100644 examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/Extensions.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/AddSupabaseTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/ResourceConfigurationTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/SupabaseTestCollection.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..934d26784 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet restore:*)", + "Bash(dotnet test:*)" + ] + } +} diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index afef4a1a4..ad82f1837 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -177,6 +177,10 @@ + + + + @@ -287,6 +291,7 @@ + diff --git a/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/CommunityToolkit.Aspire.Hosting.Supabase.AppHost.csproj b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/CommunityToolkit.Aspire.Hosting.Supabase.AppHost.csproj new file mode 100644 index 000000000..1477c60f0 --- /dev/null +++ b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/CommunityToolkit.Aspire.Hosting.Supabase.AppHost.csproj @@ -0,0 +1,12 @@ + + + + Exe + b7c5f1a9-8d2e-4f3a-9b1c-6e8d7a2f4c5b + + + + + + + diff --git a/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Program.cs b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Program.cs new file mode 100644 index 000000000..baac5fd65 --- /dev/null +++ b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Program.cs @@ -0,0 +1,25 @@ +using CommunityToolkit.Aspire.Hosting.Supabase.Builders; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add a complete Supabase stack +// This includes: PostgreSQL, Auth (GoTrue), REST API (PostgREST), +// Storage, Kong API Gateway, Postgres-Meta, and Studio Dashboard +var supabase = builder.AddSupabase("supabase"); + +// Optional: Configure individual components +supabase + .ConfigureAuth(auth => auth + .WithSiteUrl("http://localhost:3000") + .WithAutoConfirm(true) + .WithAnonymousUsers(true)) + .ConfigureStorage(storage => storage + .WithFileSizeLimit(100_000_000) // 100MB + .WithImageTransformation(true)) + .ConfigureDatabase(db => db + .WithPassword("your-secure-password") + .WithPort(54322)) + .WithMigrations("") + .WithEdgeFunctions(""); + +builder.Build().Run(); diff --git a/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Properties/launchSettings.json b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..fc4f2092c --- /dev/null +++ b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17227;http://localhost:15187", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21185", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22310" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15187", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19134", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20381" + } + } + } +} diff --git a/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/appsettings.json b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults.csproj b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults.csproj new file mode 100644 index 000000000..caa6344dc --- /dev/null +++ b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + enable + enable + true + + + + + + + + + + + + + + + diff --git a/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/Extensions.cs b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..ce94dc2c4 --- /dev/null +++ b/examples/supabase/CommunityToolkit.Aspire.Hosting.Supabase.ServiceDefaults/Extensions.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj b/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj index c13493e88..d5485b73f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj @@ -5,6 +5,10 @@ A complete Supabase stack integration for .NET Aspire, providing local development with full Supabase functionality including PostgreSQL, Auth (GoTrue), REST API (PostgREST), Storage, Kong API Gateway, Studio Dashboard, and Edge Functions. + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/AddSupabaseTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/AddSupabaseTests.cs new file mode 100644 index 000000000..d0b1856fb --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/AddSupabaseTests.cs @@ -0,0 +1,343 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Builders; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Tests; + +[Collection("Supabase")] +public class AddSupabaseTests +{ + [Fact] + public void AddSupabaseCreatesStackResource() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("supabase", stackResource.Name); + } + + [Fact] + public void AddSupabaseCreatesAllRequiredContainers() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + // Verify all expected containers are created + Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); + } + + [Fact] + public void AddSupabaseWithCustomNameUsesCorrectPrefix() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("my-supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("my-supabase", stackResource.Name); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("my-supabase-db", dbResource.Name); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("my-supabase-auth", authResource.Name); + } + + [Fact] + public void StackResourceHasDefaultJwtConfiguration() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + Assert.NotEmpty(stackResource.JwtSecret); + Assert.NotEmpty(stackResource.AnonKey); + Assert.NotEmpty(stackResource.ServiceRoleKey); + } + + [Fact] + public void StackResourceReferencesAllSubResources() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + Assert.NotNull(stackResource.Database); + Assert.NotNull(stackResource.Auth); + Assert.NotNull(stackResource.Rest); + Assert.NotNull(stackResource.Storage); + Assert.NotNull(stackResource.Kong); + Assert.NotNull(stackResource.Meta); + } + + [Fact] + public void DatabaseResourceHasDefaultConfiguration() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("postgres-insecure-dev-password", dbResource.Password); + Assert.Equal(54322, dbResource.ExternalPort); + } + + [Fact] + public void AuthResourceHasDefaultConfiguration() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(authResource.AutoConfirm); + Assert.False(authResource.DisableSignup); + Assert.True(authResource.AnonymousUsersEnabled); + Assert.Equal(3600, authResource.JwtExpiration); + } + + [Fact] + public void KongResourceHasDefaultPort() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var kongResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal(8000, kongResource.ExternalPort); + } + + [Fact] + public void ResourceNameCannotBeNull() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddSupabase(null!)); + } + + [Fact] + public void ResourceNameCannotBeEmpty() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddSupabase("")); + } + + [Fact] + public void ResourceNameCannotBeWhitespace() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddSupabase(" ")); + } + + [Fact] + public void StackResourceIsContainerResource() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + Assert.IsAssignableFrom(stackResource); + } + + [Fact] + public void StackResourceImplementsIResourceWithConnectionString() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + Assert.IsAssignableFrom(stackResource); + } + + [Fact] + public void AllContainersHaveContainerImageAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResources = appModel.Resources.OfType(); + + foreach (var container in containerResources) + { + Assert.True(container.TryGetAnnotationsOfType(out var annotations), + $"Container {container.Name} should have ContainerImageAnnotation"); + Assert.NotEmpty(annotations); + } + } + + [Fact] + public void DatabaseContainerHasCorrectImage() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(dbResource.TryGetAnnotationsOfType(out var annotations)); + var imageAnnotation = Assert.Single(annotations); + Assert.Equal("supabase/postgres", imageAnnotation.Image); + } + + [Fact] + public void AuthContainerHasCorrectImage() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(authResource.TryGetAnnotationsOfType(out var annotations)); + var imageAnnotation = Assert.Single(annotations); + Assert.Equal("supabase/gotrue", imageAnnotation.Image); + } + + [Fact] + public void RestContainerHasCorrectImage() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var restResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(restResource.TryGetAnnotationsOfType(out var annotations)); + var imageAnnotation = Assert.Single(annotations); + Assert.Equal("postgrest/postgrest", imageAnnotation.Image); + } + + [Fact] + public void StorageContainerHasCorrectImage() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(storageResource.TryGetAnnotationsOfType(out var annotations)); + var imageAnnotation = Assert.Single(annotations); + Assert.Equal("supabase/storage-api", imageAnnotation.Image); + } + + [Fact] + public void KongContainerHasCorrectImage() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var kongResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(kongResource.TryGetAnnotationsOfType(out var annotations)); + var imageAnnotation = Assert.Single(annotations); + Assert.Equal("kong", imageAnnotation.Image); + } + + [Fact] + public void DatabaseResourceHasEndpoint() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(dbResource.TryGetAnnotationsOfType(out var endpoints)); + Assert.NotEmpty(endpoints); + } + + [Fact] + public void KongResourceHasHttpEndpoint() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var kongResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(kongResource.TryGetAnnotationsOfType(out var endpoints)); + var httpEndpoint = endpoints.FirstOrDefault(e => e.Name == "http"); + Assert.NotNull(httpEndpoint); + Assert.Equal(8000, httpEndpoint.Port); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests.csproj new file mode 100644 index 000000000..ed7141499 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/ResourceConfigurationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/ResourceConfigurationTests.cs new file mode 100644 index 000000000..c275bbb80 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/ResourceConfigurationTests.cs @@ -0,0 +1,381 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Supabase.Builders; +using CommunityToolkit.Aspire.Hosting.Supabase.Resources; + +namespace CommunityToolkit.Aspire.Hosting.Supabase.Tests; + +[Collection("Supabase")] +public class ResourceConfigurationTests +{ + [Fact] + public void ConfigureAuthWithSiteUrl() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureAuth(auth => auth.WithSiteUrl("http://myapp.local:5000")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("http://myapp.local:5000", authResource.SiteUrl); + } + + [Fact] + public void ConfigureAuthWithAutoConfirmDisabled() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureAuth(auth => auth.WithAutoConfirm(false)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.False(authResource.AutoConfirm); + } + + [Fact] + public void ConfigureAuthWithDisableSignup() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureAuth(auth => auth.WithDisableSignup(true)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.True(authResource.DisableSignup); + } + + [Fact] + public void ConfigureAuthWithAnonymousUsersDisabled() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureAuth(auth => auth.WithAnonymousUsers(false)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.False(authResource.AnonymousUsersEnabled); + } + + [Fact] + public void ConfigureAuthWithCustomJwtExpiration() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureAuth(auth => auth.WithJwtExpiration(7200)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(7200, authResource.JwtExpiration); + } + + [Fact] + public void ConfigureAuthWithMultipleSettings() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureAuth(auth => auth + .WithSiteUrl("http://custom.local") + .WithAutoConfirm(false) + .WithDisableSignup(true) + .WithAnonymousUsers(false) + .WithJwtExpiration(1800)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var authResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("http://custom.local", authResource.SiteUrl); + Assert.False(authResource.AutoConfirm); + Assert.True(authResource.DisableSignup); + Assert.False(authResource.AnonymousUsersEnabled); + Assert.Equal(1800, authResource.JwtExpiration); + } + + [Fact] + public void ConfigureStorageWithFileSizeLimit() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureStorage(storage => storage.WithFileSizeLimit(100_000_000)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(100_000_000, storageResource.FileSizeLimit); + } + + [Fact] + public void ConfigureStorageWithBackend() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureStorage(storage => storage.WithBackend("s3")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("s3", storageResource.Backend); + } + + [Fact] + public void ConfigureStorageWithImageTransformationEnabled() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureStorage(storage => storage.WithImageTransformation(true)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + Assert.True(storageResource.EnableImageTransformation); + } + + [Fact] + public void ConfigureStorageWithImageTransformationDisabled() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureStorage(storage => storage.WithImageTransformation(false)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + Assert.False(storageResource.EnableImageTransformation); + } + + [Fact] + public void ConfigureDatabaseWithCustomPassword() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureDatabase(db => db.WithPassword("my-secure-password")); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("my-secure-password", dbResource.Password); + } + + [Fact] + public void ConfigureDatabaseWithCustomPort() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureDatabase(db => db.WithPort(5433)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(5433, dbResource.ExternalPort); + } + + [Fact] + public void ConfigureMultipleComponents() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase") + .ConfigureDatabase(db => db.WithPort(5433)) + .ConfigureAuth(auth => auth.WithSiteUrl("http://test.local")) + .ConfigureStorage(storage => storage.WithFileSizeLimit(50_000_000)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + var authResource = Assert.Single(appModel.Resources.OfType()); + var storageResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal(5433, dbResource.ExternalPort); + Assert.Equal("http://test.local", authResource.SiteUrl); + Assert.Equal(50_000_000, storageResource.FileSizeLimit); + } + + [Fact] + public void StackResourceProvidesApiUrl() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + var apiUrl = stackResource.GetApiUrl(); + Assert.Equal("http://localhost:8000", apiUrl); + } + + [Fact] + public void StackResourceProvidesStudioUrl() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + var studioUrl = stackResource.GetStudioUrl(); + Assert.Equal("http://localhost:54323", studioUrl); + } + + [Fact] + public void StackResourceProvidesPostgresConnectionString() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + var connectionString = stackResource.GetPostgresConnectionString(); + Assert.Contains("Host=localhost", connectionString); + Assert.Contains("Port=54322", connectionString); + Assert.Contains("Database=postgres", connectionString); + Assert.Contains("Username=postgres", connectionString); + } + + [Fact] + public void ConnectionStringExpressionReturnsKongUrl() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + + var expression = stackResource.ConnectionStringExpression; + Assert.NotNull(expression); + } + + [Fact] + public void DatabaseResourceReferencesParentStack() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + var dbResource = Assert.Single(appModel.Resources.OfType()); + + Assert.NotNull(dbResource); + Assert.Equal(stackResource, dbResource.Annotations.OfType() + .FirstOrDefault(a => a.Type == "Parent")?.Resource); + } + + [Fact] + public void AuthResourceReferencesParentStack() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var stackResource = Assert.Single(appModel.Resources.OfType()); + var authResource = Assert.Single(appModel.Resources.OfType()); + + Assert.NotNull(authResource); + Assert.Equal(stackResource, authResource.Annotations.OfType() + .FirstOrDefault(a => a.Type == "Parent")?.Resource); + } + + [Fact] + public void StorageResourceHasDefaultFileSizeLimit() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + Assert.True(storageResource.FileSizeLimit > 0); + } + + [Fact] + public void StorageResourceHasDefaultBackend() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var storageResource = Assert.Single(appModel.Resources.OfType()); + Assert.NotEmpty(storageResource.Backend); + } + + [Fact] + public void RestResourceHasDefaultSchemas() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var restResource = Assert.Single(appModel.Resources.OfType()); + Assert.NotEmpty(restResource.Schemas); + Assert.Contains("public", restResource.Schemas); + } + + [Fact] + public void RestResourceHasDefaultAnonRole() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSupabase("supabase"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var restResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("anon", restResource.AnonRole); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/SupabaseTestCollection.cs b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/SupabaseTestCollection.cs new file mode 100644 index 000000000..dba6fa4b0 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Supabase.Tests/SupabaseTestCollection.cs @@ -0,0 +1,6 @@ +namespace CommunityToolkit.Aspire.Hosting.Supabase.Tests; + +[CollectionDefinition("Supabase", DisableParallelization = true)] +public class SupabaseTestCollection +{ +} From 8440e1703ce450bb0760fa8a2746dacf6ddc4b88 Mon Sep 17 00:00:00 2001 From: Florian Gilde Date: Tue, 20 Jan 2026 16:47:23 +0100 Subject: [PATCH 4/4] add tests.yaml entries Update Readme --- .github/workflows/tests.yaml | 1 + README.md | 6 ++++++ src/CommunityToolkit.Aspire.Hosting.Supabase/README.md | 5 ----- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 38a3df7e6..4d64dc12f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -63,6 +63,7 @@ jobs: Hosting.Sqlite.Tests, Hosting.SqlServer.Extensions.Tests, Hosting.Stripe.Tests, + Hosting.Supabase.Tests, Hosting.SurrealDb.Tests, Hosting.Umami.Tests, diff --git a/README.md b/README.md index 8a308bf5d..27b30ca94 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col | - **Learn More**: [`MinIO.Client`][minio-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Minio.Client][minio-client-shields]][minio-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Client.Minio][minio-client-shields-preview]][minio-client-nuget-preview] | An Aspire client integration for the [MinIO](https://github.com/minio/minio-dotnet) package. | | - **Learn More**: [`Hosting.SurrealDb`][surrealdb-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.SurrealDb][surrealdb-shields]][surrealdb-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.SurrealDb][surrealdb-shields-preview]][surrealdb-nuget-preview] | An Aspire hosting integration leveraging the [SurrealDB](https://surrealdb.com/) container. | | - **Learn More**: [`SurrealDb`][surrealdb-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.SurrealDb][surrealdb-client-shields]][surrealdb-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.SurrealDb][surrealdb-client-shields-preview]][surrealdb-client-nuget-preview] | An Aspire client integration for the [SurrealDB](https://github.com/surrealdb/surrealdb.net/) package. | +| - **Learn More**: [`Hosting.Supabase`][supabase-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Supabase][supabase-shields]][supabase-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Supabase][supabase-shields-preview]][supabase-nuget-preview] | A complete [Supabase](https://supabase.com/) stack integration for local development with PostgreSQL, Auth, REST API, Storage, and Studio Dashboard. | | - **Learn More**: [`Hosting.Elasticsearch.Extensions`][elasticsearch-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Elasticsearch.Extensions][elasticsearch-ext-shields]][elasticsearch-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Elasticsearch.Extensions][elasticsearch-ext-shields-preview]][elasticsearch-ext-nuget-preview] | An integration that contains some additional extensions for hosting Elasticsearch container. | | - **Learn More**: [`Hosting.Umami`][umami-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Umami][umami-shields]][umami-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Umami][umami-shields-preview]][umami-nuget-preview] | An Aspire hosting integration leveraging the [Umami](https://umami.is/) container. | | - **Learn More**: [`Hosting.Azure.Extensions`][azure-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Azure.Extensions][azure-ext-shields]][azure-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Azure.Extensions][azure-ext-shields-preview]][azure-ext-nuget-preview] | An integration that contains some additional extensions for hosting Azure container. | @@ -292,3 +293,8 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [azure-ext-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Azure.Extensions/ [azure-ext-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Azure.Extensions?label=nuget%20(preview) [azure-ext-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Azure.Extensions/absoluteLatest +[supabase-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-supabase +[supabase-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Supabase +[supabase-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Supabase/ +[supabase-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Supabase?label=nuget%20(preview) +[supabase-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Supabase/absoluteLatest diff --git a/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md b/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md index 022761591..b8a327217 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md @@ -457,8 +457,3 @@ builder.Build().Run(); 2. For full schema sync, ensure the database password is provided 3. Check the console output for specific sync errors ---- - -## License - -This Supabase Aspire integration is part of the MandateManager project.