diff --git a/README.md b/README.md
index def5ea029..b51313acd 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ in your IDE’s toolbar or open the [/iosApp](./iosApp) directory in Xcode and r
- [Architecture overview](docs/architecture.md)
- [Build configuration (Supabase + Bugsnag)](docs/buildconfig-setup.md)
+- [Local Supabase development (test env instead of prod)](docs/supabase-local-development.md)
- [Developer settings (debug-only env switch + test-user login)](docs/developer-settings.md)
- [Deployment (Android / iOS / Desktop)](docs/deployment.md)
- [Deep linking setup](docs/DEEP_LINKING_SETUP.md)
diff --git a/client/composeApp/src/debug/AndroidManifest.xml b/client/composeApp/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..e0106c479
--- /dev/null
+++ b/client/composeApp/src/debug/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/client/composeApp/src/debug/res/xml/network_security_config.xml b/client/composeApp/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 000000000..2797ae214
--- /dev/null
+++ b/client/composeApp/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ 10.0.2.2
+ localhost
+ 127.0.0.1
+
+
diff --git a/docs/buildconfig-setup.md b/docs/buildconfig-setup.md
index 35869bdd8..f2fcff11d 100644
--- a/docs/buildconfig-setup.md
+++ b/docs/buildconfig-setup.md
@@ -79,4 +79,5 @@ buildkonfig {
## Related
+- [supabase-local-development.md](supabase-local-development.md) — run a full local Supabase stack and point `supabase.testing.*` at it so you develop against a test backend instead of prod.
- [developer-settings.md](developer-settings.md) — how to wire up the staging Supabase URL (`supabase.testing.*` / `SUPABASE_TESTING_*`) and pre-baked test users for the in-app developer menu.
diff --git a/docs/developer-settings.md b/docs/developer-settings.md
index b95aab347..af0640139 100644
--- a/docs/developer-settings.md
+++ b/docs/developer-settings.md
@@ -14,6 +14,8 @@ A debug-only screen reachable from the bottom of the **More** tab that lets you
The Supabase URL/key for **TESTING** is read at build time. PROD already has its values (see [buildconfig-setup.md](buildconfig-setup.md) for the existing `supabase.url` / `supabase.key`). FAKE re-uses PROD.
+> **Recommended TESTING backend:** a **local Supabase stack** (Docker) rather than a hosted staging project — it's free, isolated from prod, and works offline. See [supabase-local-development.md](supabase-local-development.md) for the full setup, including per-platform networking (`localhost` vs `10.0.2.2`) and seeded test users. The values below apply to either a local stack or a hosted staging project.
+
### `local.properties`
```properties
diff --git a/docs/supabase-local-development.md b/docs/supabase-local-development.md
new file mode 100644
index 000000000..1721ded29
--- /dev/null
+++ b/docs/supabase-local-development.md
@@ -0,0 +1,265 @@
+# Local Supabase Development
+
+Develop against a **fully local Supabase stack** (Postgres + Auth + Storage + Edge Functions +
+Studio, all in Docker) instead of testing against the production project. The app already has a
+`TESTING` environment baked in — this guide stands up a local backend and points `TESTING` at it.
+
+> **TL;DR**
+> ```bash
+> supabase start # boot the local stack (first run pulls Docker images)
+> supabase status # copy the API URL + anon key
+> # put them in local.properties as supabase.testing.* (see below), then:
+> ./gradlew :client:composeApp:run # desktop, talks to localhost:54321
+> ```
+> In the app: **More → Developer Settings → Environment → TESTING**, then restart.
+
+---
+
+## Why this works without app code changes
+
+The client already has the wiring:
+
+- `Environment` enum (`PROD` / `TESTING` / `FAKE`) + `EnvironmentProvider` in `client/shared`.
+- `SupabaseModule` reads `BuildConfig.SUPABASE_TESTING_URL` / `_KEY` when the active env is
+ `TESTING` ([SupabaseModule.kt](../client/auth/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/data/impl/SupabaseModule.kt)).
+- BuildKonfig reads `supabase.testing.url` / `supabase.testing.key` (falling back to the prod
+ values if unset) — see [client/shared/build.gradle.kts](../client/shared/build.gradle.kts).
+- The **Developer Settings** screen switches the active env and lets you log in as pre-baked
+ test users — see [developer-settings.md](developer-settings.md).
+
+So "develop against local" = point `supabase.testing.*` at the local stack and switch the app to
+`TESTING`.
+
+---
+
+## Prerequisites
+
+| Tool | Check | Install |
+|---|---|---|
+| Supabase CLI | `supabase --version` | `brew install supabase/tap/supabase` |
+| Docker (running) | `docker info` | Docker Desktop / OrbStack |
+
+The repo is already initialized for the CLI — `supabase/config.toml` is committed. You do **not**
+need to run `supabase init` again.
+
+---
+
+## ⚠️ One-time: dump prod's schema as a baseline
+
+**This step is required before the local DB will build** — `supabase/migrations/` ships empty.
+
+Why a squashed baseline instead of replaying migrations: the prod schema was built largely by
+hand on the dashboard, so the base tables (`recipes`, `profiles`, grocery, `meal_plans`) never had
+migration files. The original incremental migrations are kept in
+[`supabase/archived_migrations/`](../supabase/archived_migrations/) but are **not** applied —
+replaying them fails (the oldest already `REFERENCES recipes(id)` before any file creates it, and
+three share the version `20260610`). Instead we snapshot prod's *current* schema into one baseline.
+
+`db dump` is a read-only `pg_dump` — it never modifies prod and doesn't care about migration
+history:
+
+```bash
+# 1. Authenticate the CLI (opens a browser, one-time).
+supabase login
+
+# 2. Link this repo to the prod project. The ref is in your prod dashboard URL:
+# https://app.supabase.com/project/
+supabase link --project-ref
+
+# 3. Snapshot prod's public schema into an early-timestamped baseline migration so it runs first.
+supabase db dump --linked --schema public -f supabase/migrations/20260101000000_baseline.sql
+```
+
+This single file recreates everything currently in prod (base tables + every change the archived
+migrations made). Commit it. Future schema changes go in new migrations stacked on top.
+
+> **Note on storage buckets.** `db dump --schema public` doesn't include storage. The
+> `recipe-photos` and `avatars` buckets + their RLS policies are recreated locally by
+> [`supabase/seed.sql`](../supabase/seed.sql) (mirroring `docs/supabase-storage-setup.sql` /
+> `docs/supabase-avatars-setup.sql`, which you pasted into the prod dashboard). Nothing extra to do.
+
+---
+
+## Start the stack
+
+```bash
+supabase start
+```
+
+First run pulls several GB of Docker images (slow); subsequent starts are seconds. When it
+finishes it prints your local credentials. Re-print them any time with:
+
+```bash
+supabase status
+```
+
+Example output:
+
+```
+ API URL: http://127.0.0.1:54321
+ DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
+ Studio URL: http://127.0.0.1:54323
+ Inbucket URL: http://127.0.0.1:54324
+ anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+| Service | URL | Use |
+|---|---|---|
+| API (REST/Auth/Storage) | `http://localhost:54321` | what the app points at |
+| Studio (dashboard) | `http://localhost:54323` | browse tables, run SQL, view storage |
+| Inbucket (email capture) | `http://localhost:54324` | read confirmation/magic-link emails |
+| Postgres | `localhost:54322` | direct `psql` access (user/pass `postgres`) |
+
+> The local `anon key` is generated from the local JWT secret and is **stable across restarts**
+> on your machine, but can differ between CLI versions — always copy it from `supabase status`
+> rather than hardcoding.
+
+Apply migrations + `seed.sql` to a clean database at any time:
+
+```bash
+supabase db reset
+```
+
+---
+
+## Point the app at the local stack
+
+Add the local API URL + anon key to **`local.properties`** (gitignored). The host you use
+**depends on the target platform** (see the next section).
+
+```properties
+# Desktop (JVM) and iOS simulator can use localhost directly:
+supabase.testing.url=http://localhost:54321
+supabase.testing.key=
+
+# Pre-baked test users for Developer Settings → "Login as test user".
+# These must match the users seeded in supabase/seed.sql.
+chefmate.user.1=alice@chefmate.test
+chefmate.user.password.1=password123
+chefmate.user.2=bob@chefmate.test
+chefmate.user.password.2=password123
+```
+
+`supabase.testing.*` and `chefmate.user.*` are read at build time, so **rebuild/re-run** after
+editing `local.properties`. Full reference: [buildconfig-setup.md](buildconfig-setup.md) and
+[developer-settings.md](developer-settings.md).
+
+### Per-platform networking
+
+The local stack listens on your Mac's `localhost`. How each client reaches it differs:
+
+| Target | `supabase.testing.url` | Notes |
+|---|---|---|
+| **Desktop (JVM)** | `http://localhost:54321` | Runs on the host — works directly. |
+| **iOS simulator** | `http://localhost:54321` | Shares the host loopback. HTTP to loopback is exempt from App Transport Security, so no Info.plist change. |
+| **Android emulator** | `http://10.0.2.2:54321` | `10.0.2.2` is the emulator's alias for host loopback. Cleartext HTTP is allowed in **debug** builds via [`network_security_config.xml`](../client/composeApp/src/debug/res/xml/network_security_config.xml). |
+| **Physical device** | `http://:54321` | e.g. `http://192.168.1.20:54321`. Device + Mac must be on the same network. You may need `supabase start` exposed on `0.0.0.0` (it binds all interfaces by default). Android still needs the debug cleartext config (the LAN IP is covered only if you add it to the config). |
+
+> **Heads-up:** `supabase.testing.url` is a single build-time value, so building for the Android
+> emulator (`10.0.2.2`) vs Desktop/iOS (`localhost`) means swapping the line in `local.properties`
+> and rebuilding. Easiest day-to-day loop is **Desktop** or the **iOS simulator** with `localhost`.
+
+---
+
+## Switch the running app to TESTING
+
+1. Build/run a **debug** build (`./gradlew :client:composeApp:run`, `installDebug`, or the iOS
+ debug scheme).
+2. **More tab → Developer Settings** (debug-only row at the bottom).
+3. **Environment → TESTING.** This signs you out, wipes the local cache, and prompts for a
+ restart (the Supabase client binds its URL at first injection, so a restart is required).
+4. Reopen the app. Sync now hits your local stack.
+5. **Login as test user → User 1** to sign in as `alice@chefmate.test` without typing creds.
+
+---
+
+## Edge Functions locally
+
+The repo ships two functions (`delete-account`, `cleanup-avatars`). Serve them locally:
+
+```bash
+supabase functions serve # serves all functions with hot reload
+```
+
+They're reachable at `http://localhost:54321/functions/v1/` and the app's `Functions`
+client picks them up automatically when pointed at the local API URL. `SUPABASE_URL` and
+`SUPABASE_SERVICE_ROLE_KEY` are injected automatically for local serves.
+
+---
+
+## Everyday commands
+
+| Goal | Command |
+|---|---|
+| Start the stack | `supabase start` |
+| Stop it (keeps data) | `supabase stop` |
+| Stop + wipe all local data | `supabase stop --no-backup` |
+| Reset DB to migrations + seed | `supabase db reset` |
+| Show URLs + keys | `supabase status` |
+| Tail logs | `supabase logs` (or per service in Docker) |
+| New migration from a Studio change | `supabase db diff -f ` |
+| Serve edge functions | `supabase functions serve` |
+
+---
+
+## Troubleshooting
+
+- **`db reset` fails on `relation "recipes" does not exist`** (or `migrations/` is empty) — you
+ skipped the baseline step. Run the
+ [`supabase db dump`](#️-one-time-dump-prods-schema-as-a-baseline) step above.
+- **Android emulator: `CLEARTEXT communication ... not permitted`** — you're on a release build,
+ or using a host other than the ones in the debug `network_security_config.xml`. Use a debug
+ build and `10.0.2.2` (or add your host to the config).
+- **App still hits prod after switching to TESTING** — the Supabase client binds at first
+ injection. Fully restart (force-stop) the app after the env switch.
+- **Login as test user fails** — confirm `supabase/seed.sql` ran (`supabase db reset`) and that
+ `chefmate.user.*` in `local.properties` matches the seeded emails/passwords, then rebuild.
+- **Email confirmation blocking signup** — local `config.toml` sets
+ `[auth.email] enable_confirmations = false`; any emails that are sent are captured by Inbucket
+ at `http://localhost:54324` (nothing leaves your machine).
+- **Anonymous bootstrap fails** — `config.toml` has `enable_anonymous_sign_ins = true`; if you
+ changed it, restart the stack.
+
+---
+
+## Alternative: a hosted staging project
+
+If you'd rather not run Docker, create a **second Supabase cloud project** as staging:
+
+1. Create the project in the dashboard; copy its URL + anon key.
+2. `supabase link --project-ref ` then `supabase db push` to apply migrations
+ (after the baseline migration exists). Run `docs/supabase-storage-setup.sql` and
+ `docs/supabase-avatars-setup.sql` in its SQL editor, and enable **Anonymous Sign-ins** under
+ Authentication → Providers.
+3. Put the staging URL/key in `supabase.testing.*` and switch the app to `TESTING`.
+
+Trade-offs: real email/OAuth and no Docker, but it uses cloud quota and needs a network. The
+local stack is preferred for day-to-day work.
+
+---
+
+## Appendix: reconciling prod's migration history (for future CLI pushes)
+
+You've been applying SQL to prod **by hand**, so prod's migration-history table
+(`supabase_migrations.schema_migrations`) doesn't know about your migrations. If you ever run
+`supabase db push` against prod, the CLI will try to **replay the baseline from scratch** — which
+will error because every object already exists.
+
+Because we squashed to a single baseline, reconciling is now a one-liner. After
+`supabase link --project-ref `:
+
+```bash
+# Mark the baseline as already-applied WITHOUT re-running it (prod already has this schema).
+supabase migration repair --status applied 20260101000000
+
+# Verify local and remote agree.
+supabase migration list
+```
+
+Once reconciled, `supabase db push` will only apply genuinely *new* migrations you add on top of
+the baseline, and you can stop pasting SQL into the dashboard. **This is optional and only needed
+when you want the CLI to manage prod — it is not required for local development.**
+
+> The archived per-feature migrations in `supabase/archived_migrations/` are history-only and are
+> never pushed; the baseline already contains their combined effect.
diff --git a/supabase/.gitignore b/supabase/.gitignore
new file mode 100644
index 000000000..ad9264f0b
--- /dev/null
+++ b/supabase/.gitignore
@@ -0,0 +1,8 @@
+# Supabase
+.branches
+.temp
+
+# dotenvx
+.env.keys
+.env.local
+.env.*.local
diff --git a/supabase/migrations/20260513_add_categories.sql b/supabase/archived_migrations/20260513_add_categories.sql
similarity index 100%
rename from supabase/migrations/20260513_add_categories.sql
rename to supabase/archived_migrations/20260513_add_categories.sql
diff --git a/supabase/migrations/20260522_add_grocery_aisle.sql b/supabase/archived_migrations/20260522_add_grocery_aisle.sql
similarity index 100%
rename from supabase/migrations/20260522_add_grocery_aisle.sql
rename to supabase/archived_migrations/20260522_add_grocery_aisle.sql
diff --git a/supabase/migrations/20260602_add_recipe_books.sql b/supabase/archived_migrations/20260602_add_recipe_books.sql
similarity index 100%
rename from supabase/migrations/20260602_add_recipe_books.sql
rename to supabase/archived_migrations/20260602_add_recipe_books.sql
diff --git a/supabase/migrations/20260604_feature_flags_admin.sql b/supabase/archived_migrations/20260604_feature_flags_admin.sql
similarity index 100%
rename from supabase/migrations/20260604_feature_flags_admin.sql
rename to supabase/archived_migrations/20260604_feature_flags_admin.sql
diff --git a/supabase/migrations/20260605_add_feature_flag_user_ids.sql b/supabase/archived_migrations/20260605_add_feature_flag_user_ids.sql
similarity index 100%
rename from supabase/migrations/20260605_add_feature_flag_user_ids.sql
rename to supabase/archived_migrations/20260605_add_feature_flag_user_ids.sql
diff --git a/supabase/migrations/20260608_add_recipe_book_collaboration.sql b/supabase/archived_migrations/20260608_add_recipe_book_collaboration.sql
similarity index 100%
rename from supabase/migrations/20260608_add_recipe_book_collaboration.sql
rename to supabase/archived_migrations/20260608_add_recipe_book_collaboration.sql
diff --git a/supabase/migrations/20260610_fix_recipe_rls_recursion.sql b/supabase/archived_migrations/20260610_fix_recipe_rls_recursion.sql
similarity index 100%
rename from supabase/migrations/20260610_fix_recipe_rls_recursion.sql
rename to supabase/archived_migrations/20260610_fix_recipe_rls_recursion.sql
diff --git a/supabase/migrations/20260610_invite_email_via_auth_users.sql b/supabase/archived_migrations/20260610_invite_email_via_auth_users.sql
similarity index 100%
rename from supabase/migrations/20260610_invite_email_via_auth_users.sql
rename to supabase/archived_migrations/20260610_invite_email_via_auth_users.sql
diff --git a/supabase/migrations/20260610_recipe_book_collaborators.sql b/supabase/archived_migrations/20260610_recipe_book_collaborators.sql
similarity index 100%
rename from supabase/migrations/20260610_recipe_book_collaborators.sql
rename to supabase/archived_migrations/20260610_recipe_book_collaborators.sql
diff --git a/supabase/archived_migrations/README.md b/supabase/archived_migrations/README.md
new file mode 100644
index 000000000..6a3a07737
--- /dev/null
+++ b/supabase/archived_migrations/README.md
@@ -0,0 +1,16 @@
+# Archived migrations
+
+These are the original incremental migration files that predate adopting the Supabase CLI.
+
+They are **not** applied by the CLI (they live outside `supabase/migrations/`, so `supabase db
+reset` / `db push` ignore them). They were superseded by the squashed baseline migration
+(`supabase/migrations/_baseline.sql`), which is a full snapshot of the prod schema and
+already includes every change these files made.
+
+Why squashed: the prod schema was built largely by hand via the dashboard, so the base tables
+(`recipes`, `profiles`, grocery, `meal_plans`) never had migration files — the oldest file here
+already `REFERENCES recipes(id)`. Replaying these against a fresh DB therefore fails on ordering,
+and three of them share the version `20260610` (a duplicate-version collision in the history
+table). The baseline sidesteps both problems.
+
+Kept here purely for historical reference. See [docs/supabase-local-development.md](../../docs/supabase-local-development.md).
diff --git a/supabase/config.toml b/supabase/config.toml
new file mode 100644
index 000000000..73982a3b5
--- /dev/null
+++ b/supabase/config.toml
@@ -0,0 +1,416 @@
+# For detailed configuration reference documentation, visit:
+# https://supabase.com/docs/guides/local-development/cli/config
+# A string used to distinguish different Supabase projects on the same host. Defaults to the
+# working directory name when running `supabase init`.
+project_id = "chef-mate"
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` and `graphql_public` schemas are included by default.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+# Controls whether new tables, views, sequences and functions created in the `public` schema by
+# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
+# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default
+# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in
+# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early.
+# auto_expose_new_tables = false
+
+[api.tls]
+# Enable HTTPS endpoints locally using a self-signed certificate.
+enabled = false
+# Paths to self-signed certificate pair.
+# cert_path = "../certs/my-cert.pem"
+# key_path = "../certs/my-key.pem"
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# Maximum amount of time to wait for health check when starting the local database.
+health_timeout = "2m"
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 17
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+# [db.vault]
+# secret_key = "env(SECRET_VALUE)"
+
+[db.migrations]
+# If disabled, migrations will be skipped during a db push or reset.
+enabled = true
+# Specifies an ordered list of schema files that describe your database.
+# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
+schema_paths = []
+
+[db.seed]
+# If enabled, seeds the database after migrations during a db reset.
+enabled = true
+# Specifies an ordered list of seed files to load during db reset.
+# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
+sql_paths = ["./seed.sql"]
+
+[db.network_restrictions]
+# Enable management of network restrictions.
+enabled = false
+# List of IPv4 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
+allowed_cidrs = ["0.0.0.0/0"]
+# List of IPv6 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
+allowed_cidrs_v6 = ["::/0"]
+
+# Uncomment to reject non-secure connections to the database.
+# [db.ssl_enforcement]
+# enabled = true
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[inbucket]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+# Uncomment to configure local storage buckets
+# [storage.buckets.images]
+# public = false
+# file_size_limit = "50MiB"
+# allowed_mime_types = ["image/png", "image/jpeg"]
+# objects_path = "./images"
+
+# Allow connections via S3 compatible clients
+[storage.s3_protocol]
+enabled = true
+
+# Image transformation API is available to Supabase Pro plan.
+# [storage.image_transformation]
+# enabled = true
+
+# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
+# This feature is only available on the hosted platform.
+[storage.analytics]
+enabled = false
+max_namespaces = 5
+max_tables = 10
+max_catalogs = 2
+
+# Analytics Buckets is available to Supabase Pro plan.
+# [storage.analytics.buckets.my-warehouse]
+
+# Store vector embeddings in S3 for large and durable datasets
+[storage.vector]
+enabled = true
+max_buckets = 10
+max_indexes = 5
+
+# Vector Buckets is available to Supabase Pro plan.
+# [storage.vector.buckets.documents-openai]
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "http://127.0.0.1:3000"
+# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
+# external_url = ""
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["https://127.0.0.1:3000"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# JWT issuer URL. If not set, defaults to auth.external_url.
+# jwt_issuer = ""
+# Path to JWT signing key. DO NOT commit your signing keys file to git.
+# signing_keys_path = "./signing_keys.json"
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+# ChefMate bootstraps an anonymous Supabase session on app start
+# (SupabaseAuthenticationRepository.kt), so this must stay enabled locally — matching the
+# "Anonymous Sign-ins" provider toggle enabled on the prod dashboard.
+enable_anonymous_sign_ins = true
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
+minimum_password_length = 6
+# Passwords that do not meet the following requirements will be rejected as weak. Supported values
+# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
+password_requirements = ""
+
+# Configure passkey sign-ins.
+# [auth.passkey]
+# enabled = false
+
+# Configure WebAuthn relying party settings (required when passkey is enabled).
+# [auth.webauthn]
+# rp_display_name = "Supabase"
+# rp_id = "localhost"
+# rp_origins = ["http://127.0.0.1:3000"]
+
+[auth.rate_limit]
+# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
+email_sent = 2
+# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
+sms_sent = 30
+# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
+anonymous_users = 30
+# Number of sessions that can be refreshed in a 5 minute interval per IP address.
+token_refresh = 150
+# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
+sign_in_sign_ups = 30
+# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
+token_verifications = 30
+# Number of Web3 logins that can be made in a 5 minute interval per IP address.
+web3 = 30
+
+# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
+# [auth.captcha]
+# enabled = true
+# provider = "hcaptcha"
+# secret = ""
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = true
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = true
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = false
+# If enabled, users will need to reauthenticate or have logged in recently to change their password.
+secure_password_change = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1s"
+# Number of characters used in the email OTP.
+otp_length = 6
+# Number of seconds before the email OTP expires (defaults to 1 hour).
+otp_expiry = 3600
+
+# Use a production-ready SMTP server
+# [auth.email.smtp]
+# enabled = true
+# host = "smtp.sendgrid.net"
+# port = 587
+# user = "apikey"
+# pass = "env(SENDGRID_API_KEY)"
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+# Uncomment to customize notification email template
+# [auth.email.notification.password_changed]
+# enabled = true
+# subject = "Your password has been changed"
+# content_path = "./templates/password_changed_notification.html"
+
+[auth.sms]
+# Allow/disallow new user signups via SMS to your project.
+enable_signup = false
+# If enabled, users need to confirm their phone number before signing in.
+enable_confirmations = false
+# Template for sending OTP to users
+template = "Your code is {{ `{{ .Code }}` }}"
+# Controls the minimum amount of time that must pass before sending another sms otp.
+max_frequency = "5s"
+
+# Use pre-defined map of phone number to OTP for testing.
+# [auth.sms.test_otp]
+# 4152127777 = "123456"
+
+# Configure logged in session timeouts.
+# [auth.sessions]
+# Force log out after the specified duration.
+# timebox = "24h"
+# Force log out if the user has been inactive longer than the specified duration.
+# inactivity_timeout = "8h"
+
+# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
+# [auth.hook.before_user_created]
+# enabled = true
+# uri = "pg-functions://postgres/auth/before-user-created-hook"
+
+# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
+# [auth.hook.custom_access_token]
+# enabled = true
+# uri = "pg-functions:////"
+
+# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
+[auth.sms.twilio]
+enabled = false
+account_sid = ""
+message_service_sid = ""
+# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
+auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
+
+# Multi-factor-authentication is available to Supabase Pro plan.
+[auth.mfa]
+# Control how many MFA factors can be enrolled at once per user.
+max_enrolled_factors = 10
+
+# Control MFA via App Authenticator (TOTP)
+[auth.mfa.totp]
+enroll_enabled = false
+verify_enabled = false
+
+# Configure MFA via Phone Messaging
+[auth.mfa.phone]
+enroll_enabled = false
+verify_enabled = false
+otp_length = 6
+template = "Your code is {{ `{{ .Code }}` }}"
+max_frequency = "5s"
+
+# Configure MFA via WebAuthn
+# [auth.mfa.web_authn]
+# enroll_enabled = true
+# verify_enabled = true
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.apple]
+enabled = false
+client_id = ""
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
+# Overrides the default auth callback URL derived from auth.external_url.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
+email_optional = false
+
+# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
+# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
+[auth.web3.solana]
+enabled = false
+
+# Use Firebase Auth as a third-party provider alongside Supabase Auth.
+[auth.third_party.firebase]
+enabled = false
+# project_id = "my-firebase-project"
+
+# Use Auth0 as a third-party provider alongside Supabase Auth.
+[auth.third_party.auth0]
+enabled = false
+# tenant = "my-auth0-tenant"
+# tenant_region = "us"
+
+# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
+[auth.third_party.aws_cognito]
+enabled = false
+# user_pool_id = "my-user-pool-id"
+# user_pool_region = "us-east-1"
+
+# Use Clerk as a third-party provider alongside Supabase Auth.
+[auth.third_party.clerk]
+enabled = false
+# Obtain from https://clerk.com/setup/supabase
+# domain = "example.clerk.accounts.dev"
+
+# OAuth server configuration
+[auth.oauth_server]
+# Enable OAuth server functionality
+enabled = false
+# Path for OAuth consent flow UI
+authorization_url_path = "/oauth/consent"
+# Allow dynamic client registration
+allow_dynamic_registration = false
+
+[edge_runtime]
+enabled = true
+# Supported request policies: `oneshot`, `per_worker`.
+# `per_worker` (default) — enables hot reload during local development.
+# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
+policy = "per_worker"
+# Port to attach the Chrome inspector for debugging edge functions.
+inspector_port = 8083
+# The Deno major version to use.
+deno_version = 2
+
+# [edge_runtime.secrets]
+# secret_key = "env(SECRET_VALUE)"
+
+[analytics]
+enabled = true
+port = 54327
+# Configure one of the supported backends: `postgres`, `bigquery`.
+backend = "postgres"
+
+# Experimental features may be deprecated any time
+[experimental]
+# Configures Postgres storage engine to use OrioleDB (S3)
+orioledb_version = ""
+# Configures S3 bucket URL, eg. .s3-.amazonaws.com
+s3_host = "env(S3_HOST)"
+# Configures S3 bucket region, eg. us-east-1
+s3_region = "env(S3_REGION)"
+# Configures AWS_ACCESS_KEY_ID for S3 bucket
+s3_access_key = "env(S3_ACCESS_KEY)"
+# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
+s3_secret_key = "env(S3_SECRET_KEY)"
+
+# [experimental.pgdelta]
+# When enabled, pg-delta becomes the active engine for supported schema flows.
+# enabled = false
+# Directory under `supabase/` where declarative files are written.
+# declarative_schema_path = "./database"
+# JSON string passed through to pg-delta SQL formatting.
+# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
diff --git a/supabase/seed.sql b/supabase/seed.sql
new file mode 100644
index 000000000..b8188a2a6
--- /dev/null
+++ b/supabase/seed.sql
@@ -0,0 +1,162 @@
+-- Local-only seed data, applied by `supabase db reset` / `supabase start`.
+--
+-- This file is for the LOCAL Docker stack ONLY. It is never pushed to prod (only
+-- `supabase/migrations/` is). It recreates the things that, on prod, were set up by hand:
+-- 1. The storage buckets + RLS policies (mirrors docs/supabase-storage-setup.sql and
+-- docs/supabase-avatars-setup.sql, which were pasted into the prod dashboard).
+-- 2. A couple of confirmed email/password users so the in-app Developer Settings
+-- "Login as test user" flow works against the local stack with no network.
+--
+-- Keep the test-user credentials below in sync with the `chefmate.user.*` entries in
+-- local.properties (see docs/supabase-local-development.md).
+
+-- ---------------------------------------------------------------------------
+-- Storage: recipe-photos bucket + policies
+-- ---------------------------------------------------------------------------
+insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+values (
+ 'recipe-photos',
+ 'recipe-photos',
+ true,
+ 5242880, -- 5 MB
+ array['image/jpeg', 'image/png', 'image/webp', 'image/heic']
+)
+on conflict (id) do update set
+ public = excluded.public,
+ file_size_limit = excluded.file_size_limit,
+ allowed_mime_types = excluded.allowed_mime_types;
+
+drop policy if exists "recipe_photos_public_read" on storage.objects;
+create policy "recipe_photos_public_read"
+on storage.objects for select
+to public
+using (bucket_id = 'recipe-photos');
+
+drop policy if exists "recipe_photos_owner_insert" on storage.objects;
+create policy "recipe_photos_owner_insert"
+on storage.objects for insert
+to authenticated
+with check (
+ bucket_id = 'recipe-photos'
+ and (storage.foldername(name))[1] = auth.uid()::text
+);
+
+drop policy if exists "recipe_photos_owner_update" on storage.objects;
+create policy "recipe_photos_owner_update"
+on storage.objects for update
+to authenticated
+using (
+ bucket_id = 'recipe-photos'
+ and (storage.foldername(name))[1] = auth.uid()::text
+)
+with check (
+ bucket_id = 'recipe-photos'
+ and (storage.foldername(name))[1] = auth.uid()::text
+);
+
+drop policy if exists "recipe_photos_owner_delete" on storage.objects;
+create policy "recipe_photos_owner_delete"
+on storage.objects for delete
+to authenticated
+using (
+ bucket_id = 'recipe-photos'
+ and (storage.foldername(name))[1] = auth.uid()::text
+);
+
+-- ---------------------------------------------------------------------------
+-- Storage: avatars bucket + policies
+-- ---------------------------------------------------------------------------
+insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+values (
+ 'avatars',
+ 'avatars',
+ true,
+ 5242880, -- 5 MB
+ array['image/jpeg', 'image/png', 'image/webp', 'image/heic']
+)
+on conflict (id) do update set
+ public = excluded.public,
+ file_size_limit = excluded.file_size_limit,
+ allowed_mime_types = excluded.allowed_mime_types;
+
+drop policy if exists "avatars_public_read" on storage.objects;
+create policy "avatars_public_read"
+on storage.objects for select
+to public
+using (bucket_id = 'avatars');
+
+drop policy if exists "avatars_owner_insert" on storage.objects;
+create policy "avatars_owner_insert"
+on storage.objects for insert
+to authenticated
+with check (
+ bucket_id = 'avatars'
+ and (storage.foldername(name))[1] = auth.uid()::text
+);
+
+drop policy if exists "avatars_owner_update" on storage.objects;
+create policy "avatars_owner_update"
+on storage.objects for update
+to authenticated
+using (
+ bucket_id = 'avatars'
+ and (storage.foldername(name))[1] = auth.uid()::text
+)
+with check (
+ bucket_id = 'avatars'
+ and (storage.foldername(name))[1] = auth.uid()::text
+);
+
+drop policy if exists "avatars_owner_delete" on storage.objects;
+create policy "avatars_owner_delete"
+on storage.objects for delete
+to authenticated
+using (
+ bucket_id = 'avatars'
+ and (storage.foldername(name))[1] = auth.uid()::text
+);
+
+-- ---------------------------------------------------------------------------
+-- Auth: pre-baked, email-confirmed test users
+-- ---------------------------------------------------------------------------
+-- Inserts directly into GoTrue's tables with a bcrypt-hashed password and a matching
+-- `auth.identities` row (required for email/password login on current GoTrue). Emails are
+-- pre-confirmed (email_confirmed_at = now()) so no verification step is needed.
+do $$
+declare
+ test_users text[][] := array[
+ array['alice@chefmate.test', 'password123'],
+ array['bob@chefmate.test', 'password123']
+ ];
+ u text[];
+ uid uuid;
+begin
+ foreach u slice 1 in array test_users loop
+ -- Skip if a user with this email already exists (keeps the seed idempotent).
+ if exists (select 1 from auth.users where email = u[1]) then
+ continue;
+ end if;
+
+ uid := gen_random_uuid();
+
+ insert into auth.users (
+ instance_id, id, aud, role, email, encrypted_password,
+ email_confirmed_at, created_at, updated_at,
+ raw_app_meta_data, raw_user_meta_data, is_super_admin
+ ) values (
+ '00000000-0000-0000-0000-000000000000', uid, 'authenticated', 'authenticated',
+ u[1], extensions.crypt(u[2], extensions.gen_salt('bf')),
+ now(), now(), now(),
+ '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, false
+ );
+
+ insert into auth.identities (
+ id, user_id, provider_id, identity_data, provider,
+ last_sign_in_at, created_at, updated_at
+ ) values (
+ gen_random_uuid(), uid, uid::text,
+ jsonb_build_object('sub', uid::text, 'email', u[1]), 'email',
+ now(), now(), now()
+ );
+ end loop;
+end $$;