@exposify/supabase 0.3.7 → 0.3.9
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/README.md +283 -72
- package/dist/cli.js +1144 -279
- package/dist/index.cjs +172 -12
- package/dist/index.d.cts +54 -4
- package/dist/index.d.ts +54 -4
- package/dist/index.js +166 -10
- package/package.json +3 -2
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
|
|
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
|
|
41
|
-
|
|
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,36 +100,84 @@ 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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
112
159
|
|
|
113
160
|
### Using the hosted broker
|
|
114
161
|
|
|
115
|
-
If you use the Exposify hosted broker (the dashboard's "Hosted broker" card),
|
|
116
162
|
`init` asks first:
|
|
117
163
|
|
|
118
164
|
```
|
|
119
|
-
|
|
165
|
+
Let agents log in as your users through the Exposify hosted broker (they get a long-lived connection instead of an hourly Supabase JWT)? [y/N]
|
|
120
166
|
```
|
|
121
167
|
|
|
122
|
-
Or skip the question with `--broker-url <url
|
|
123
|
-
|
|
124
|
-
non-interactively. Saying yes does three
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
`authRequired: true` to `aiReady()`.
|
|
129
|
-
`
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
168
|
+
Or skip the question with `--broker` (`--broker-url <url>`, or
|
|
169
|
+
`EXPOSIFY_BROKER_URL`, also points it at a different broker, for local
|
|
170
|
+
development), which also works non-interactively. Saying yes does three
|
|
171
|
+
things:
|
|
172
|
+
|
|
173
|
+
1. **Wires the broker into the function.** Adds `auth: hostedBroker()` and
|
|
174
|
+
`authRequired: true` to `aiReady()`. `hostedBroker()` reads the
|
|
175
|
+
`EXPOSIFY_KEY` secret, which is never written into the file. This goes in
|
|
176
|
+
the managed block, so an existing `index.ts` gets it too, unless its block
|
|
177
|
+
was hand-edited. In that case `init` prints the block for you to merge,
|
|
178
|
+
and leaves `verify_jwt` alone until you have.
|
|
179
|
+
2. **Sets `verify_jwt = false` for `mcp` in `supabase/config.toml`.** A
|
|
180
|
+
broker token isn't a Supabase JWT, so with the gateway's check on, every request
|
|
134
181
|
fails with `{"code":"UNAUTHORIZED_ASYMMETRIC_JWT","message":"Invalid JWT"}`
|
|
135
182
|
before the function runs. The setting goes in config.toml and not just
|
|
136
183
|
on the deploy command line, because a later plain
|
|
@@ -139,13 +186,14 @@ non-interactively. Saying yes does three things:
|
|
|
139
186
|
that have no valid broker token. Otherwise those requests would run as
|
|
140
187
|
`anon`.
|
|
141
188
|
3. **Deploys with `--no-verify-jwt`**, and adds
|
|
142
|
-
`supabase secrets set
|
|
143
|
-
`init` never sees the
|
|
189
|
+
`supabase secrets set EXPOSIFY_KEY=...` to the printed steps.
|
|
190
|
+
`init` never sees the key, so you set that secret yourself.
|
|
144
191
|
Until you do, every request gets a 401.
|
|
145
192
|
|
|
146
|
-
MCP clients then
|
|
147
|
-
|
|
148
|
-
|
|
193
|
+
MCP clients then click Connect and sign in through the broker (or send a
|
|
194
|
+
pasted `Authorization: Bearer extb_...`). Don't give the client the JWT that
|
|
195
|
+
`POST /resolve` returns. It's the short-lived Supabase token the function
|
|
196
|
+
swaps in on each request, and it stops working when it expires.
|
|
149
197
|
|
|
150
198
|
## 2. Decide what's exposed
|
|
151
199
|
|
|
@@ -157,19 +205,24 @@ invisible, full stop, with no override. For each exposed table you get a
|
|
|
157
205
|
column list, `order`, and a `limit` capped server-side (100 rows by default,
|
|
158
206
|
regardless of what the caller asks for).
|
|
159
207
|
|
|
160
|
-
Writes and Postgres functions are **opt-in
|
|
161
|
-
`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:
|
|
162
211
|
|
|
163
212
|
```ts
|
|
213
|
+
// exposify:begin -- generated by `npx exposify-supabase init`, ...
|
|
164
214
|
import { aiReady } from "npm:@exposify/supabase";
|
|
165
215
|
|
|
166
|
-
|
|
216
|
+
const generated = {
|
|
167
217
|
// "<table>.insert" | "<table>.update" | "<table>.delete" | "<table>.*"
|
|
168
218
|
writes: ["orders.insert", "orders.update"],
|
|
169
|
-
|
|
170
219
|
// Postgres functions exposed as rpc_<fn> tools
|
|
171
220
|
rpc: ["place_order"],
|
|
221
|
+
};
|
|
222
|
+
// exposify:end
|
|
172
223
|
|
|
224
|
+
Deno.serve(aiReady({
|
|
225
|
+
...generated,
|
|
173
226
|
// Sibling edge functions, invoked via fetch with the caller's forwarded JWT
|
|
174
227
|
functions: {
|
|
175
228
|
refund: {
|
|
@@ -201,7 +254,7 @@ Other options you can pass to `aiReady()`:
|
|
|
201
254
|
| Option | Default | What it does |
|
|
202
255
|
|---|---|---|
|
|
203
256
|
| `docs` | `["docs/**/*.md"]` | Glob(s), relative to the function directory, bundled into the knowledge base |
|
|
204
|
-
| `docsAuth` | `"public"` | `"required"` hides `search_docs` until the caller sends *any* `Authorization` header (presence-only, not validated) |
|
|
257
|
+
| `docsAuth` | `"public"` (`"required"` with the hosted broker) | `"required"` hides `search_docs` until the caller sends *any* `Authorization` header (presence-only, not validated) |
|
|
205
258
|
| `maxRows` | `100` | Hard cap applied to every `query_<table>` call |
|
|
206
259
|
| `rateLimit` / `rateWindowMs` | `60` / `60000` | In-memory, per-instance, best-effort rate limit — **not** distributed across edge instances; real limits belong to Supabase's own gateway |
|
|
207
260
|
| `supabaseUrl` / `anonKey` / `serviceRoleKey` | read from `SUPABASE_URL` / `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` env vars | Override for local dev/testing; in a deployed function all three env vars are automatic. `serviceRoleKey` is used only for schema introspection — see below. |
|
|
@@ -253,7 +306,9 @@ unless you're editing `sql/exposify_meta.sql` directly).
|
|
|
253
306
|
## 4. Connect an MCP client
|
|
254
307
|
|
|
255
308
|
Point any MCP client at the function's URL with a real user's JWT as the
|
|
256
|
-
bearer token
|
|
309
|
+
bearer token (or set up OAuth or the hosted broker, below, so the client logs
|
|
310
|
+
in by itself) — for
|
|
311
|
+
example, with the Claude Code CLI:
|
|
257
312
|
|
|
258
313
|
```bash
|
|
259
314
|
claude mcp add --transport http my-app https://<project-ref>.supabase.co/functions/v1/mcp \
|
|
@@ -264,29 +319,174 @@ Ask the agent to query or mutate data. **It should succeed for that user's
|
|
|
264
319
|
own rows and be refused everyone else's** — that's the definition of done:
|
|
265
320
|
RLS enforcement, not anything in this SDK, is what makes that true.
|
|
266
321
|
|
|
267
|
-
##
|
|
322
|
+
## 5. Check it: `doctor`
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
npx exposify-supabase doctor # finds the project the same way init does
|
|
326
|
+
npx exposify-supabase doctor --token <user-jwt | extb_...>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A read-only health check of everything `init` sets up. It never writes a file
|
|
330
|
+
and never runs a mutating Supabase CLI command. Each check prints ✓ (pass),
|
|
331
|
+
! (warning) or ✗ (failure), and warnings and failures come with the fix. It
|
|
332
|
+
exits 1 if anything failed, so you can run it in CI.
|
|
333
|
+
|
|
334
|
+
- **Project:** `supabase/config.toml`, the Supabase CLI, the linked project.
|
|
335
|
+
- **Local files:** the `exposify_meta()` migration and `functions/mcp/index.ts`,
|
|
336
|
+
and which auth that file uses. With the hosted broker or OAuth, it checks
|
|
337
|
+
that `verify_jwt = false` for `mcp`.
|
|
338
|
+
- **Database:** `exposify_meta()` is applied and the PostgREST schema is
|
|
339
|
+
readable. It lists which tables became tools, and warns about tables that
|
|
340
|
+
never will: RLS off, or RLS on but no API grants. It also warns about
|
|
341
|
+
tables whose missing comments leave tools with thin descriptions.
|
|
342
|
+
- **Deployed function:** speaks MCP to `<project>/functions/v1/mcp`
|
|
343
|
+
(`initialize`, then `tools/list`) and reports the tool count. If the
|
|
344
|
+
function registered `exposify://diagnostics`, which it only does when
|
|
345
|
+
degraded, doctor prints that too. With the broker, it also checks that the
|
|
346
|
+
`EXPOSIFY_KEY` secret exists (`supabase secrets list`).
|
|
347
|
+
|
|
348
|
+
**How the function check authenticates:** it uses `--token` if you pass one.
|
|
349
|
+
Otherwise it sends no token. If the gateway rejects that (`verify_jwt` on),
|
|
350
|
+
it retries once with the legacy anon key, when one was discovered or set in
|
|
351
|
+
`SUPABASE_ANON_KEY`. Tools come from service-role introspection, so the anon
|
|
352
|
+
role is enough to list them. A function with `authRequired` answers 401 to
|
|
353
|
+
both, and doctor reports that as a warning asking for `--token`.
|
|
354
|
+
|
|
355
|
+
## "Click Connect and log in" with Supabase Auth (OAuth)
|
|
356
|
+
|
|
357
|
+
Instead of pasting a JWT, MCP clients can log users in through Supabase
|
|
358
|
+
Auth's OAuth 2.1 server:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
// supabase/functions/mcp/index.ts
|
|
362
|
+
import { aiReady, supabaseAuth } from "npm:@exposify/supabase";
|
|
363
|
+
|
|
364
|
+
Deno.serve(aiReady({ oauth: supabaseAuth(), writes: ["todos.*"] }));
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`init --oauth` (or answering yes to its "sign in with Supabase Auth?"
|
|
368
|
+
question) writes this for you and handles `verify_jwt`. The user adds just
|
|
369
|
+
the function URL to Claude, Cursor, etc. The first request gets a `401`
|
|
370
|
+
whose `WWW-Authenticate` points at Protected Resource Metadata served under
|
|
371
|
+
the function's own URL (`…/functions/v1/mcp/.well-known/oauth-protected-resource`).
|
|
372
|
+
The client then discovers Supabase Auth, registers, opens your consent page,
|
|
373
|
+
and refreshes tokens later on its own. The validated token is an ordinary
|
|
374
|
+
project JWT, forwarded to PostgREST unchanged, so **RLS runs as the
|
|
375
|
+
signed-in user** exactly as with a pasted JWT.
|
|
376
|
+
|
|
377
|
+
Project setup (dashboard, **Authentication > OAuth Server**, beta):
|
|
378
|
+
|
|
379
|
+
- Enable the OAuth 2.1 server and **dynamic client registration**.
|
|
380
|
+
- Set the **authorization path** to a consent page your app hosts, built
|
|
381
|
+
with `supabase.auth.oauth.getAuthorizationDetails()` /
|
|
382
|
+
`approveAuthorization()`. Supabase doesn't host one for you.
|
|
383
|
+
- Use **asymmetric JWT signing keys**. `supabaseAuth()` verifies tokens
|
|
384
|
+
against `/auth/v1/.well-known/jwks.json`, and legacy HS256 projects have no
|
|
385
|
+
keys there.
|
|
386
|
+
- **Turn off the gateway's JWT check** (`verify_jwt = false`). Otherwise the
|
|
387
|
+
gateway answers the first tokenless request with its own `401`, which has no
|
|
388
|
+
`resource_metadata`, and discovery never starts. The `oauth` gate checks
|
|
389
|
+
every token itself.
|
|
390
|
+
|
|
391
|
+
Notes:
|
|
392
|
+
|
|
393
|
+
- Supabase Auth always issues `aud: "authenticated"` and can't bind a
|
|
394
|
+
token to the function's URL, so `supabaseAuth()` accepts that audience (a
|
|
395
|
+
startup warning says so). In practice any valid user JWT of the project
|
|
396
|
+
is accepted, which is exactly what RLS already trusts.
|
|
397
|
+
- `resource` defaults to `${SUPABASE_URL}/functions/v1/<function directory>`.
|
|
398
|
+
Pass `supabaseAuth({ resource })` if you serve the function under a
|
|
399
|
+
custom domain.
|
|
400
|
+
- Any other spec-compliant AS works with plain options:
|
|
401
|
+
`oauth: { issuer, resource, scopes?, audience?, jwksUri?, verify? }`
|
|
402
|
+
(see the `@exposify/express` README).
|
|
403
|
+
- By default PRM is only reachable through the `401` header (an edge
|
|
404
|
+
function can't serve the project domain's root `/.well-known/`, and
|
|
405
|
+
Supabase's custom-domain add-on doesn't change that; the root still
|
|
406
|
+
belongs to Supabase). MCP clients are required to prefer that header, so
|
|
407
|
+
this only fails with clients that probe well-known URIs and ignore it. If
|
|
408
|
+
you hit one, or want a branded URL anyway, see "Serving it from your own
|
|
409
|
+
domain" below.
|
|
410
|
+
- `oauth` can't be combined with `auth`/`authRequired` (the broker) or
|
|
411
|
+
`docsAuth: "public"`; both throw at startup.
|
|
412
|
+
- Not yet verified against a live Supabase project with a real MCP client;
|
|
413
|
+
see HANDOVER.
|
|
414
|
+
|
|
415
|
+
### Serving it from your own domain (optional)
|
|
416
|
+
|
|
417
|
+
To give clients the standard root-level well-known URI, put a proxy you
|
|
418
|
+
control in front of the function, e.g. a Cloudflare Worker on
|
|
419
|
+
`mcp.acme.com`:
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
const FN = "https://<project-ref>.supabase.co/functions/v1/mcp";
|
|
423
|
+
|
|
424
|
+
export default {
|
|
425
|
+
async fetch(req) {
|
|
426
|
+
const { pathname, search } = new URL(req.url);
|
|
427
|
+
let target;
|
|
428
|
+
if (pathname === "/.well-known/oauth-protected-resource/mcp") {
|
|
429
|
+
target = `${FN}/.well-known/oauth-protected-resource`;
|
|
430
|
+
} else if (pathname === "/mcp" || pathname.startsWith("/mcp/")) {
|
|
431
|
+
target = FN + pathname.slice("/mcp".length) + search;
|
|
432
|
+
} else {
|
|
433
|
+
return new Response("not found", { status: 404 });
|
|
434
|
+
}
|
|
435
|
+
return fetch(new Request(target, req));
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
and tell the function its public address:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
oauth: supabaseAuth({
|
|
444
|
+
resource: "https://mcp.acme.com/mcp", // what clients connect to = token audience
|
|
445
|
+
metadataUrl: "https://mcp.acme.com/.well-known/oauth-protected-resource/mcp",
|
|
446
|
+
}),
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The PRM now reports the proxy URL, the `401` points at the root-level URI,
|
|
450
|
+
and the function answers the forwarded request (it serves PRM on any path
|
|
451
|
+
ending in `/.well-known/oauth-protected-resource`). Caveat: the built-in
|
|
452
|
+
per-IP rate limiter keys on `x-forwarded-for`, which Supabase's gateway
|
|
453
|
+
will likely set to the proxy's address, so every client could share one
|
|
454
|
+
60-requests/minute bucket. Rate-limit at the proxy and raise `rateLimit` on
|
|
455
|
+
the function. This setup is untested against a real deployment.
|
|
456
|
+
|
|
457
|
+
## Long-lived tokens for clients that can't refresh (the hosted broker)
|
|
268
458
|
|
|
269
459
|
Supabase user JWTs expire (default 1h) and their refresh tokens rotate; a plain
|
|
270
|
-
MCP client can hold a bearer token but can't refresh it.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
460
|
+
MCP client can hold a bearer token but can't refresh it. Exposify's hosted
|
|
461
|
+
broker holds each user's session, refreshes it, and swaps the agent's
|
|
462
|
+
long-lived connection token (`extb_...`) for a live Supabase JWT on every
|
|
463
|
+
call ([`docs/hosted-broker.md`](../../docs/hosted-broker.md)). `init` sets it
|
|
464
|
+
up (see "Using the hosted broker" above); by hand:
|
|
465
|
+
|
|
466
|
+
```sh
|
|
467
|
+
supabase secrets set EXPOSIFY_KEY=exk_... # the project's secret key from the dashboard
|
|
468
|
+
```
|
|
274
469
|
|
|
275
470
|
```ts
|
|
276
471
|
// supabase/functions/mcp/index.ts
|
|
277
|
-
import { aiReady } from "npm:@exposify/supabase";
|
|
278
|
-
import { broker } from "./broker.ts"; // createTokenBroker(...) over a shared store
|
|
472
|
+
import { aiReady, hostedBroker } from "npm:@exposify/supabase";
|
|
279
473
|
|
|
280
|
-
Deno.serve(aiReady({ auth:
|
|
474
|
+
Deno.serve(aiReady({ auth: hostedBroker(), authRequired: true, writes: ["todos.*"] }));
|
|
281
475
|
```
|
|
282
476
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
477
|
+
With the `EXPOSIFY_KEY` secret set, `aiReady()` uses the broker even without
|
|
478
|
+
`auth`, and `authRequired` defaults to on; `hostedBroker()` just says so in
|
|
479
|
+
the code, and never throws (a missing secret refuses every call with one
|
|
480
|
+
warning).
|
|
481
|
+
|
|
482
|
+
- `auth` takes any resolver (`(incoming: Headers) => Headers | Promise<Headers>`).
|
|
483
|
+
It receives the request's original headers (the `extb_...` token) and its
|
|
484
|
+
result **replaces** them for PostgREST and `functions` calls, so RLS applies
|
|
485
|
+
as that user and the connection token is never forwarded. `apikey` and a
|
|
286
486
|
missing `authorization` still fall back to the anon key.
|
|
287
|
-
- `docsAuth: "required"` checks the raw incoming headers, so a
|
|
288
|
-
counts as authenticated.
|
|
289
|
-
-
|
|
487
|
+
- `docsAuth: "required"` checks the raw incoming headers, so a connection
|
|
488
|
+
token counts as authenticated.
|
|
489
|
+
- The resolver returns empty headers for an unknown or revoked token or a
|
|
290
490
|
failed refresh. By default the request then runs as the `anon` role (RLS
|
|
291
491
|
decides). Set `authRequired: true` to answer `401` instead. Recommended.
|
|
292
492
|
- A resolver that throws yields a 503.
|
|
@@ -294,24 +494,7 @@ Deno.serve(aiReady({ auth: broker.resolver, authRequired: true, writes: ["todos.
|
|
|
294
494
|
(`[functions.mcp] verify_jwt = false` in `supabase/config.toml`, or
|
|
295
495
|
`supabase functions deploy mcp --no-verify-jwt`). Otherwise Supabase
|
|
296
496
|
rejects the `extb_...` token as `Invalid JWT` before your code runs. With
|
|
297
|
-
the check off, `authRequired: true` stops being optional.
|
|
298
|
-
- For the platform-hosted broker, use `createHostedAuthResolver` in place of
|
|
299
|
-
`broker.resolver`. `init` can set this up for you; see "Using the hosted
|
|
300
|
-
broker" above.
|
|
301
|
-
|
|
302
|
-
**Deployment constraints** (not verified end to end, see HANDOVER):
|
|
303
|
-
|
|
304
|
-
- The broker's OAuth server (`listenTokenBroker`) is a Node HTTP server and
|
|
305
|
-
can't run in an Edge Function; run it as a separate service.
|
|
306
|
-
- The edge function builds its own `createTokenBroker(...)` over the **same
|
|
307
|
-
persistent `TokenStore`** (Redis/Postgres, see the token-broker README's
|
|
308
|
-
`ioredis` example) and the same `TOKEN_BROKER_ENCRYPTION_KEY`. The in-memory
|
|
309
|
-
store is per-process and won't work.
|
|
310
|
-
- The broker service does proactive refresh; short-lived edge instances rely on
|
|
311
|
-
the resolver's lazy refresh when `expiresAt` has passed.
|
|
312
|
-
- `@exposify/token-broker` imports `node:crypto` (`scryptSync`, AES-256-GCM,
|
|
313
|
-
RS256). Whether that works under Supabase's Deno runtime has **not** been
|
|
314
|
-
tested; try `supabase functions serve` before depending on it.
|
|
497
|
+
the check off, `authRequired: true` stops being optional. `init` does both.
|
|
315
498
|
|
|
316
499
|
## Knowledge base
|
|
317
500
|
|
|
@@ -356,7 +539,10 @@ PostgREST's OpenAPI document at all, so `generate` needs the same
|
|
|
356
539
|
privileged key runtime introspection does. Nothing from the service role
|
|
357
540
|
key ends up in the generated manifest — it's just table/column metadata.
|
|
358
541
|
|
|
359
|
-
This writes `supabase/functions/mcp/exposify.manifest.json`.
|
|
542
|
+
This writes `supabase/functions/mcp/exposify.manifest.json`. A re-run only
|
|
543
|
+
rewrites it when the schema in it changed (a new `generatedAt` alone doesn't
|
|
544
|
+
count), so committing the manifest doesn't produce churn.
|
|
545
|
+
`generate --check` exits 1 if the manifest is missing or out of date. Load it in
|
|
360
546
|
`aiReady({ manifest: ... })` — since the function runs on Deno, import it
|
|
361
547
|
with a JSON import:
|
|
362
548
|
|
|
@@ -366,8 +552,33 @@ import manifest from "./exposify.manifest.json" with { type: "json" };
|
|
|
366
552
|
Deno.serve(aiReady({ manifest, /* ...writes/rpc/functions... */ }));
|
|
367
553
|
```
|
|
368
554
|
|
|
555
|
+
## What the function logs
|
|
556
|
+
|
|
557
|
+
Once per edge instance (cold start), after the first schema introspection,
|
|
558
|
+
the function prints whether MCP is up. It shows in `supabase functions serve`
|
|
559
|
+
locally and in the function's logs in production:
|
|
560
|
+
|
|
561
|
+
```
|
|
562
|
+
[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)
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
- **Manifest fallback:** the same line, marked "from the bundled manifest",
|
|
566
|
+
plus a warning with the fix.
|
|
567
|
+
- **Functions-only fallback:** `MCP failed to start: couldn't read the
|
|
568
|
+
database schema ...` with a fix line, since no database tools exist.
|
|
569
|
+
- **Missing `SUPABASE_URL`:** reported once at cold start.
|
|
570
|
+
- **With `oauth`:** the issuer's keys are fetched at cold start, and a
|
|
571
|
+
failure becomes a warning.
|
|
572
|
+
|
|
573
|
+
Failures and warnings always print. The ready line and the issuer check are
|
|
574
|
+
on by default and off under `NODE_ENV=test`; `startupLog: true | false`
|
|
575
|
+
overrides that.
|
|
576
|
+
|
|
369
577
|
## Troubleshooting
|
|
370
578
|
|
|
579
|
+
Start with `npx exposify-supabase doctor`. It checks most of what's below
|
|
580
|
+
and names the fix.
|
|
581
|
+
|
|
371
582
|
**"exposify_meta() is not reachable" / "PGRST202" errors in function logs.**
|
|
372
583
|
The migration hasn't been applied to this database. Run `supabase db push`
|
|
373
584
|
(or apply `sql/exposify_meta.sql` from this package directly), then
|