Skip to content

feat: per-group Supabase isolation via project or schema#58

Merged
howethomas merged 4 commits into
mainfrom
feat/multi-supabase-isolation
Jun 10, 2026
Merged

feat: per-group Supabase isolation via project or schema#58
howethomas merged 4 commits into
mainfrom
feat/multi-supabase-isolation

Conversation

@howethomas

Copy link
Copy Markdown
Contributor

What

Lets each vCon group run as its own MCP instance, isolated either by separate Supabase project (physical) or separate Postgres schema in a shared project (logical) — selectable per instance via env. The caller picks a group by choosing which named MCP server to call.

An instance is now fully described by { SUPABASE_URL, key, SUPABASE_DB_SCHEMA }:

  • different SUPABASE_URL → project isolation
  • same URL, different SUPABASE_DB_SCHEMA → schema isolation
  • mix freely; local and cloud interchangeable

Changes

  • src/db/client.ts — honor SUPABASE_DB_SCHEMA (default public), pass db.schema to createClient so .from()/.rpc() route to the group's schema; log the schema.
  • src/index.tsENV_FILE-aware dotenv.config so each instance loads .env.<group>; instance label + schema in startup banner.
  • src/api/routes/schema.ts — surface instance + schema in GET /health.
  • package.jsonstart / start:group scripts.
  • scripts/start-group.sh — launch an instance for a named group.
  • scripts/bootstrap-schema.sh — clone public into a new schema (writes reviewable SQL, applies with --apply).
  • scripts/migrate-all-groups.sh — fan out migrations across every .env.<group> (the main ongoing cost of isolation).
  • docs/guide/multi-supabase-isolation.md + sidebar entry; new env vars documented in .env.example.

Why not separate Postgres databases

Supabase's PostgREST layer (which supabase-js rides on) serves only the one default postgres database, so a second database is unreachable without dropping supabase-js for a raw pg connection. Schema isolation is the Postgres-native option that fits this stack.

Security note

In schema-isolation mode a single project service-role key bypasses RLS and can read every exposed schema — so with a shared service-role key it's an organizational boundary, not a hard one. A real boundary needs per-schema Postgres roles (documented as a follow-up in the guide). Project isolation is a security boundary by default.

Verification

  • npm run build green.
  • 738 tests pass (vitest run), 5 Mongo tests skipped.
  • Booted in HTTP mode against local Supabase with VCON_INSTANCE_LABEL + SUPABASE_DB_SCHEMA; GET /health correctly reports instance and schema, database: connected.
  • Full Mode B schema-routing isolation test (create in schema A → 404 in schema B) is documented in the guide; it requires exposing the new schema to PostgREST + restarting the local stack, so left for the operator.

🤖 Generated with Claude Code

Lets each vCon group run as its own MCP instance pointed at a separate
Supabase project (physical isolation) or a separate Postgres schema in a
shared project (logical isolation), selectable per instance.

- client.ts: honor SUPABASE_DB_SCHEMA (default public), pass db.schema to
  createClient so .from()/.rpc() route to the group's schema
- index.ts: ENV_FILE-aware dotenv load so each instance loads .env.<group>;
  instance label + schema in startup banner
- schema.ts: surface instance + schema in GET /health
- package.json: start / start:group scripts
- scripts/start-group.sh: launch an instance for a named group
- scripts/bootstrap-schema.sh: clone public into a new schema (review-then-apply)
- scripts/migrate-all-groups.sh: fan out migrations across all .env.<group>
- docs/guide/multi-supabase-isolation.md + sidebar entry; .env.example vars

Build green; 738 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 9, 2026 20:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enables running one vCon MCP server instance per “group”, isolating data either by separate Supabase projects (different SUPABASE_URL) or by separate Postgres schemas within a shared project (SUPABASE_DB_SCHEMA), with per-instance env-file loading and operator tooling to bootstrap/migrate schemas.

Changes:

  • Add ENV_FILE-selectable dotenv loading and include instance/schema metadata in startup logs and /health.
  • Scope Supabase client queries/RPCs to a configurable Postgres schema via createClient(..., { db: { schema } }).
  • Add operational scripts and documentation for starting per-group instances and bootstrapping/migrating per-schema setups.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/index.ts Loads env from ENV_FILE and logs instance/schema on startup.
src/db/client.ts Creates Supabase client scoped to SUPABASE_DB_SCHEMA.
src/api/routes/schema.ts Surfaces instance and schema in GET /health.
scripts/start-group.sh Starts a labeled instance with a per-group env file.
scripts/migrate-all-groups.sh Fans out migrations across .env.<group> files.
scripts/bootstrap-schema.sh Generates/applies SQL to clone public into a new schema.
package.json Adds start / start:group scripts for built server.
docs/guide/multi-supabase-isolation.md Documents project vs schema isolation modes and ops flow.
docs/.vitepress/config.ts Adds guide entry to the docs sidebar.
.env.example Documents new env vars for multi-instance isolation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/start-group.sh
Comment on lines +36 to +37
echo "Starting vCon MCP instance: group=$GROUP env=$ENV_FILE" >&2
exec node "$ROOT/dist/index.js"
Comment on lines +50 to +68
# Read just the two vars we care about without polluting the shell env.
url="$(grep -E '^\s*SUPABASE_URL=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)"
schema="$(grep -E '^\s*SUPABASE_DB_SCHEMA=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)"

echo "== group=$group url=${url:-<none>} schema=${schema:-public} ==" >&2

if [[ -n "$schema" && "$schema" != "public" ]]; then
# Schema-isolated.
SQL="$ROOT/sql/bootstrap-$schema.sql"
if [[ ! -f "$SQL" ]]; then
echo " ! Missing $SQL — run scripts/bootstrap-schema.sh $schema first. Skipping." >&2
continue
fi
DB_URL="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}"
run psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$SQL"
else
# Project-isolated.
run env ENV_FILE="$ENV_FILE" supabase db push
fi
Comment on lines +57 to +61
# Reject reserved / unsafe schema names.
if [[ "$SCHEMA" =~ [^a-zA-Z0-9_] || "$SCHEMA" == "public" || "$SCHEMA" == "extensions" || "$SCHEMA" == "auth" ]]; then
echo "Refusing schema name '$SCHEMA' (use a simple identifier; not public/extensions/auth)." >&2
exit 1
fi
Comment thread scripts/bootstrap-schema.sh Outdated
Comment on lines +80 to +83
printf '%s\n' "$DUMP" \
| sed -E "s/\bpublic\./${SCHEMA}./g" \
| sed -E "s/CREATE SCHEMA public;//g" \
| sed -E "s/SET search_path = public/SET search_path = ${SCHEMA}/g"
Comment thread src/db/client.ts
Comment on lines +46 to +55
// Cast back to the schema-agnostic SupabaseClient type: a runtime schema
// string widens the inferred literal ('public') and would otherwise not
// match the module-level type. The client is identical at runtime.
supabase = createClient(url, key, {
db: { schema },
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
}) as SupabaseClient;
howethomas and others added 2 commits June 9, 2026 17:00
Doc-review found the three new env vars (SUPABASE_DB_SCHEMA, ENV_FILE,
VCON_INSTANCE_LABEL) were in .env.example + the new guide but missing from
the canonical reference spots.

- installation.md: add the 3 vars to the Environment Variables Reference table
- configuration.md: new "Multi-Group / Schema Isolation" subsection after RLS
- README.md: "Isolating groups" pointer after the backend note
- getting-started.md: bump stale "Last Updated" April -> June 2026

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The multi-group feature introduces .env.<group> files (credentials) and
scripts/bootstrap-schema.sh writes sql/ dumps. Ignore both; keep .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 9, 2026 21:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.

Comment on lines +57 to +61
# Reject reserved / unsafe schema names.
if [[ "$SCHEMA" =~ [^a-zA-Z0-9_] || "$SCHEMA" == "public" || "$SCHEMA" == "extensions" || "$SCHEMA" == "auth" ]]; then
echo "Refusing schema name '$SCHEMA' (use a simple identifier; not public/extensions/auth)." >&2
exit 1
fi
Comment thread scripts/bootstrap-schema.sh Outdated
Comment on lines +80 to +83
printf '%s\n' "$DUMP" \
| sed -E "s/\bpublic\./${SCHEMA}./g" \
| sed -E "s/CREATE SCHEMA public;//g" \
| sed -E "s/SET search_path = public/SET search_path = ${SCHEMA}/g"
Comment on lines +49 to +52
group="${base#.env.}"
# Read just the two vars we care about without polluting the shell env.
url="$(grep -E '^\s*SUPABASE_URL=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)"
schema="$(grep -E '^\s*SUPABASE_DB_SCHEMA=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)"
…on types, grants

Three bugs found provisioning a schema group on macOS, plus one gap:

- BSD sed has no \b word boundary, so the public.->schema. rewrite silently
  matched nothing and objects landed in the wrong schema. Rewrite now uses
  perl -pe (ships with macOS), with a post-write guardrail that fails loudly
  if the output still lacks <schema>.vcons.
- Host pg_dump older than the server (Homebrew 14 vs Supabase local 17)
  aborts. The script now compares majors and falls back to running
  pg_dump/psql inside the supabase_db_* container publishing the DB port;
  --db-container overrides detection.
- Extension-owned names installed in public (pgvector vector/halfvec/
  sparsevec types, opclasses, functions) were rewritten to <schema>.vector,
  which fails since pg_dump skips extension members. They are now excluded
  via a pg_depend/pg_extension catalog query, seeded with pgvector names as
  a floor.
- The dump uses --no-privileges, so the generated SQL now ends with GRANT
  USAGE + table/sequence/function grants and default privileges for anon,
  authenticated, service_role (otherwise PostgREST returns permission
  denied for the new schema).

Verified against the local stack: generated output is byte-identical to the
hand-built sql/bootstrap-personal.sql (modulo \restrict token and the new
grants), and --apply creates 12 tables, 1 matview, 44 functions with working
grants and vector(384) columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@howethomas howethomas merged commit 27ac127 into main Jun 10, 2026
6 checks passed
@howethomas howethomas deleted the feat/multi-supabase-isolation branch June 10, 2026 22:27
@howethomas howethomas mentioned this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants