Architecture

Multi-tenancy

Single database, shared schema, and a policy that does the work.

GStack uses a single database with a shared schema. Every table carries a tenant_id, and RLS scopes rows through a current_tenant_id() security-definer helper that reads the caller's profile.

Signup creates a tenant and makes the registrant its admin, via a handle_new_user trigger. profiles.id is the auth user id — there is no second identity table to keep in sync.

Roles

member and admin. RLS is the real security layer; the roles key in definePageMeta is UX only — it stops a member from navigating to a page that would render empty anyway.

definePageMeta({ roles: ['admin'] })

Why app code doesn't scope queries

The reference CRUD (layers/notes) never filters by tenant or owner:

const { data: notes } = await useAsyncData('notes', async () =>
  supabase.from('notes').select('*').order('created_at', { ascending: false }),
)

There is no .eq('tenant_id', …) because the policy already applied it. Scoping in both places means two things to keep correct, and the app-layer one is the one that silently rots.

Proving it

Claiming isolation is cheap. e2e/tenant-isolation.spec.ts logs in as an admin of a second seeded tenant and asserts the first tenant's rows are invisible through the real HTTP surface — not through a mocked client.

That test is the reason a widened policy fails in CI instead of in production.

Scaling path

Reading the tenant from the caller's profile costs a lookup per policy evaluation. The upgrade — moving tenant_id into a JWT claim — is noted in the migration itself, so the person who hits the ceiling finds the note where they're already looking.

That's deliberate: the fast version is a schema change plus a claim, and doing it before there's load would be optimising a query nobody has run yet.