Skip to content

Commit 3a34e7c

Browse files
TML-2503: Supabase-internal namespaces via a secondary db.supabase root (slice D) (#845)
## Linked issue Refs [TML-2503](https://linear.app/prisma-company/issue/TML-2503). Slice D of extension-supabase (slice A merged in #839). Slice contract: `projects/extension-supabase/slices/d-service-role-internal-namespaces/spec.md`; design in decision **C15**. ## At a glance ```ts const admin = db.asServiceRole(); admin.sql.public.profile… // primary root (app contract) — unchanged admin.supabase.sql.auth.users.select({ … }) // secondary root (extension contract) admin.supabase.orm.auth.AuthUser.find({ … }) db.asAnon().supabase // ✗ not on the type — service_role only ``` ## Decision Admin access to Supabase-internal tables (`auth.*`, `storage.*`) is exposed as a **separate secondary root** `db.asServiceRole().supabase` — **not** by merging the extension contract into the app contract (a first attempt did that; rejected in review). The `.supabase` facet is the extension contract's **own intact** `ExecutionContext` + a second `SupabaseRuntimeImpl` bound to it, **sharing the app runtime's driver/pool + the `service_role` session**, with marker-verification off (the `external` extension contract owns no app-space marker). `asServiceRole().sql`/`.orm` stay app-contract-only; `asUser`/`asAnon` are unchanged (no `.supabase`). ## Why a separate root, not a merge The runtime is **contract-bound by `storageHash`**: `SqlFamilyAdapter.validatePlan` asserts `plan.meta.storageHash === context.contract.storage.storageHash`. A plan built against the extension contract carries the extension's hash and can't run on the app runtime — so the two roots are genuinely separate (own context + own runtime), sharing only the driver/pool. Merging two `Contract`s also breaks codec-registry uniqueness and the marker check. Two intact contexts/runtimes sharing one pool is the ADR 230 pattern. ## Behavior changes & evidence - `db.asServiceRole().supabase.{sql,orm}` query the extension contract; `asServiceRole().sql`/`.orm` are app-only; `asUser`/`asAnon` have no `.supabase`. Impl: `packages/3-extensions/supabase/src/runtime/supabase.ts` (`SupabaseInternalDb`, `ServiceRoleDb`, `buildExtensionContract`, `extContext`/`extRuntime`). Evidence: `examples/supabase/test/explicit-namespace-query.integration.test.ts` (reads `auth.users` via `.supabase.sql` **and** `.orm`; SQL targets `"auth"."users"`; `current_setting('role') = 'service_role'`) + `service-role-namespaces.test-d.ts` (type-level: secondary root carries `auth`/`storage`; primary root + `asAnon`/`asUser` do not). ## Reviewer notes - **Independent opus review: APPROVE-WITH-NITS** — confirmed one shared pool (no double-close), `service_role` binding intact on the ext path, marker-verify correctly scoped (ext off, app on), no `auth.*` leak to anon/user, per-contract `validatePlan`. The one acted nit (doc comment explaining `SupabaseInternalDb` omits `transaction`) is applied. - This single commit **replaces an earlier merge approach** (force-pushed away) — the diff is the net secondary-root design. - `ext-contract-type.ts` is a no-`as` type alias naming the extension `Contract` distinctly from the framework `Contract` (keeps `lint:casts` delta 0). - **v1 limitation (documented):** no single `transaction` spanning the app root and `.supabase` (separate runtimes, unpinned connections). The principled fix — a `Runtime` bound to the **aggregate contract** — is recorded as future direction in C15 + `deferred.md`. ## Testing performed `pnpm exec turbo run typecheck test lint --filter @prisma-next/example-supabase --filter @prisma-next/extension-supabase` — green (example suite 9 tests, incl. the `.supabase` sql+orm reads). `pnpm lint:casts` delta 0; `pnpm lint:deps` clean. CI re-running on push. ## Skill update n/a — the only user-facing surface change (the `asServiceRole().supabase` admin root) is documented in decision C15 + `overview.md`; no CLI/config/error-code change. ## Checklist - [x] All commits are signed off (`git commit -s`). - [x] I read CONTRIBUTING.md and the change is scoped to one logical concern. - [x] Tests are updated (integration + type-level). - [x] PR title is Linear-prefixed. - [x] **Skill update** filled in. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an additive `db.asServiceRole().supabase` admin root that enables service-role access to Supabase internal namespaces (e.g., `auth`, `storage`) via `.sql` and `.orm`, while keeping the primary `asServiceRole()` surface restricted to `public`. * **Tests** * Added integration coverage for internal-namespace reads, emitted SQL targeting, and role-scoped execution for both app and internal surfaces. * Added compile-time typing checks for `service_role`, `anon`, and user/JWT namespace visibility. * **Documentation** * Updated upgrade instructions to describe the new secondary `.supabase` surface and related exported types. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d987376 commit 3a34e7c

12 files changed

Lines changed: 508 additions & 22 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/**
2+
* service_role reaches the Supabase-internal namespaces (`auth`, `storage`)
3+
* through a separate secondary root: `db.asServiceRole().supabase`. The internal
4+
* contract is never merged into the app contract, so the primary root
5+
* (`asServiceRole().sql` / `.orm`) stays app-only, identical to `asAnon` /
6+
* `asUser`.
7+
*
8+
* Assertions:
9+
*
10+
* 1. `db.asServiceRole().supabase.sql.auth.users.select(…)` returns a seeded
11+
* row and the emitted SQL targets `"auth"."users"`; the bound connection
12+
* runs as `service_role` (read via `current_setting('role')`), proving it
13+
* is the role grants — not the pool owner — that authorise the read. The
14+
* ORM path (`.supabase.orm.auth.AuthUser.first(…)`) reads back the same row.
15+
* 2. The primary root `asServiceRole().sql.public.profile` still works
16+
* (the secondary root does not disturb the app contract).
17+
*
18+
* The compile-time side (`.supabase` carries `auth`/`storage`; the primary root
19+
* and `asAnon`/`asUser` do not) lives in `service-role-namespaces.test-d.ts`.
20+
*/
21+
22+
import { mkdtemp, rm } from 'node:fs/promises';
23+
import { tmpdir } from 'node:os';
24+
import { join } from 'node:path';
25+
import postgresAdapter from '@prisma-next/adapter-postgres/control';
26+
import { createControlClient } from '@prisma-next/cli/control-api';
27+
import postgresDriver from '@prisma-next/driver-postgres/control';
28+
import supabasePack from '@prisma-next/extension-supabase/pack';
29+
import sql from '@prisma-next/family-sql/control';
30+
import { emitContractSpaceArtefacts } from '@prisma-next/migration-tools/spaces';
31+
import type { SqlMiddleware } from '@prisma-next/sql-runtime';
32+
import postgres from '@prisma-next/target-postgres/control';
33+
import { createDevDatabase, timeouts, withClient } from '@prisma-next/test-utils';
34+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
35+
import contractJson from '../src/contract.json' with { type: 'json' };
36+
import { createDb } from '../src/prisma/db';
37+
import { bootstrapSupabaseShim } from './supabase-bootstrap';
38+
39+
function recordingMiddleware(): { middleware: SqlMiddleware; sqls: string[] } {
40+
const sqls: string[] = [];
41+
const middleware: SqlMiddleware = {
42+
name: 'sql-recorder',
43+
familyId: 'sql',
44+
async beforeExecute(plan) {
45+
sqls.push(plan.sql);
46+
},
47+
};
48+
return { middleware, sqls };
49+
}
50+
51+
async function runDbInit(connectionString: string, migrationsDir: string): Promise<void> {
52+
const space = supabasePack.contractSpace;
53+
if (!space) throw new Error('supabasePack must declare a contractSpace');
54+
55+
await emitContractSpaceArtefacts(migrationsDir, 'supabase', {
56+
contract: space.contractJson,
57+
contractDts: '// supabase extension contract space\n',
58+
headRef: { hash: space.headRef.hash, invariants: [...space.headRef.invariants] },
59+
});
60+
61+
const client = createControlClient({
62+
family: sql,
63+
target: postgres,
64+
adapter: postgresAdapter,
65+
driver: postgresDriver,
66+
extensionPacks: [supabasePack],
67+
});
68+
69+
try {
70+
await client.connect(connectionString);
71+
const result = await client.dbInit({ contract: contractJson, mode: 'apply', migrationsDir });
72+
if (!result.ok) throw new Error(`dbInit apply failed: ${result.failure.summary}`);
73+
} finally {
74+
await client.close();
75+
}
76+
}
77+
78+
describe('service_role queries auth/storage via the .supabase secondary root', () => {
79+
let database: Awaited<ReturnType<typeof createDevDatabase>>;
80+
let migrationsDir: string;
81+
82+
beforeEach(async () => {
83+
database = await createDevDatabase();
84+
migrationsDir = await mkdtemp(join(tmpdir(), 'supabase-sliced-migrations-'));
85+
}, timeouts.spinUpPpgDev);
86+
87+
afterEach(async () => {
88+
if (database) await database.close();
89+
if (migrationsDir) await rm(migrationsDir, { recursive: true, force: true });
90+
}, timeouts.spinUpPpgDev);
91+
92+
it(
93+
'asServiceRole().supabase reads auth.users (sql + orm) as service_role; emitted SQL targets auth.users',
94+
async () => {
95+
const { connectionString } = database;
96+
97+
await withClient(connectionString, async (pg) => {
98+
await bootstrapSupabaseShim(pg);
99+
});
100+
await runDbInit(connectionString, migrationsDir);
101+
102+
await withClient(connectionString, async (pg) => {
103+
await pg.query(
104+
'GRANT USAGE ON SCHEMA _prisma_dev_wal TO anon, authenticated, service_role',
105+
);
106+
await pg.query(
107+
'GRANT ALL ON ALL TABLES IN SCHEMA _prisma_dev_wal TO anon, authenticated, service_role',
108+
);
109+
await pg.query(
110+
'GRANT ALL ON ALL SEQUENCES IN SCHEMA _prisma_dev_wal TO anon, authenticated, service_role',
111+
);
112+
await pg.query(
113+
'GRANT EXECUTE ON FUNCTION _prisma_dev_wal.capture_event() TO anon, authenticated, service_role',
114+
);
115+
});
116+
117+
const userId = crypto.randomUUID();
118+
const now = new Date().toISOString();
119+
120+
await withClient(connectionString, async (pg) => {
121+
await pg.query(
122+
'INSERT INTO auth.users (id, email, created_at, updated_at) VALUES ($1, $2, $3, $3)',
123+
[userId, 'admin@example.com', now],
124+
);
125+
});
126+
127+
const recorder = recordingMiddleware();
128+
const db = await createDb(connectionString, { middleware: [recorder.middleware] });
129+
130+
try {
131+
const internal = db.asServiceRole().supabase;
132+
133+
// SQL path: read auth.users via the secondary root.
134+
const rows = await internal
135+
.execute(
136+
internal.sql.auth.users
137+
.select('id', 'email')
138+
.where((f, fns) => fns.eq(f.id, userId))
139+
.build(),
140+
)
141+
.toArray();
142+
143+
expect(rows).toEqual([{ id: userId, email: 'admin@example.com' }]);
144+
145+
// The emitted SQL must reference "auth"."users"
146+
const authQuery = recorder.sqls.find((s) => s.includes('"auth"') && s.includes('"users"'));
147+
expect(
148+
authQuery,
149+
`Expected a SQL query targeting "auth"."users"; saw: ${JSON.stringify(recorder.sqls)}`,
150+
).toBeDefined();
151+
152+
// Pin the bound role: the connection serving the secondary root must run
153+
// as service_role, not as the pool's owner/superuser (which would also
154+
// have had grants and passed the read above). This is the security boundary.
155+
const [boundRole] = await internal
156+
.execute(
157+
internal.sql.auth.users
158+
.select('role', (_f, fns) => fns.raw`current_setting('role')`.returns('pg/text@1'))
159+
.where((f, fns) => fns.eq(f.id, userId))
160+
.build(),
161+
)
162+
.toArray();
163+
expect(boundRole).toEqual({ role: 'service_role' });
164+
165+
// ORM path reaches auth through the secondary root: orm.auth.AuthUser maps
166+
// to "auth"."users". Reads back the same seeded row as the .sql assertion.
167+
const authUser = await internal.orm.auth.AuthUser.select('id', 'email').first({
168+
id: userId,
169+
});
170+
expect(authUser).toEqual({ id: userId, email: 'admin@example.com' });
171+
} finally {
172+
await db.close();
173+
}
174+
},
175+
timeouts.spinUpPpgDev * 4,
176+
);
177+
178+
it(
179+
'asServiceRole() primary root stays app-only while .supabase reaches auth',
180+
async () => {
181+
const { connectionString } = database;
182+
183+
await withClient(connectionString, async (pg) => {
184+
await bootstrapSupabaseShim(pg);
185+
});
186+
await runDbInit(connectionString, migrationsDir);
187+
188+
await withClient(connectionString, async (pg) => {
189+
await pg.query(
190+
'GRANT USAGE ON SCHEMA _prisma_dev_wal TO anon, authenticated, service_role',
191+
);
192+
await pg.query(
193+
'GRANT ALL ON ALL TABLES IN SCHEMA _prisma_dev_wal TO anon, authenticated, service_role',
194+
);
195+
await pg.query(
196+
'GRANT ALL ON ALL SEQUENCES IN SCHEMA _prisma_dev_wal TO anon, authenticated, service_role',
197+
);
198+
await pg.query(
199+
'GRANT EXECUTE ON FUNCTION _prisma_dev_wal.capture_event() TO anon, authenticated, service_role',
200+
);
201+
});
202+
203+
const userId = crypto.randomUUID();
204+
const now = new Date().toISOString();
205+
206+
await withClient(connectionString, async (pg) => {
207+
await pg.query(
208+
'INSERT INTO auth.users (id, email, created_at, updated_at) VALUES ($1, $2, $3, $3)',
209+
[userId, 'admin@example.com', now],
210+
);
211+
});
212+
213+
const db = await createDb(connectionString);
214+
215+
try {
216+
const sr = db.asServiceRole();
217+
218+
// Primary root: the app contract, exactly as asUser/asAnon see it.
219+
await sr.execute(sr.sql.public.profile.select('id').build()).toArray();
220+
221+
// Secondary root: the Supabase-internal contract, reached only here.
222+
const authRows = await sr.supabase
223+
.execute(sr.supabase.sql.auth.users.select('email').build())
224+
.toArray();
225+
226+
expect(authRows.some((r) => r.email === 'admin@example.com')).toBe(true);
227+
} finally {
228+
await db.close();
229+
}
230+
},
231+
timeouts.spinUpPpgDev * 4,
232+
);
233+
});
234+
// Type-level assertions for the surface (only .supabase carries auth/storage;
235+
// the primary root and asAnon/asUser do not, and have no .supabase) live in
236+
// service-role-namespaces.test-d.ts, typed with this example's app contract.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Type-level invariants for the service_role namespace surface.
3+
*
4+
* Typed with the real example app contract (`../src/contract`), which declares
5+
* only the `public` namespace (with `profile`) and has no `auth` / `storage`.
6+
* That is the whole point: the negative assertions below would be vacuous
7+
* against a contract that already carried `auth` / `storage`.
8+
*
9+
* Proven here:
10+
* 1. The Supabase-internal namespaces (`auth`, `storage`) live ONLY on the
11+
* `asServiceRole().supabase` secondary root, on both `.sql` and `.orm`.
12+
* 2. The primary root (`asServiceRole().sql` / `.orm`) is app-only — no
13+
* `auth` / `storage` — exactly like `asAnon()` / `asUser(jwt)`.
14+
* 3. Only `asServiceRole()` carries a `.supabase` property; `asAnon()` /
15+
* `asUser(jwt)` do not.
16+
*/
17+
18+
import type { RoleBoundDb, SupabaseDb } from '@prisma-next/extension-supabase/runtime';
19+
import { expectTypeOf, test } from 'vitest';
20+
import type { Contract } from '../src/contract';
21+
22+
const db = {} as SupabaseDb<Contract>;
23+
24+
test('asServiceRole().supabase.sql exposes the internal auth and storage namespaces', () => {
25+
const internal = db.asServiceRole().supabase;
26+
expectTypeOf(internal.sql).toHaveProperty('auth');
27+
expectTypeOf(internal.sql).toHaveProperty('storage');
28+
});
29+
30+
test('asServiceRole().supabase.orm exposes the internal auth and storage namespaces', () => {
31+
const internal = db.asServiceRole().supabase;
32+
expectTypeOf(internal.orm).toHaveProperty('auth');
33+
expectTypeOf(internal.orm).toHaveProperty('storage');
34+
});
35+
36+
test('asServiceRole() primary root is app-only — public but not auth/storage', () => {
37+
const sr = db.asServiceRole();
38+
expectTypeOf(sr.sql).toHaveProperty('public');
39+
expectTypeOf(sr.sql).not.toHaveProperty('auth');
40+
expectTypeOf(sr.sql).not.toHaveProperty('storage');
41+
expectTypeOf(sr.orm).toHaveProperty('public');
42+
expectTypeOf(sr.orm).not.toHaveProperty('auth');
43+
expectTypeOf(sr.orm).not.toHaveProperty('storage');
44+
});
45+
46+
test('asAnon() is app-only and has no .supabase secondary root', () => {
47+
const anon = db.asAnon();
48+
expectTypeOf(anon.sql).toHaveProperty('public');
49+
expectTypeOf(anon.sql).not.toHaveProperty('auth');
50+
expectTypeOf(anon.sql).not.toHaveProperty('storage');
51+
expectTypeOf(anon).not.toHaveProperty('supabase');
52+
});
53+
54+
test('asUser(jwt) is app-only and has no .supabase secondary root', async () => {
55+
const user = await db.asUser('jwt');
56+
expectTypeOf(user.sql).not.toHaveProperty('auth');
57+
expectTypeOf(user.sql).not.toHaveProperty('storage');
58+
expectTypeOf(user.orm).not.toHaveProperty('auth');
59+
expectTypeOf(user.orm).not.toHaveProperty('storage');
60+
expectTypeOf(user).not.toHaveProperty('supabase');
61+
});
62+
63+
test('asAnon() and asUser() return the plain app-contract RoleBoundDb', async () => {
64+
expectTypeOf(db.asAnon()).toEqualTypeOf<RoleBoundDb<Contract>>();
65+
expectTypeOf(await db.asUser('jwt')).toEqualTypeOf<RoleBoundDb<Contract>>();
66+
});

packages/3-extensions/supabase/src/exports/runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ export default supabaseRuntimeDescriptor;
44

55
export type {
66
RoleBoundDb,
7+
ServiceRoleDb,
78
SupabaseDb,
9+
SupabaseInternalDb,
810
SupabaseOptions,
911
SupabaseOptionsWithContract,
1012
SupabaseOptionsWithContractJson,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Contract } from '../contract/contract.d';
2+
3+
/**
4+
* The Supabase extension's own emitted contract type (`auth`, `storage`
5+
* namespaces), re-exported under a distinct name so the runtime facade can
6+
* reference it alongside the framework `Contract` (`@prisma-next/contract/types`)
7+
* without an aliased import. Backs the `service_role` `.supabase` secondary root.
8+
*/
9+
export type SupabaseExtensionContract = Contract;

0 commit comments

Comments
 (0)