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/.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/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index 9d30dd237..ad82f1837 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -177,15 +177,20 @@
+
+
+
+
-
+
+
@@ -238,7 +243,7 @@
-
+
@@ -286,6 +291,7 @@
+
@@ -298,7 +304,7 @@
-
+
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/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/Builders/AuthBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/AuthBuilderExtensions.cs
new file mode 100644
index 000000000..5afb26816
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/DatabaseBuilderExtensions.cs
new file mode 100644
index 000000000..3d3fb047e
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/EdgeRuntimeBuilderExtensions.cs
new file mode 100644
index 000000000..e25fc46ec
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/KongBuilderExtensions.cs
new file mode 100644
index 000000000..790142e15
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/MetaBuilderExtensions.cs
new file mode 100644
index 000000000..4d0c7a751
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/RestBuilderExtensions.cs
new file mode 100644
index 000000000..61dcfa352
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StorageBuilderExtensions.cs
new file mode 100644
index 000000000..32a992de7
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/StudioBuilderExtensions.cs
new file mode 100644
index 000000000..c2afcac7a
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseBuilderExtensions.cs
new file mode 100644
index 000000000..518f83763
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Builders/SupabaseStackExtensions.cs
new file mode 100644
index 000000000..c14284d2e
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj b/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj
new file mode 100644
index 000000000..d5485b73f
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Supabase/CommunityToolkit.Aspire.Hosting.Supabase.csproj
@@ -0,0 +1,16 @@
+
+
+
+ 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/src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/EdgeFunctionRouter.cs
new file mode 100644
index 000000000..b6f2d8c85
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Helpers/SupabaseSqlGenerator.cs
new file mode 100644
index 000000000..969d163e8
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md b/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md
new file mode 100644
index 000000000..b8a327217
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Supabase/README.md
@@ -0,0 +1,459 @@
+# 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
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseAuthResource.cs
new file mode 100644
index 000000000..c58a26790
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseDatabaseResource.cs
new file mode 100644
index 000000000..5fb0b9c31
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseEdgeRuntimeResource.cs
new file mode 100644
index 000000000..0bfd2d4f5
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseKongResource.cs
new file mode 100644
index 000000000..1b1153f98
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseMetaResource.cs
new file mode 100644
index 000000000..f8a8184de
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseRestResource.cs
new file mode 100644
index 000000000..6045ae692
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStackResource.cs
new file mode 100644
index 000000000..d2f4fa5db
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Resources/SupabaseStorageResource.cs
new file mode 100644
index 000000000..a0fb4e4a0
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/ProjectSyncExtensions.cs
new file mode 100644
index 000000000..f67fc3ae0
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncOptions.cs
new file mode 100644
index 000000000..9242f08c3
--- /dev/null
+++ b/src/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/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs b/src/CommunityToolkit.Aspire.Hosting.Supabase/Sync/SyncService.cs
new file mode 100644
index 000000000..a07119f6d
--- /dev/null
+++ b/src/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/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
+{
+}