@exposify/supabase 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Ali
3
+ Copyright (c) 2026 Exposify
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -26,10 +26,7 @@ From your Supabase project's root (the directory containing `supabase/`):
26
26
  npx exposify-supabase init
27
27
  ```
28
28
 
29
- This is safe to re-run — it never overwrites an existing function file, and
30
- never scaffolds a second copy of the migration if one's already there
31
- (checked by filename, not content: migrations are meant to be immutable
32
- once written, so a re-run skips rather than rewrites). It writes:
29
+ This is safe to re-run; see "Re-running `init`" below. It writes:
33
30
 
34
31
  - `supabase/migrations/<timestamp>_exposify_meta.sql` — a SQL migration that
35
32
  adds `exposify_meta()`, a `SECURITY DEFINER` function returning, per table,
@@ -37,8 +34,10 @@ once written, so a re-run skips rather than rewrites). It writes:
37
34
  you've written. It only ever returns catalog metadata (table/column names,
38
35
  flags, comments) — never row data — which is what makes it safe to grant
39
36
  to `anon`/`authenticated` and call with a low-privilege key.
40
- - `supabase/functions/mcp/index.ts` — the edge function itself, a few lines
41
- around `aiReady()`.
37
+ - `supabase/functions/mcp/index.ts` — the edge function itself. `init` owns
38
+ the part between `// exposify:begin` and `// exposify:end` (imports and a
39
+ `generated` options object). Everything after that is yours, starting with
40
+ `Deno.serve(aiReady({ ...generated, /* your options */ }))`.
42
41
  - `supabase/functions/mcp/docs/` — an empty directory for optional bundled
43
42
  docs (see "Knowledge base" below).
44
43
 
@@ -101,14 +100,100 @@ you to hand-edit `writes`/`rpc`/comments yourself:
101
100
  worth doing even for tables you're not granting writes on, since it's
102
101
  the only thing that makes read tools self-explanatory to an agent.
103
102
 
104
- If `supabase/functions/mcp/index.ts` already exists, none of this touches
105
- it — your selections are printed as a config snippet to merge in by hand
106
- instead, and the documentation migration still gets written either way.
107
- Connects using `SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY` (env vars or
108
- `--url`/`--key` flags — **the service role key, not the anon key**; see the
109
- note on introspection below) — without those, `init` can't reach anything to
110
- introspect and falls back to the fixed scaffold-and-ask-to-deploy flow above,
111
- same as before this wizard existed.
103
+ On a re-run, each question starts from what's already configured; see
104
+ "Re-running `init`" below.
105
+
106
+ To reach the project, `init` needs its URL and **service role key (not the
107
+ anon key)**; see the note on introspection below. It looks in this order:
108
+
109
+ 1. `--url`/`--key` flags.
110
+ 2. `SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY` env vars. A flag beats its env
111
+ var, so a value left in your shell can't override what you typed.
112
+ 3. The Supabase CLI, if it's on your `PATH`:
113
+ - a running local stack: `supabase status` (`API_URL` +
114
+ `SERVICE_ROLE_KEY`, or the newer `SECRET_KEY`);
115
+ - the linked project: the ref in `supabase/.temp/project-ref`, keys from
116
+ `supabase projects api-keys --reveal`. This needs `supabase login`.
117
+
118
+ If both exist, `init` asks which one to use. `--local`/`--linked`
119
+ skips the question. Without a terminal to ask in, it uses the linked
120
+ project, because that's where `functions deploy` sends the function.
121
+
122
+ It prints which source it used. The key stays in memory and is never written
123
+ to a file. If nothing works, `init` can't introspect anything and falls back
124
+ to the fixed scaffold-and-ask-to-deploy flow above.
125
+
126
+ ### Re-running `init`
127
+
128
+ `init` works out every file change first and only then writes. That makes
129
+ re-runs safe:
130
+
131
+ - **Answers default to what's configured.** The broker and anonymous sign-in
132
+ questions default to what the function already has. Per-table writes show
133
+ the current grants (`orders [i,u]:`). Enter keeps them, `-` clears them,
134
+ and a typo keeps them too, with a note. Declining "Configure write
135
+ access?" keeps all grants as they are. RPCs work the same way.
136
+ Non-interactive runs keep everything except what flags override, so CI
137
+ can't reset your config.
138
+ - **Only the managed block of `index.ts` is ever rewritten, and only when
139
+ its config actually changed.** Order doesn't count, so the same grants in
140
+ a different order aren't a change. Code outside the markers is never
141
+ touched. If you edited *inside* the block in a way `init` can't read back,
142
+ or the file has no markers, `init` leaves the file alone and prints the
143
+ block for you to merge. It also leaves `verify_jwt` on until the broker
144
+ wiring is really in the file, because turning it off first would let
145
+ tokenless requests run as anon.
146
+ - **Descriptions are never asked for twice.** Columns covered by a local
147
+ `_exposify_comments.sql` count as documented even before `db push`, so a
148
+ re-run doesn't write a second migration for them.
149
+ - **A run that changes no file checks the project instead.** It checks that
150
+ `exposify_meta()` is applied, that local descriptions are pushed, and that
151
+ the deployed function is current. "Current" means its write/RPC tools match
152
+ the config, and it rejects tokenless requests when the config uses the
153
+ broker. If all of that holds, `init` prints "Already up to date" and stops.
154
+ Otherwise it names what's behind and offers only the step that fixes it
155
+ (`db push` and/or `functions deploy`).
156
+ - **`init --check`** asks no questions and writes nothing. It exits 1 if a
157
+ run would change a file (CI drift check). It looks at local files only;
158
+ `doctor` checks the live project.
159
+
160
+ ### Using the hosted broker
161
+
162
+ If you use the Exposify hosted broker (the dashboard's "Hosted broker" card),
163
+ `init` asks first:
164
+
165
+ ```
166
+ Use the Exposify hosted broker for auth (MCP clients get a long-lived extb_... token instead of a Supabase JWT)? [y/N]
167
+ ```
168
+
169
+ Or skip the question with `--broker-url <url> --broker-project <id>`
170
+ (or `EXPOSIFY_BROKER_URL`/`EXPOSIFY_PROJECT_ID`), which also works
171
+ non-interactively. Saying yes does three things:
172
+
173
+ 1. **Wires the resolver into the function.** Adds
174
+ `auth: createHostedAuthResolver({ brokerUrl, projectId, licenseKey })` and
175
+ `authRequired: true` to `aiReady()`. The license key is read from the
176
+ `EXPOSIFY_LICENSE_KEY` secret and is never written into the file. This
177
+ goes in the managed block, so an existing `index.ts` gets it too, unless
178
+ its block was hand-edited. In that case `init` prints the block for you to
179
+ merge, and leaves `verify_jwt` alone until you have.
180
+ 2. **Sets `verify_jwt = false` for `mcp` in `supabase/config.toml`.** An
181
+ `extb_...` token isn't a JWT, so with the gateway's check on, every request
182
+ fails with `{"code":"UNAUTHORIZED_ASYMMETRIC_JWT","message":"Invalid JWT"}`
183
+ before the function runs. The setting goes in config.toml and not just
184
+ on the deploy command line, because a later plain
185
+ `supabase functions deploy mcp` would otherwise turn the check back on.
186
+ With the gateway check off, `authRequired: true` is what rejects requests
187
+ that have no valid broker token. Otherwise those requests would run as
188
+ `anon`.
189
+ 3. **Deploys with `--no-verify-jwt`**, and adds
190
+ `supabase secrets set EXPOSIFY_LICENSE_KEY=...` to the printed steps.
191
+ `init` never sees the license key, so you set that secret yourself.
192
+ Until you do, every request gets a 401.
193
+
194
+ MCP clients then send `Authorization: Bearer extb_...`. Don't give the client
195
+ the JWT that `POST /resolve` returns. It's the short-lived Supabase token the
196
+ function swaps in on each request, and it stops working when it expires.
112
197
 
113
198
  ## 2. Decide what's exposed
114
199
 
@@ -120,19 +205,24 @@ invisible, full stop, with no override. For each exposed table you get a
120
205
  column list, `order`, and a `limit` capped server-side (100 rows by default,
121
206
  regardless of what the caller asks for).
122
207
 
123
- Writes and Postgres functions are **opt-in**, edited in
124
- `supabase/functions/mcp/index.ts`:
208
+ Writes and Postgres functions are **opt-in**. Re-run `init` to change them,
209
+ or edit the managed block in `supabase/functions/mcp/index.ts` by hand,
210
+ keeping its format. Anything else goes below the block:
125
211
 
126
212
  ```ts
213
+ // exposify:begin -- generated by `npx exposify-supabase init`, ...
127
214
  import { aiReady } from "npm:@exposify/supabase";
128
215
 
129
- Deno.serve(aiReady({
216
+ const generated = {
130
217
  // "<table>.insert" | "<table>.update" | "<table>.delete" | "<table>.*"
131
218
  writes: ["orders.insert", "orders.update"],
132
-
133
219
  // Postgres functions exposed as rpc_<fn> tools
134
220
  rpc: ["place_order"],
221
+ };
222
+ // exposify:end
135
223
 
224
+ Deno.serve(aiReady({
225
+ ...generated,
136
226
  // Sibling edge functions, invoked via fetch with the caller's forwarded JWT
137
227
  functions: {
138
228
  refund: {
@@ -216,7 +306,8 @@ unless you're editing `sql/exposify_meta.sql` directly).
216
306
  ## 4. Connect an MCP client
217
307
 
218
308
  Point any MCP client at the function's URL with a real user's JWT as the
219
- bearer token — for example, with the Claude Code CLI:
309
+ bearer token (or set up OAuth, below, so the client logs in by itself) — for
310
+ example, with the Claude Code CLI:
220
311
 
221
312
  ```bash
222
313
  claude mcp add --transport http my-app https://<project-ref>.supabase.co/functions/v1/mcp \
@@ -227,6 +318,141 @@ Ask the agent to query or mutate data. **It should succeed for that user's
227
318
  own rows and be refused everyone else's** — that's the definition of done:
228
319
  RLS enforcement, not anything in this SDK, is what makes that true.
229
320
 
321
+ ## 5. Check it: `doctor`
322
+
323
+ ```bash
324
+ npx exposify-supabase doctor # finds the project the same way init does
325
+ npx exposify-supabase doctor --token <user-jwt | extb_...>
326
+ ```
327
+
328
+ A read-only health check of everything `init` sets up. It never writes a file
329
+ and never runs a mutating Supabase CLI command. Each check prints ✓ (pass),
330
+ ! (warning) or ✗ (failure), and warnings and failures come with the fix. It
331
+ exits 1 if anything failed, so you can run it in CI.
332
+
333
+ - **Project:** `supabase/config.toml`, the Supabase CLI, the linked project.
334
+ - **Local files:** the `exposify_meta()` migration and `functions/mcp/index.ts`,
335
+ and which auth that file uses. With the hosted broker or OAuth, it checks
336
+ that `verify_jwt = false` for `mcp`.
337
+ - **Database:** `exposify_meta()` is applied and the PostgREST schema is
338
+ readable. It lists which tables became tools, and warns about tables that
339
+ never will: RLS off, or RLS on but no API grants. It also warns about
340
+ tables whose missing comments leave tools with thin descriptions.
341
+ - **Deployed function:** speaks MCP to `<project>/functions/v1/mcp`
342
+ (`initialize`, then `tools/list`) and reports the tool count. If the
343
+ function registered `exposify://diagnostics`, which it only does when
344
+ degraded, doctor prints that too. With the broker, it also checks that the
345
+ `EXPOSIFY_LICENSE_KEY` secret exists (`supabase secrets list`).
346
+
347
+ **How the function check authenticates:** it uses `--token` if you pass one.
348
+ Otherwise it sends no token. If the gateway rejects that (`verify_jwt` on),
349
+ it retries once with the legacy anon key, when one was discovered or set in
350
+ `SUPABASE_ANON_KEY`. Tools come from service-role introspection, so the anon
351
+ role is enough to list them. A function with `authRequired` answers 401 to
352
+ both, and doctor reports that as a warning asking for `--token`.
353
+
354
+ ## "Click Connect and log in" with Supabase Auth (OAuth)
355
+
356
+ Instead of pasting a JWT, MCP clients can log users in through Supabase
357
+ Auth's OAuth 2.1 server:
358
+
359
+ ```ts
360
+ // supabase/functions/mcp/index.ts
361
+ import { aiReady, supabaseAuth } from "npm:@exposify/supabase";
362
+
363
+ Deno.serve(aiReady({ oauth: supabaseAuth(), writes: ["todos.*"] }));
364
+ ```
365
+
366
+ `init --oauth` (or answering yes to its "sign in with Supabase Auth?"
367
+ question) writes this for you and handles `verify_jwt`. The user adds just
368
+ the function URL to Claude, Cursor, etc. The first request gets a `401`
369
+ whose `WWW-Authenticate` points at Protected Resource Metadata served under
370
+ the function's own URL (`…/functions/v1/mcp/.well-known/oauth-protected-resource`).
371
+ The client then discovers Supabase Auth, registers, opens your consent page,
372
+ and refreshes tokens later on its own. The validated token is an ordinary
373
+ project JWT, forwarded to PostgREST unchanged, so **RLS runs as the
374
+ signed-in user** exactly as with a pasted JWT.
375
+
376
+ Project setup (dashboard, **Authentication > OAuth Server**, beta):
377
+
378
+ - Enable the OAuth 2.1 server and **dynamic client registration**.
379
+ - Set the **authorization path** to a consent page your app hosts, built
380
+ with `supabase.auth.oauth.getAuthorizationDetails()` /
381
+ `approveAuthorization()`. Supabase doesn't host one for you.
382
+ - Use **asymmetric JWT signing keys**. `supabaseAuth()` verifies tokens
383
+ against `/auth/v1/.well-known/jwks.json`, and legacy HS256 projects have no
384
+ keys there.
385
+ - **Turn off the gateway's JWT check** (`verify_jwt = false`). Otherwise the
386
+ gateway answers the first tokenless request with its own `401`, which has no
387
+ `resource_metadata`, and discovery never starts. The `oauth` gate checks
388
+ every token itself.
389
+
390
+ Notes:
391
+
392
+ - Supabase Auth always issues `aud: "authenticated"` and can't bind a
393
+ token to the function's URL, so `supabaseAuth()` accepts that audience (a
394
+ startup warning says so). In practice any valid user JWT of the project
395
+ is accepted, which is exactly what RLS already trusts.
396
+ - `resource` defaults to `${SUPABASE_URL}/functions/v1/<function directory>`.
397
+ Pass `supabaseAuth({ resource })` if you serve the function under a
398
+ custom domain.
399
+ - Any other spec-compliant AS works with plain options:
400
+ `oauth: { issuer, resource, scopes?, audience?, jwksUri?, verify? }`
401
+ (see the `@exposify/express` README).
402
+ - By default PRM is only reachable through the `401` header (an edge
403
+ function can't serve the project domain's root `/.well-known/`, and
404
+ Supabase's custom-domain add-on doesn't change that; the root still
405
+ belongs to Supabase). MCP clients are required to prefer that header, so
406
+ this only fails with clients that probe well-known URIs and ignore it. If
407
+ you hit one, or want a branded URL anyway, see "Serving it from your own
408
+ domain" below.
409
+ - `oauth` can't be combined with `auth`/`authRequired` (the broker) or
410
+ `docsAuth: "public"`; both throw at startup.
411
+ - Not yet verified against a live Supabase project with a real MCP client;
412
+ see HANDOVER.
413
+
414
+ ### Serving it from your own domain (optional)
415
+
416
+ To give clients the standard root-level well-known URI, put a proxy you
417
+ control in front of the function, e.g. a Cloudflare Worker on
418
+ `mcp.acme.com`:
419
+
420
+ ```js
421
+ const FN = "https://<project-ref>.supabase.co/functions/v1/mcp";
422
+
423
+ export default {
424
+ async fetch(req) {
425
+ const { pathname, search } = new URL(req.url);
426
+ let target;
427
+ if (pathname === "/.well-known/oauth-protected-resource/mcp") {
428
+ target = `${FN}/.well-known/oauth-protected-resource`;
429
+ } else if (pathname === "/mcp" || pathname.startsWith("/mcp/")) {
430
+ target = FN + pathname.slice("/mcp".length) + search;
431
+ } else {
432
+ return new Response("not found", { status: 404 });
433
+ }
434
+ return fetch(new Request(target, req));
435
+ },
436
+ };
437
+ ```
438
+
439
+ and tell the function its public address:
440
+
441
+ ```ts
442
+ oauth: supabaseAuth({
443
+ resource: "https://mcp.acme.com/mcp", // what clients connect to = token audience
444
+ metadataUrl: "https://mcp.acme.com/.well-known/oauth-protected-resource/mcp",
445
+ }),
446
+ ```
447
+
448
+ The PRM now reports the proxy URL, the `401` points at the root-level URI,
449
+ and the function answers the forwarded request (it serves PRM on any path
450
+ ending in `/.well-known/oauth-protected-resource`). Caveat: the built-in
451
+ per-IP rate limiter keys on `x-forwarded-for`, which Supabase's gateway
452
+ will likely set to the proxy's address, so every client could share one
453
+ 60-requests/minute bucket. Rate-limit at the proxy and raise `rateLimit` on
454
+ the function. This setup is untested against a real deployment.
455
+
230
456
  ## Long-lived tokens for clients that can't refresh (token broker)
231
457
 
232
458
  Supabase user JWTs expire (default 1h) and their refresh tokens rotate; a plain
@@ -253,6 +479,14 @@ Deno.serve(aiReady({ auth: broker.resolver, authRequired: true, writes: ["todos.
253
479
  failed refresh. By default the request then runs as the `anon` role (RLS
254
480
  decides). Set `authRequired: true` to answer `401` instead. Recommended.
255
481
  - A resolver that throws yields a 503.
482
+ - **Turn off the gateway's JWT check for the function**
483
+ (`[functions.mcp] verify_jwt = false` in `supabase/config.toml`, or
484
+ `supabase functions deploy mcp --no-verify-jwt`). Otherwise Supabase
485
+ rejects the `extb_...` token as `Invalid JWT` before your code runs. With
486
+ the check off, `authRequired: true` stops being optional.
487
+ - For the platform-hosted broker, use `createHostedAuthResolver` in place of
488
+ `broker.resolver`. `init` can set this up for you; see "Using the hosted
489
+ broker" above.
256
490
 
257
491
  **Deployment constraints** (not verified end to end, see HANDOVER):
258
492
 
@@ -311,7 +545,10 @@ PostgREST's OpenAPI document at all, so `generate` needs the same
311
545
  privileged key runtime introspection does. Nothing from the service role
312
546
  key ends up in the generated manifest — it's just table/column metadata.
313
547
 
314
- This writes `supabase/functions/mcp/exposify.manifest.json`. Load it in
548
+ This writes `supabase/functions/mcp/exposify.manifest.json`. A re-run only
549
+ rewrites it when the schema in it changed (a new `generatedAt` alone doesn't
550
+ count), so committing the manifest doesn't produce churn.
551
+ `generate --check` exits 1 if the manifest is missing or out of date. Load it in
315
552
  `aiReady({ manifest: ... })` — since the function runs on Deno, import it
316
553
  with a JSON import:
317
554
 
@@ -321,8 +558,33 @@ import manifest from "./exposify.manifest.json" with { type: "json" };
321
558
  Deno.serve(aiReady({ manifest, /* ...writes/rpc/functions... */ }));
322
559
  ```
323
560
 
561
+ ## What the function logs
562
+
563
+ Once per edge instance (cold start), after the first schema introspection,
564
+ the function prints whether MCP is up. It shows in `supabase functions serve`
565
+ locally and in the function's logs in production:
566
+
567
+ ```
568
+ [exposify-supabase] MCP ready at https://<ref>.supabase.co/functions/v1/mcp: 9 tools (5 query, 2 write, 1 rpc, search_docs), 3 docs indexed, auth: passthrough (caller's JWT)
569
+ ```
570
+
571
+ - **Manifest fallback:** the same line, marked "from the bundled manifest",
572
+ plus a warning with the fix.
573
+ - **Functions-only fallback:** `MCP failed to start: couldn't read the
574
+ database schema ...` with a fix line, since no database tools exist.
575
+ - **Missing `SUPABASE_URL`:** reported once at cold start.
576
+ - **With `oauth`:** the issuer's keys are fetched at cold start, and a
577
+ failure becomes a warning.
578
+
579
+ Failures and warnings always print. The ready line and the issuer check are
580
+ on by default and off under `NODE_ENV=test`; `startupLog: true | false`
581
+ overrides that.
582
+
324
583
  ## Troubleshooting
325
584
 
585
+ Start with `npx exposify-supabase doctor`. It checks most of what's below
586
+ and names the fix.
587
+
326
588
  **"exposify_meta() is not reachable" / "PGRST202" errors in function logs.**
327
589
  The migration hasn't been applied to this database. Run `supabase db push`
328
590
  (or apply `sql/exposify_meta.sql` from this package directly), then