@exposify/supabase 0.2.0 → 0.3.2

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 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 writes two things and never overwrites an existing function file:
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`/`SUPABASE_ANON_KEY` (env vars or `--url`/`--key`
101
- flags) — without those, `init` can't reach anything to introspect and falls
102
- back to the fixed scaffold-and-ask-to-deploy flow above, same as before this
103
- wizard existed.
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 the env vars are automatic |
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
- SUPABASE_ANON_KEY=<anon-key> \
262
+ SUPABASE_SERVICE_ROLE_KEY=<service-role-key> \
228
263
  npx exposify-supabase generate
229
264
  ```
230
265
 
231
- Uses the **anon key**, deliberately the same key runtime introspection
232
- itself uses (see "Fallback ladder" above) — a service-role key would see
233
- tables/columns the `anon`/`authenticated` roles can't actually reach,
234
- producing a fallback manifest that offers tools live introspection never
235
- would.
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`/`SUPABASE_ANON_KEY` env vars, or `--url`/`--key`
276
- flags, pointing at a project where the migration is already applied). On a
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
- const res = await fetchImpl(postgrestUrl, {
13
+ const res = await fetchImpl(`${postgrestUrl.replace(/\/+$/, "")}/`, {
13
14
  headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
14
15
  });
15
16
  if (!res.ok) {
16
- throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}`);
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 anonKey = process.env.SUPABASE_ANON_KEY ?? argValue("--key");
278
- if (!url || !anonKey) return null;
279
- return { url, anonKey };
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.anonKey}`, apikey: conn.anonKey });
322
+ const headers = new Headers({ authorization: `Bearer ${conn.serviceRoleKey}`, apikey: conn.serviceRoleKey });
285
323
  const [openapi, metaRows] = await Promise.all([
286
- fetchOpenApi(postgrestUrl, headers, fetch),
287
- fetchExposifyMeta(postgrestUrl, headers, fetch)
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 fetch(url, { headers: { apikey: conn.anonKey, authorization: `Bearer ${conn.anonKey}` } });
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
- const migrationsDir = "supabase/migrations";
373
- await mkdir(migrationsDir, { recursive: true });
374
- const path = join(migrationsDir, `${timestamp()}_exposify_comments.sql`);
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 cmdInit() {
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 migrationsDir = "supabase/migrations";
381
- await mkdir(migrationsDir, { recursive: true });
382
- const migrationPath = join(migrationsDir, `${timestamp()}_exposify_meta.sql`);
383
- await writeFile(migrationPath, migrationSql);
384
- console.log(`Wrote ${migrationPath}`);
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/SUPABASE_ANON_KEY or --url/--key available). Re-run `exposify-supabase init` with those set to configure writes, RPCs, and documentation interactively."
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 SUPABASE_ANON_KEY (env vars, or --url/--key flags) pointing at a running local or linked project (e.g. `supabase start`)."
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({ authorization: `Bearer ${conn.anonKey}`, apikey: conn.anonKey });
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, fetch),
502
- fetchExposifyMeta(postgrestUrl, headers, fetch)
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 <anon-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/SUPABASE_ANON_KEY env vars, or --url/--key),");
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
- const res = await fetchImpl(postgrestUrl, {
167
+ const res = await fetchImpl(`${postgrestUrl.replace(/\/+$/, "")}/`, {
167
168
  headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
168
169
  });
169
170
  if (!res.ok) {
170
- throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}`);
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(), anonKey),
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
- /** PostgREST serves its OpenAPI document at the API root when asked for it explicitly. */
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
- /** PostgREST serves its OpenAPI document at the API root when asked for it explicitly. */
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
- const res = await fetchImpl(postgrestUrl, {
134
+ const res = await fetchImpl(`${postgrestUrl.replace(/\/+$/, "")}/`, {
134
135
  headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
135
136
  });
136
137
  if (!res.ok) {
137
- throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}`);
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(), anonKey),
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.2.0",
3
+ "version": "0.3.2",
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.2.0",
30
+ "@exposify/core": "0.3.2",
31
31
  "@modelcontextprotocol/sdk": "^1.12.0",
32
32
  "zod": "^3.24.0"
33
33
  },