@exposify/supabase 0.2.0 → 0.3.1
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 +61 -14
- package/dist/cli.js +94 -26
- package/dist/index.cjs +10 -2
- package/dist/index.d.cts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +10 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,7 +26,10 @@ From your Supabase project's root (the directory containing `supabase/`):
|
|
|
26
26
|
npx exposify-supabase init
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
This
|
|
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:
|
|
30
33
|
|
|
31
34
|
- `supabase/migrations/<timestamp>_exposify_meta.sql` — a SQL migration that
|
|
32
35
|
adds `exposify_meta()`, a `SECURITY DEFINER` function returning, per table,
|
|
@@ -39,6 +42,10 @@ This writes two things and never overwrites an existing function file:
|
|
|
39
42
|
- `supabase/functions/mcp/docs/` — an empty directory for optional bundled
|
|
40
43
|
docs (see "Knowledge base" below).
|
|
41
44
|
|
|
45
|
+
If you run it somewhere that doesn't look like a Supabase project root (no
|
|
46
|
+
`supabase/config.toml`), it warns but still proceeds — worth double-checking
|
|
47
|
+
you're in the directory you think you are before continuing.
|
|
48
|
+
|
|
42
49
|
`init` is interactive from there (skips straight to the old fixed behavior
|
|
43
50
|
below if stdin isn't a real terminal — CI, a script, piped input). If the
|
|
44
51
|
Supabase CLI is on your `PATH`, it asks:
|
|
@@ -97,10 +104,11 @@ you to hand-edit `writes`/`rpc`/comments yourself:
|
|
|
97
104
|
If `supabase/functions/mcp/index.ts` already exists, none of this touches
|
|
98
105
|
it — your selections are printed as a config snippet to merge in by hand
|
|
99
106
|
instead, and the documentation migration still gets written either way.
|
|
100
|
-
Connects using `SUPABASE_URL`/`
|
|
101
|
-
flags
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
104
112
|
|
|
105
113
|
## 2. Decide what's exposed
|
|
106
114
|
|
|
@@ -159,7 +167,34 @@ Other options you can pass to `aiReady()`:
|
|
|
159
167
|
| `docsAuth` | `"public"` | `"required"` hides `search_docs` until the caller sends *any* `Authorization` header (presence-only, not validated) |
|
|
160
168
|
| `maxRows` | `100` | Hard cap applied to every `query_<table>` call |
|
|
161
169
|
| `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 |
|
|
162
|
-
| `supabaseUrl` / `anonKey` | read from `SUPABASE_URL` / `SUPABASE_ANON_KEY` env vars | Override for local dev/testing; in a deployed function
|
|
170
|
+
| `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. |
|
|
171
|
+
|
|
172
|
+
### Why introspection needs the service role key
|
|
173
|
+
|
|
174
|
+
Every actual data call (`query_<table>`, `insert_<table>`, `rpc_<fn>`, ...)
|
|
175
|
+
uses the caller's own forwarded JWT, or the anon key as its fallback for an
|
|
176
|
+
unauthenticated caller — **that part is unaffected by any of this** and is
|
|
177
|
+
still what makes RLS the real authorization layer.
|
|
178
|
+
|
|
179
|
+
Schema introspection is different: fetching PostgREST's OpenAPI document (the
|
|
180
|
+
one-time-per-instance call that discovers which tables/columns exist) has to
|
|
181
|
+
use `SUPABASE_SERVICE_ROLE_KEY`. Supabase's gateway rejects that specific
|
|
182
|
+
request outright for an anon or publishable-tier key —
|
|
183
|
+
`{"message":"Invalid API key","hint":"Only the service_role API key can be
|
|
184
|
+
used for this endpoint."}` — regardless of RLS policies or table grants, and
|
|
185
|
+
regardless of whether you're using a legacy JWT-format anon key or a newer
|
|
186
|
+
`sb_publishable_...` key. This isn't documented anywhere in Supabase's own
|
|
187
|
+
docs as far as we've found; it was discovered by deploying this SDK against
|
|
188
|
+
a real project. The service role key never leaves the function and is never
|
|
189
|
+
used for anything except this schema-shape discovery — it doesn't touch row
|
|
190
|
+
data, and RLS still fully governs every actual tool call.
|
|
191
|
+
|
|
192
|
+
If you deploy with `supabase functions deploy` this just works — Supabase
|
|
193
|
+
auto-injects `SUPABASE_SERVICE_ROLE_KEY` into every edge function already.
|
|
194
|
+
The only place you'd ever need to supply it yourself is local dev/testing
|
|
195
|
+
(the `serviceRoleKey` option above) or the CLI's `init`/`generate` (see
|
|
196
|
+
their own sections), which run outside the deployed function and need it
|
|
197
|
+
passed explicitly.
|
|
163
198
|
|
|
164
199
|
## 3. Apply the migration and deploy
|
|
165
200
|
|
|
@@ -224,15 +259,16 @@ project (local `supabase start` or linked):
|
|
|
224
259
|
|
|
225
260
|
```bash
|
|
226
261
|
SUPABASE_URL=http://localhost:54321 \
|
|
227
|
-
|
|
262
|
+
SUPABASE_SERVICE_ROLE_KEY=<service-role-key> \
|
|
228
263
|
npx exposify-supabase generate
|
|
229
264
|
```
|
|
230
265
|
|
|
231
|
-
Uses the **
|
|
232
|
-
itself uses (see "
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
266
|
+
Uses the **service role key**, deliberately the same key runtime
|
|
267
|
+
introspection itself uses (see "Why introspection needs the service role
|
|
268
|
+
key" above, and "Fallback ladder" below) — the anon key can't fetch
|
|
269
|
+
PostgREST's OpenAPI document at all, so `generate` needs the same
|
|
270
|
+
privileged key runtime introspection does. Nothing from the service role
|
|
271
|
+
key ends up in the generated manifest — it's just table/column metadata.
|
|
236
272
|
|
|
237
273
|
This writes `supabase/functions/mcp/exposify.manifest.json`. Load it in
|
|
238
274
|
`aiReady({ manifest: ... })` — since the function runs on Deno, import it
|
|
@@ -272,9 +308,20 @@ the function.
|
|
|
272
308
|
|
|
273
309
|
**`init` just scaffolds and asks to deploy — it never asks about writes/RPCs/
|
|
274
310
|
documentation.** The wizard only runs when `init` can actually reach the
|
|
275
|
-
project (`SUPABASE_URL`/`
|
|
276
|
-
flags
|
|
311
|
+
project (`SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY` env vars, or `--url`/`--key`
|
|
312
|
+
flags — the service role key, not the anon key, for the same reason described
|
|
313
|
+
above — pointing at a project where the migration is already applied). On a
|
|
277
314
|
brand-new project's first `init`, that's not true yet — say yes to the
|
|
278
315
|
deploy prompt (or run `supabase db push` yourself) and the wizard picks up
|
|
279
316
|
automatically once the migration is live, in the same run if you said yes,
|
|
280
317
|
or on your next `exposify-supabase init` otherwise.
|
|
318
|
+
|
|
319
|
+
**`PostgREST OpenAPI fetch failed: HTTP 401`, and the error body (if you
|
|
320
|
+
curl it directly) says `"Only the service_role API key can be used for this
|
|
321
|
+
endpoint"`.** This is expected if `SUPABASE_SERVICE_ROLE_KEY` isn't
|
|
322
|
+
reaching the function — see "Why introspection needs the service role key"
|
|
323
|
+
above. On a deployed function this should be automatic; if you're still
|
|
324
|
+
seeing this after a `supabase functions deploy`, check `supabase secrets
|
|
325
|
+
list` for a stale explicit `SUPABASE_SERVICE_ROLE_KEY` override, and confirm
|
|
326
|
+
you're not accidentally passing an anon/publishable key as `serviceRoleKey`
|
|
327
|
+
in local dev.
|
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/cli.ts
|
|
4
|
-
import { mkdir, readFile, writeFile, access } from "fs/promises";
|
|
4
|
+
import { mkdir, readFile, writeFile, access, readdir } from "fs/promises";
|
|
5
5
|
import { dirname, join } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { execFileSync } from "child_process";
|
|
8
8
|
import { createInterface } from "readline/promises";
|
|
9
9
|
|
|
10
10
|
// src/fetch-openapi.ts
|
|
11
|
+
var WRONG_KEY_TIER_RE = /service_role|secret api key/i;
|
|
11
12
|
async function fetchOpenApi(postgrestUrl, headers, fetchImpl) {
|
|
12
13
|
const res = await fetchImpl(postgrestUrl, {
|
|
13
14
|
headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
|
|
14
15
|
});
|
|
15
16
|
if (!res.ok) {
|
|
16
|
-
|
|
17
|
+
const body = await res.text().catch(() => "");
|
|
18
|
+
if (res.status === 401 && WRONG_KEY_TIER_RE.test(body)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`PostgREST OpenAPI fetch failed: HTTP 401 -- Supabase rejected this request because it wasn't made with the service role key (response: "${body.trim()}"). Schema introspection always needs SUPABASE_SERVICE_ROLE_KEY, never the anon key -- see this package's README, "Why introspection needs the service role key". In a deployed function this is auto-injected automatically; for local dev/testing pass { serviceRoleKey } explicitly (or --key for the CLI).`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}${body ? `: ${body}` : ""}`);
|
|
17
24
|
}
|
|
18
25
|
return await res.json();
|
|
19
26
|
}
|
|
@@ -218,10 +225,41 @@ function buildFunctionTemplate(opts = {}) {
|
|
|
218
225
|
);
|
|
219
226
|
return lines.join("\n");
|
|
220
227
|
}
|
|
228
|
+
function findExistingMigration(filenames, suffix) {
|
|
229
|
+
const matches = filenames.filter((f) => f.endsWith(suffix)).sort();
|
|
230
|
+
return matches[0] ?? null;
|
|
231
|
+
}
|
|
232
|
+
function uniqueMigrationFilename(existing, desired) {
|
|
233
|
+
if (!existing.includes(desired)) return desired;
|
|
234
|
+
const dot = desired.lastIndexOf(".");
|
|
235
|
+
const base = dot === -1 ? desired : desired.slice(0, dot);
|
|
236
|
+
const ext = dot === -1 ? "" : desired.slice(dot);
|
|
237
|
+
let i = 2;
|
|
238
|
+
let candidate = `${base}-${i}${ext}`;
|
|
239
|
+
while (existing.includes(candidate)) candidate = `${base}-${++i}${ext}`;
|
|
240
|
+
return candidate;
|
|
241
|
+
}
|
|
242
|
+
function partialConnectionWarning(url, key) {
|
|
243
|
+
if (url && !key) return "SUPABASE_URL is set but SUPABASE_SERVICE_ROLE_KEY (or --key) is not -- both are required to introspect.";
|
|
244
|
+
if (!url && key) return "SUPABASE_SERVICE_ROLE_KEY (or --key) is set but SUPABASE_URL is not -- both are required to introspect.";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
221
247
|
|
|
222
248
|
// bin/cli.ts
|
|
223
249
|
var PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
224
250
|
var FUNCTION_DIR = "supabase/functions/mcp";
|
|
251
|
+
var MIGRATIONS_DIR = "supabase/migrations";
|
|
252
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
253
|
+
function fetchWithTimeout(input, init) {
|
|
254
|
+
return fetch(input, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
255
|
+
}
|
|
256
|
+
async function listFilesOrEmpty(dir) {
|
|
257
|
+
try {
|
|
258
|
+
return await readdir(dir);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
225
263
|
async function exists(path) {
|
|
226
264
|
try {
|
|
227
265
|
await access(path);
|
|
@@ -274,27 +312,33 @@ function pushAndDeploy() {
|
|
|
274
312
|
}
|
|
275
313
|
function resolveConnectionInfo() {
|
|
276
314
|
const url = process.env.SUPABASE_URL ?? argValue("--url");
|
|
277
|
-
const
|
|
278
|
-
if (!url || !
|
|
279
|
-
return { url,
|
|
315
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key");
|
|
316
|
+
if (!url || !serviceRoleKey) return null;
|
|
317
|
+
return { url, serviceRoleKey };
|
|
280
318
|
}
|
|
281
319
|
async function tryIntrospect(conn) {
|
|
282
320
|
try {
|
|
283
321
|
const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
|
|
284
|
-
const headers = new Headers({ authorization: `Bearer ${conn.
|
|
322
|
+
const headers = new Headers({ authorization: `Bearer ${conn.serviceRoleKey}`, apikey: conn.serviceRoleKey });
|
|
285
323
|
const [openapi, metaRows] = await Promise.all([
|
|
286
|
-
fetchOpenApi(postgrestUrl, headers,
|
|
287
|
-
fetchExposifyMeta(postgrestUrl, headers,
|
|
324
|
+
fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
|
|
325
|
+
fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
|
|
288
326
|
]);
|
|
289
327
|
return { tables: introspectTables(openapi, metaRows), openapi };
|
|
290
|
-
} catch {
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (!(err instanceof MissingExposifyMetaError)) {
|
|
330
|
+
console.log(`
|
|
331
|
+
Couldn't introspect the project: ${err instanceof Error ? err.message : String(err)}`);
|
|
332
|
+
}
|
|
291
333
|
return null;
|
|
292
334
|
}
|
|
293
335
|
}
|
|
294
336
|
async function tryDetectAnonymousSignIn(conn) {
|
|
295
337
|
try {
|
|
296
338
|
const url = `${conn.url.replace(/\/+$/, "")}/auth/v1/settings`;
|
|
297
|
-
const res = await
|
|
339
|
+
const res = await fetchWithTimeout(url, {
|
|
340
|
+
headers: { apikey: conn.serviceRoleKey, authorization: `Bearer ${conn.serviceRoleKey}` }
|
|
341
|
+
});
|
|
298
342
|
if (!res.ok) return null;
|
|
299
343
|
return parseAnonymousSignInSetting(await res.json());
|
|
300
344
|
} catch {
|
|
@@ -369,23 +413,44 @@ ${missing.length} table(s) are missing documentation MCP tools would otherwise s
|
|
|
369
413
|
async function writeCommentsMigration(entries) {
|
|
370
414
|
const sql = buildCommentStatements(entries);
|
|
371
415
|
if (!sql) return null;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
416
|
+
await mkdir(MIGRATIONS_DIR, { recursive: true });
|
|
417
|
+
const existing = await listFilesOrEmpty(MIGRATIONS_DIR);
|
|
418
|
+
const filename = uniqueMigrationFilename(existing, `${timestamp()}_exposify_comments.sql`);
|
|
419
|
+
const path = join(MIGRATIONS_DIR, filename);
|
|
375
420
|
await writeFile(path, sql);
|
|
376
421
|
return path;
|
|
377
422
|
}
|
|
378
|
-
async function
|
|
423
|
+
async function ensureMetaMigration() {
|
|
424
|
+
await mkdir(MIGRATIONS_DIR, { recursive: true });
|
|
425
|
+
const existing = await listFilesOrEmpty(MIGRATIONS_DIR);
|
|
426
|
+
const found = findExistingMigration(existing, "_exposify_meta.sql");
|
|
427
|
+
if (found) return { path: join(MIGRATIONS_DIR, found), wasNew: false };
|
|
379
428
|
const migrationSql = await readFile(join(PACKAGE_ROOT, "sql/exposify_meta.sql"), "utf8");
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
429
|
+
const filename = uniqueMigrationFilename(existing, `${timestamp()}_exposify_meta.sql`);
|
|
430
|
+
const path = join(MIGRATIONS_DIR, filename);
|
|
431
|
+
await writeFile(path, migrationSql);
|
|
432
|
+
return { path, wasNew: true };
|
|
433
|
+
}
|
|
434
|
+
async function warnIfNotSupabaseProject() {
|
|
435
|
+
if (!await exists("supabase/config.toml")) {
|
|
436
|
+
console.warn(
|
|
437
|
+
"warn: no supabase/config.toml found here -- this doesn't look like a Supabase project root (or you haven't run `supabase init` yet). Continuing, but double-check you're in the right directory."
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async function cmdInit() {
|
|
442
|
+
await warnIfNotSupabaseProject();
|
|
443
|
+
const { path: migrationPath, wasNew } = await ensureMetaMigration();
|
|
444
|
+
console.log(wasNew ? `Wrote ${migrationPath}` : `Skipped ${migrationPath} (already scaffolded)`);
|
|
385
445
|
await mkdir(FUNCTION_DIR, { recursive: true });
|
|
386
446
|
const functionPath = join(FUNCTION_DIR, "index.ts");
|
|
387
447
|
const functionExists = await exists(functionPath);
|
|
388
448
|
await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
|
|
449
|
+
const connWarning = partialConnectionWarning(
|
|
450
|
+
process.env.SUPABASE_URL ?? argValue("--url"),
|
|
451
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key")
|
|
452
|
+
);
|
|
453
|
+
if (connWarning) console.warn(`warn: ${connWarning}`);
|
|
389
454
|
const rl = openInteractiveReadline();
|
|
390
455
|
if (!rl) {
|
|
391
456
|
if (!functionExists) {
|
|
@@ -435,7 +500,7 @@ Supabase CLI command failed: ${result2.error instanceof Error ? result2.error.me
|
|
|
435
500
|
introspection = conn ? await tryIntrospect(conn) : null;
|
|
436
501
|
if (!introspection) {
|
|
437
502
|
console.log(
|
|
438
|
-
"\nDeployed, but couldn't reach the project to introspect tables (no SUPABASE_URL/
|
|
503
|
+
"\nDeployed, but couldn't reach the project to introspect tables (no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY or --url/--key available). Re-run `exposify-supabase init` with those set to configure writes, RPCs, and documentation interactively."
|
|
439
504
|
);
|
|
440
505
|
console.log("\nDone. Paste the function URL + a user JWT into an MCP client to try it.");
|
|
441
506
|
return;
|
|
@@ -490,16 +555,19 @@ async function cmdGenerate() {
|
|
|
490
555
|
const conn = resolveConnectionInfo();
|
|
491
556
|
if (!conn) {
|
|
492
557
|
console.error(
|
|
493
|
-
"generate needs SUPABASE_URL and
|
|
558
|
+
"generate needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (env vars, or --url/--key flags) pointing at a running local or linked project (e.g. `supabase start`). The service role key is used only for this one-time introspection call, matching what runtime introspection itself uses -- never written into the generated manifest."
|
|
494
559
|
);
|
|
495
560
|
process.exitCode = 1;
|
|
496
561
|
return;
|
|
497
562
|
}
|
|
498
563
|
const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
|
|
499
|
-
const headers = new Headers({
|
|
564
|
+
const headers = new Headers({
|
|
565
|
+
authorization: `Bearer ${conn.serviceRoleKey}`,
|
|
566
|
+
apikey: conn.serviceRoleKey
|
|
567
|
+
});
|
|
500
568
|
const [openapi, metaRows] = await Promise.all([
|
|
501
|
-
fetchOpenApi(postgrestUrl, headers,
|
|
502
|
-
fetchExposifyMeta(postgrestUrl, headers,
|
|
569
|
+
fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
|
|
570
|
+
fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
|
|
503
571
|
]);
|
|
504
572
|
const manifest = buildManifest(openapi, metaRows, (/* @__PURE__ */ new Date()).toISOString());
|
|
505
573
|
const outPath = join(FUNCTION_DIR, "exposify.manifest.json");
|
|
@@ -520,10 +588,10 @@ async function main() {
|
|
|
520
588
|
const [, , cmd] = process.argv;
|
|
521
589
|
if (cmd === "init") return cmdInit();
|
|
522
590
|
if (cmd === "generate") return cmdGenerate();
|
|
523
|
-
console.log("Usage: exposify-supabase <init|generate> [--url <url>] [--key <
|
|
591
|
+
console.log("Usage: exposify-supabase <init|generate> [--url <url>] [--key <service-role-key>]");
|
|
524
592
|
console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts.");
|
|
525
593
|
console.log(" Interactively offers to run `supabase db push` + `functions deploy`; if a");
|
|
526
|
-
console.log(" project is reachable (SUPABASE_URL/
|
|
594
|
+
console.log(" project is reachable (SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY env vars, or --url/--key),");
|
|
527
595
|
console.log(" also walks through anonymous sign-in, per-table writes, RPC exposure, and");
|
|
528
596
|
console.log(" documentation for tables/columns missing a comment.");
|
|
529
597
|
console.log(" generate Build exposify.manifest.json from a running local/linked project");
|
package/dist/index.cjs
CHANGED
|
@@ -162,12 +162,19 @@ function createPostgrestClient(postgrestUrl, headers, fetchImpl) {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// src/fetch-openapi.ts
|
|
165
|
+
var WRONG_KEY_TIER_RE = /service_role|secret api key/i;
|
|
165
166
|
async function fetchOpenApi(postgrestUrl, headers, fetchImpl) {
|
|
166
167
|
const res = await fetchImpl(postgrestUrl, {
|
|
167
168
|
headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
|
|
168
169
|
});
|
|
169
170
|
if (!res.ok) {
|
|
170
|
-
|
|
171
|
+
const body = await res.text().catch(() => "");
|
|
172
|
+
if (res.status === 401 && WRONG_KEY_TIER_RE.test(body)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`PostgREST OpenAPI fetch failed: HTTP 401 -- Supabase rejected this request because it wasn't made with the service role key (response: "${body.trim()}"). Schema introspection always needs SUPABASE_SERVICE_ROLE_KEY, never the anon key -- see this package's README, "Why introspection needs the service role key". In a deployed function this is auto-injected automatically; for local dev/testing pass { serviceRoleKey } explicitly (or --key for the CLI).`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}${body ? `: ${body}` : ""}`);
|
|
171
178
|
}
|
|
172
179
|
return await res.json();
|
|
173
180
|
}
|
|
@@ -638,6 +645,7 @@ function aiReady(options = {}) {
|
|
|
638
645
|
const env = denoEnv();
|
|
639
646
|
const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
|
|
640
647
|
const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
|
|
648
|
+
const serviceRoleKey = options.serviceRoleKey ?? env?.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
641
649
|
let kbPromise = null;
|
|
642
650
|
const getKb = () => {
|
|
643
651
|
kbPromise ??= (async () => {
|
|
@@ -655,7 +663,7 @@ function aiReady(options = {}) {
|
|
|
655
663
|
const getLadder = (postgrestUrl) => {
|
|
656
664
|
ladderPromise ??= resolveTableSchemas({
|
|
657
665
|
postgrestUrl,
|
|
658
|
-
headers: buildPostgrestHeaders(new Headers(),
|
|
666
|
+
headers: buildPostgrestHeaders(new Headers(), serviceRoleKey),
|
|
659
667
|
fetchImpl,
|
|
660
668
|
manifest: options.manifest
|
|
661
669
|
}).then((ladder) => {
|
package/dist/index.d.cts
CHANGED
|
@@ -60,6 +60,18 @@ interface AgentifySupabaseOptions {
|
|
|
60
60
|
/** Overrides for local/CI use -- production reads these from the Deno env automatically. */
|
|
61
61
|
supabaseUrl?: string;
|
|
62
62
|
anonKey?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Used ONLY for schema introspection (fetching PostgREST's OpenAPI document
|
|
65
|
+
* + calling `exposify_meta()`) -- never for per-request data access, which
|
|
66
|
+
* always uses the caller's forwarded JWT (or the anon key as its fallback).
|
|
67
|
+
* Required because Supabase's gateway rejects the OpenAPI-discovery route
|
|
68
|
+
* for anon/publishable-tier keys outright (`Invalid API key` / `Only the
|
|
69
|
+
* service_role API key can be used for this endpoint`), regardless of key
|
|
70
|
+
* format -- discovered via a real deployment, not documented anywhere at
|
|
71
|
+
* design time. Auto-injected as `SUPABASE_SERVICE_ROLE_KEY` in every
|
|
72
|
+
* deployed edge function; this option exists for local dev/testing only.
|
|
73
|
+
*/
|
|
74
|
+
serviceRoleKey?: string;
|
|
63
75
|
/** Injectable for tests; defaults to global `fetch`. */
|
|
64
76
|
fetchImpl?: typeof fetch;
|
|
65
77
|
/** Bundled fallback manifest (from `exposify-supabase generate`), used when runtime introspection fails. */
|
|
@@ -156,7 +168,15 @@ declare class MissingExposifyMetaError extends Error {
|
|
|
156
168
|
*/
|
|
157
169
|
declare function fetchExposifyMeta(postgrestUrl: string, headers: Headers, fetchImpl: typeof fetch): Promise<MetaRow[]>;
|
|
158
170
|
|
|
159
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* PostgREST serves its OpenAPI document at the API root when asked for it
|
|
173
|
+
* explicitly. This is the one call in the whole SDK most likely to fail for
|
|
174
|
+
* a specific, fixable reason (the wrong key tier -- see decisions journal
|
|
175
|
+
* §9), so unlike a generic fetch wrapper this one reads the body on failure
|
|
176
|
+
* and turns that one known failure mode into a self-diagnosing message
|
|
177
|
+
* naming the exact fix, the same pattern `meta.ts` already uses for a
|
|
178
|
+
* missing `exposify_meta()` migration.
|
|
179
|
+
*/
|
|
160
180
|
declare function fetchOpenApi(postgrestUrl: string, headers: Headers, fetchImpl: typeof fetch): Promise<PostgrestOpenApiDoc>;
|
|
161
181
|
|
|
162
182
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,18 @@ interface AgentifySupabaseOptions {
|
|
|
60
60
|
/** Overrides for local/CI use -- production reads these from the Deno env automatically. */
|
|
61
61
|
supabaseUrl?: string;
|
|
62
62
|
anonKey?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Used ONLY for schema introspection (fetching PostgREST's OpenAPI document
|
|
65
|
+
* + calling `exposify_meta()`) -- never for per-request data access, which
|
|
66
|
+
* always uses the caller's forwarded JWT (or the anon key as its fallback).
|
|
67
|
+
* Required because Supabase's gateway rejects the OpenAPI-discovery route
|
|
68
|
+
* for anon/publishable-tier keys outright (`Invalid API key` / `Only the
|
|
69
|
+
* service_role API key can be used for this endpoint`), regardless of key
|
|
70
|
+
* format -- discovered via a real deployment, not documented anywhere at
|
|
71
|
+
* design time. Auto-injected as `SUPABASE_SERVICE_ROLE_KEY` in every
|
|
72
|
+
* deployed edge function; this option exists for local dev/testing only.
|
|
73
|
+
*/
|
|
74
|
+
serviceRoleKey?: string;
|
|
63
75
|
/** Injectable for tests; defaults to global `fetch`. */
|
|
64
76
|
fetchImpl?: typeof fetch;
|
|
65
77
|
/** Bundled fallback manifest (from `exposify-supabase generate`), used when runtime introspection fails. */
|
|
@@ -156,7 +168,15 @@ declare class MissingExposifyMetaError extends Error {
|
|
|
156
168
|
*/
|
|
157
169
|
declare function fetchExposifyMeta(postgrestUrl: string, headers: Headers, fetchImpl: typeof fetch): Promise<MetaRow[]>;
|
|
158
170
|
|
|
159
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* PostgREST serves its OpenAPI document at the API root when asked for it
|
|
173
|
+
* explicitly. This is the one call in the whole SDK most likely to fail for
|
|
174
|
+
* a specific, fixable reason (the wrong key tier -- see decisions journal
|
|
175
|
+
* §9), so unlike a generic fetch wrapper this one reads the body on failure
|
|
176
|
+
* and turns that one known failure mode into a self-diagnosing message
|
|
177
|
+
* naming the exact fix, the same pattern `meta.ts` already uses for a
|
|
178
|
+
* missing `exposify_meta()` migration.
|
|
179
|
+
*/
|
|
160
180
|
declare function fetchOpenApi(postgrestUrl: string, headers: Headers, fetchImpl: typeof fetch): Promise<PostgrestOpenApiDoc>;
|
|
161
181
|
|
|
162
182
|
/**
|
package/dist/index.js
CHANGED
|
@@ -129,12 +129,19 @@ function createPostgrestClient(postgrestUrl, headers, fetchImpl) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// src/fetch-openapi.ts
|
|
132
|
+
var WRONG_KEY_TIER_RE = /service_role|secret api key/i;
|
|
132
133
|
async function fetchOpenApi(postgrestUrl, headers, fetchImpl) {
|
|
133
134
|
const res = await fetchImpl(postgrestUrl, {
|
|
134
135
|
headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
|
|
135
136
|
});
|
|
136
137
|
if (!res.ok) {
|
|
137
|
-
|
|
138
|
+
const body = await res.text().catch(() => "");
|
|
139
|
+
if (res.status === 401 && WRONG_KEY_TIER_RE.test(body)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`PostgREST OpenAPI fetch failed: HTTP 401 -- Supabase rejected this request because it wasn't made with the service role key (response: "${body.trim()}"). Schema introspection always needs SUPABASE_SERVICE_ROLE_KEY, never the anon key -- see this package's README, "Why introspection needs the service role key". In a deployed function this is auto-injected automatically; for local dev/testing pass { serviceRoleKey } explicitly (or --key for the CLI).`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}${body ? `: ${body}` : ""}`);
|
|
138
145
|
}
|
|
139
146
|
return await res.json();
|
|
140
147
|
}
|
|
@@ -608,6 +615,7 @@ function aiReady(options = {}) {
|
|
|
608
615
|
const env = denoEnv();
|
|
609
616
|
const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
|
|
610
617
|
const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
|
|
618
|
+
const serviceRoleKey = options.serviceRoleKey ?? env?.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
611
619
|
let kbPromise = null;
|
|
612
620
|
const getKb = () => {
|
|
613
621
|
kbPromise ??= (async () => {
|
|
@@ -625,7 +633,7 @@ function aiReady(options = {}) {
|
|
|
625
633
|
const getLadder = (postgrestUrl) => {
|
|
626
634
|
ladderPromise ??= resolveTableSchemas({
|
|
627
635
|
postgrestUrl,
|
|
628
|
-
headers: buildPostgrestHeaders(new Headers(),
|
|
636
|
+
headers: buildPostgrestHeaders(new Headers(), serviceRoleKey),
|
|
629
637
|
fetchImpl,
|
|
630
638
|
manifest: options.manifest
|
|
631
639
|
}).then((ladder) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exposify/supabase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"prepack": "yarn build"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@exposify/core": "0.
|
|
30
|
+
"@exposify/core": "0.3.1",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
32
|
"zod": "^3.24.0"
|
|
33
33
|
},
|