@exposify/supabase 0.3.10 → 0.3.12

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
@@ -15,7 +15,7 @@ root.
15
15
 
16
16
  - A Supabase project (local via the [Supabase CLI](https://supabase.com/docs/guides/cli), or hosted).
17
17
  - The Supabase CLI (`supabase` on your `PATH`) for `db push` / `functions deploy`.
18
- - Node or Yarn to run `npx exposify-supabase` (the CLI is a regular npm
18
+ - Node or Yarn to run `npx @exposify/supabase` (the CLI is a regular npm
19
19
  package; the edge function itself runs on Deno and needs no local install).
20
20
 
21
21
  ## 1. Scaffold
@@ -23,17 +23,20 @@ root.
23
23
  From your Supabase project's root (the directory containing `supabase/`):
24
24
 
25
25
  ```bash
26
- npx exposify-supabase init
26
+ npx @exposify/supabase init
27
27
  ```
28
28
 
29
29
  This is safe to re-run; see "Re-running `init`" below. It writes:
30
30
 
31
- - `supabase/migrations/<timestamp>_exposify_meta.sql` — a SQL migration that
31
+ - `supabase/migrations/<timestamp>_exposify_meta_v2.sql` — a SQL migration that
32
32
  adds `exposify_meta()`, a `SECURITY DEFINER` function returning, per table,
33
- whether RLS is enabled and any `COMMENT ON TABLE`/`COMMENT ON COLUMN` text
33
+ whether RLS is enabled, what the `authenticated` role is granted on it
34
+ (select/insert/update/delete), and any `COMMENT ON TABLE`/`COMMENT ON COLUMN` text
34
35
  you've written. It only ever returns catalog metadata (table/column names,
35
36
  flags, comments) — never row data — which is what makes it safe to grant
36
- to `anon`/`authenticated` and call with a low-privilege key.
37
+ to `anon`/`authenticated` and call with a low-privilege key. A project with
38
+ the older `<timestamp>_exposify_meta.sql` (no grants) gets the `_v2` one on
39
+ the next `init`, which replaces the function.
37
40
  - `supabase/functions/mcp/index.ts` — the edge function itself. `init` owns
38
41
  the part between `// exposify:begin` and `// exposify:end` (imports and a
39
42
  `generated` options object). Everything after that is yours, starting with
@@ -204,9 +207,14 @@ swaps in on each request, and it stops working when it expires.
204
207
  ## 2. Decide what's exposed
205
208
 
206
209
  **Every RLS-enabled table is automatically readable.** A table only shows up
207
- at all if `exposify_meta()` reports RLS is turned on for it *and* PostgREST
208
- actually exposes it (the calling role has grants) — an RLS-less table is
209
- invisible, full stop, with no override. For each exposed table you get a
210
+ at all if `exposify_meta()` reports RLS is turned on for it *and* the
211
+ `authenticated` role has `SELECT` on it — an RLS-less table is
212
+ invisible, full stop, with no override. RLS decides which rows; the grant
213
+ decides whether the role can touch the table at all, and without it every
214
+ call answers 403. Newer Supabase local images no longer grant `anon` and
215
+ `authenticated` on new tables in `public`, so check with `doctor`, which
216
+ prints the `grant` to run. A write you opt into below only becomes a tool
217
+ when the role is granted that operation too. For each exposed table you get a
210
218
  `query_<table>` tool: typed filters (`eq`/`neq`/`gt`/`lt`/`like`), a `select`
211
219
  column list, `order`, and a `limit` capped server-side (100 rows by default,
212
220
  regardless of what the caller asks for).
@@ -216,7 +224,7 @@ or edit the managed block in `supabase/functions/mcp/index.ts` by hand,
216
224
  keeping its format. Anything else goes below the block:
217
225
 
218
226
  ```ts
219
- // exposify:begin -- generated by `npx exposify-supabase init`, ...
227
+ // exposify:begin -- generated by `npx @exposify/supabase init`, ...
220
228
  import { aiReady } from "npm:@exposify/supabase";
221
229
 
222
230
  const generated = {
@@ -244,9 +252,13 @@ Deno.serve(aiReady({
244
252
  ```
245
253
 
246
254
  Notes:
247
- - `update_<table>` and `delete_<table>` always require at least one filter —
248
- there is no way to opt into a table-wide mutation, even where RLS would
249
- technically allow it.
255
+ - `update_<table>` and `delete_<table>` change one row: the filter must
256
+ include `eq` on every primary-key column (more filters only narrow it).
257
+ A filter that happens to match everything (`id neq -1`, `item like %`) is
258
+ refused, and there is no way to opt into a table-wide mutation, even where
259
+ RLS would technically allow it; an agent that means to change several rows
260
+ makes one call per row. A table without a primary key gets `insert_<table>`
261
+ but no update or delete tools (the cold-start log and `doctor` say so).
250
262
  - Column/table descriptions shown to the agent come from SQL comments
251
263
  (`COMMENT ON TABLE orders IS '...'`, `COMMENT ON COLUMN orders.status IS
252
264
  '...'`) — write these once and every generated tool reads them.
@@ -328,8 +340,8 @@ RLS enforcement, not anything in this SDK, is what makes that true.
328
340
  ## 5. Check it: `doctor`
329
341
 
330
342
  ```bash
331
- npx exposify-supabase doctor # finds the project the same way init does
332
- npx exposify-supabase doctor --token <user-jwt | extb_...>
343
+ npx @exposify/supabase doctor # finds the project the same way init does
344
+ npx @exposify/supabase doctor --token <user-jwt | extb_...>
333
345
  ```
334
346
 
335
347
  A read-only health check of everything `init` sets up. It never writes a file
@@ -343,8 +355,11 @@ exits 1 if anything failed, so you can run it in CI.
343
355
  that `verify_jwt = false` for `mcp`.
344
356
  - **Database:** `exposify_meta()` is applied and the PostgREST schema is
345
357
  readable. It lists which tables became tools, and warns about tables that
346
- never will: RLS off, or RLS on but no API grants. It also warns about
347
- tables whose missing comments leave tools with thin descriptions.
358
+ never will: RLS off, or RLS on but no `SELECT` for `authenticated` (with the
359
+ `grant` to run). It warns about writes in the function's config the role
360
+ isn't granted, about the older `exposify_meta()` that doesn't report
361
+ grants, and about tables whose missing comments leave tools with thin
362
+ descriptions.
348
363
  - **Deployed function:** speaks MCP to `<project>/functions/v1/mcp`
349
364
  (`initialize`, then `tools/list`) and reports the tool count. If the
350
365
  function registered `exposify://diagnostics`, which it only does when
@@ -535,7 +550,7 @@ project (local `supabase start` or linked):
535
550
  ```bash
536
551
  SUPABASE_URL=http://localhost:54321 \
537
552
  SUPABASE_SERVICE_ROLE_KEY=<service-role-key> \
538
- npx exposify-supabase generate
553
+ npx @exposify/supabase generate
539
554
  ```
540
555
 
541
556
  Uses the **service role key**, deliberately the same key runtime
@@ -565,7 +580,7 @@ the function prints whether MCP is up. It shows in `supabase functions serve`
565
580
  locally and in the function's logs in production:
566
581
 
567
582
  ```
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)
583
+ [@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
584
  ```
570
585
 
571
586
  - **Manifest fallback:** the same line, marked "from the bundled manifest",
@@ -582,7 +597,7 @@ overrides that.
582
597
 
583
598
  ## Troubleshooting
584
599
 
585
- Start with `npx exposify-supabase doctor`. It checks most of what's below
600
+ Start with `npx @exposify/supabase doctor`. It checks most of what's below
586
601
  and names the fix.
587
602
 
588
603
  **"exposify_meta() is not reachable" / "PGRST202" errors in function logs.**
@@ -617,7 +632,7 @@ above — pointing at a project where the migration is already applied). On a
617
632
  brand-new project's first `init`, that's not true yet — say yes to the
618
633
  deploy prompt (or run `supabase db push` yourself) and the wizard picks up
619
634
  automatically once the migration is live, in the same run if you said yes,
620
- or on your next `exposify-supabase init` otherwise.
635
+ or on your next `@exposify/supabase init` otherwise.
621
636
 
622
637
  **`PostgREST OpenAPI fetch failed: HTTP 401`, and the error body (if you
623
638
  curl it directly) says `"Only the service_role API key can be used for this
package/dist/cli.js CHANGED
@@ -40,7 +40,7 @@ async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
40
40
  });
41
41
  if (res.status === 404) {
42
42
  throw new MissingExposifyMetaError(
43
- "exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify-supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
43
+ "exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx @exposify/supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
44
44
  );
45
45
  }
46
46
  if (!res.ok) {
@@ -99,6 +99,8 @@ function introspectTables(openapi, metaRows) {
99
99
  const rlsEnabled = rows[0].rls_enabled;
100
100
  if (!rlsEnabled) continue;
101
101
  if (!exposedInOpenApi.has(name)) continue;
102
+ const grants = grantsOf(rows[0]);
103
+ if (grants && !grants.select) continue;
102
104
  const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
103
105
  const columns = [];
104
106
  for (const row of rows) {
@@ -116,11 +118,21 @@ function introspectTables(openapi, metaRows) {
116
118
  name,
117
119
  rlsEnabled: true,
118
120
  description: rows[0].table_comment ?? void 0,
119
- columns
121
+ columns,
122
+ ...grants ? { grants } : {}
120
123
  });
121
124
  }
122
125
  return tables.sort((a, b) => a.name.localeCompare(b.name));
123
126
  }
127
+ function grantsOf(row) {
128
+ if (typeof row.can_select !== "boolean") return void 0;
129
+ return {
130
+ select: row.can_select,
131
+ insert: row.can_insert === true,
132
+ update: row.can_update === true,
133
+ delete: row.can_delete === true
134
+ };
135
+ }
124
136
  function groupByTable(rows) {
125
137
  const map = /* @__PURE__ */ new Map();
126
138
  for (const row of rows) {
@@ -399,6 +411,9 @@ async function discoverConnection(deps) {
399
411
  return { connection: local ? fromLocal() : null, notes };
400
412
  }
401
413
 
414
+ // bin/doctor.ts
415
+ import { DEFAULT_BROKER_URL, parseExposifyKey, readSupabaseAuth } from "@exposify/core";
416
+
402
417
  // bin/function-file.ts
403
418
  var KEY_SECRET = "EXPOSIFY_KEY";
404
419
  var EMPTY_CONFIG = { writes: [], rpc: [], anonymousSignIn: false, broker: null, oauth: false };
@@ -422,7 +437,7 @@ var ANONYMOUS_MARKER = "anonymous sign-in enabled";
422
437
  function buildManagedBlock(config) {
423
438
  if (config.broker && config.oauth) throw new Error("the hosted broker and Supabase Auth OAuth can't be combined");
424
439
  const lines = [
425
- `${BEGIN} -- generated by \`npx exposify-supabase init\`, which rewrites`,
440
+ `${BEGIN} -- generated by \`npx @exposify/supabase init\`, which rewrites`,
426
441
  `// everything down to exposify:end when you re-run it. Add your own code below.`
427
442
  ];
428
443
  if (config.anonymousSignIn) lines.push(...ANONYMOUS_SIGN_IN_WARNING);
@@ -525,6 +540,53 @@ function configsEqual(a, b) {
525
540
  return sameSet(a.writes, b.writes) && sameSet(a.rpc, b.rpc) && a.anonymousSignIn === b.anonymousSignIn && a.broker === null === (b.broker === null) && a.broker?.brokerUrl === b.broker?.brokerUrl && a.oauth === b.oauth;
526
541
  }
527
542
 
543
+ // src/config.ts
544
+ var WRITE_OPS = ["insert", "update", "delete"];
545
+ function parseWrites(writes = []) {
546
+ const grants = /* @__PURE__ */ new Map();
547
+ for (const entry of writes) {
548
+ const [table, op] = entry.split(".");
549
+ if (!table || !op) {
550
+ warnOnce(`ignoring malformed writes entry "${entry}" (expected "<table>.insert|update|delete|*")`);
551
+ continue;
552
+ }
553
+ const ops = op === "*" ? WRITE_OPS : isWriteOp(op) ? [op] : null;
554
+ if (!ops) {
555
+ warnOnce(`ignoring writes entry "${entry}": "${op}" is not insert, update, delete, or *`);
556
+ continue;
557
+ }
558
+ const set = grants.get(table) ?? /* @__PURE__ */ new Set();
559
+ for (const o of ops) set.add(o);
560
+ grants.set(table, set);
561
+ }
562
+ return grants;
563
+ }
564
+ function isWriteOp(op) {
565
+ return WRITE_OPS.includes(op);
566
+ }
567
+ var warned = /* @__PURE__ */ new Set();
568
+ function warnOnce(message) {
569
+ if (warned.has(message)) return;
570
+ warned.add(message);
571
+ try {
572
+ console.warn(`[@exposify/supabase] ${message}`);
573
+ } catch {
574
+ }
575
+ }
576
+ function writeToolOps(table, opted) {
577
+ const ops = [];
578
+ const ungranted = [];
579
+ const keyless = [];
580
+ const hasKey = table.columns.some((c) => c.isPrimaryKey);
581
+ for (const op of WRITE_OPS) {
582
+ if (!opted?.has(op)) continue;
583
+ if (table.grants && !table.grants[op]) ungranted.push(op);
584
+ else if (op !== "insert" && !hasKey) keyless.push(op);
585
+ else ops.push(op);
586
+ }
587
+ return { ops, ungranted, keyless };
588
+ }
589
+
528
590
  // bin/doctor.ts
529
591
  var FUNCTION_NAME = "mcp";
530
592
  var FUNCTION_PATH = `supabase/functions/${FUNCTION_NAME}/index.ts`;
@@ -573,6 +635,17 @@ function invisibleTables(metaRows, exposed) {
573
635
  }
574
636
  return { rlsOff, notGranted };
575
637
  }
638
+ function unavailableWrites(exposed, writes) {
639
+ const opted = parseWrites(writes);
640
+ const ungranted = [];
641
+ const keyless = [];
642
+ for (const table of exposed) {
643
+ const why = writeToolOps(table, opted.get(table.name));
644
+ for (const op of why.ungranted) ungranted.push({ table: table.name, op });
645
+ for (const op of why.keyless) keyless.push({ table: table.name, op });
646
+ }
647
+ return { ungranted, keyless };
648
+ }
576
649
  function parseJsonRpcResponse(contentType, body, id) {
577
650
  const candidates = contentType.includes("text/event-stream") ? body.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()) : [body];
578
651
  for (const raw of candidates) {
@@ -667,14 +740,20 @@ async function runDoctor(deps) {
667
740
  );
668
741
  sections.push({ title: "Project", checks: project });
669
742
  const files = [];
670
- const migration = findExistingMigration(await deps.listDir(MIGRATIONS_DIR), "_exposify_meta.sql");
743
+ const migrations = await deps.listDir(MIGRATIONS_DIR);
744
+ const migration = findExistingMigration(migrations, "_exposify_meta_v2.sql");
745
+ const legacyMigration = findExistingMigration(migrations, "_exposify_meta.sql");
671
746
  files.push(
672
- migration ? { status: "pass", message: `migration ${MIGRATIONS_DIR}/${migration}` } : { status: "fail", message: "no *_exposify_meta.sql migration", fix: "npx exposify-supabase init" }
747
+ migration ? { status: "pass", message: `migration ${MIGRATIONS_DIR}/${migration}` } : legacyMigration ? {
748
+ status: "warn",
749
+ message: `migration ${MIGRATIONS_DIR}/${legacyMigration} is the older exposify_meta(), which doesn't report grants`,
750
+ fix: "npx @exposify/supabase init (it adds *_exposify_meta_v2.sql), then supabase db push"
751
+ } : { status: "fail", message: "no *_exposify_meta_v2.sql migration", fix: "npx @exposify/supabase init" }
673
752
  );
674
753
  const functionSource = await deps.readText(FUNCTION_PATH);
675
754
  const authMode = functionSource === null ? null : detectAuthMode(functionSource);
676
755
  files.push(
677
- functionSource !== null ? { status: "pass", message: `function ${FUNCTION_PATH} (auth: ${AUTH_LABEL[authMode]})` } : { status: "fail", message: `no ${FUNCTION_PATH}`, fix: "npx exposify-supabase init" }
756
+ functionSource !== null ? { status: "pass", message: `function ${FUNCTION_PATH} (auth: ${AUTH_LABEL[authMode]})` } : { status: "fail", message: `no ${FUNCTION_PATH}`, fix: "npx @exposify/supabase init" }
678
757
  );
679
758
  if (bypassesGatewayJwt2(authMode)) {
680
759
  const verifyJwt = toml === null ? null : readFunctionVerifyJwt(toml);
@@ -686,7 +765,7 @@ async function runDoctor(deps) {
686
765
  } : {
687
766
  status: "fail",
688
767
  message: `verify_jwt is ${verifyJwt === null ? "unset (defaults to on)" : "on"} for ${FUNCTION_NAME}; ` + (oauth ? "MCP clients will never get the OAuth login challenge" : "the gateway will reject every extb_... token"),
689
- fix: "re-run `npx exposify-supabase init` (it sets it), or add `[functions.mcp] verify_jwt = false` to config.toml and redeploy"
768
+ fix: "re-run `npx @exposify/supabase init` (it sets it), or add `[functions.mcp] verify_jwt = false` to config.toml and redeploy"
690
769
  }
691
770
  );
692
771
  }
@@ -715,6 +794,13 @@ async function runDoctor(deps) {
715
794
  } else {
716
795
  const tableCount = new Set(meta.value.map((r) => r.table_name)).size;
717
796
  db.push({ status: "pass", message: `exposify_meta() applied (${tableCount} table(s) in the catalog)` });
797
+ if (meta.value.length > 0 && grantsOf(meta.value[0]) === void 0) {
798
+ db.push({
799
+ status: "warn",
800
+ message: "exposify_meta() in the database is the older one: it doesn't report grants, so tables agents can't read are still listed as tools",
801
+ fix: "npx @exposify/supabase init, then supabase db push"
802
+ });
803
+ }
718
804
  }
719
805
  if (openapi.status === "rejected") {
720
806
  const reason = openapi.reason instanceof Error ? openapi.reason.message : String(openapi.reason);
@@ -724,10 +810,14 @@ async function runDoctor(deps) {
724
810
  }
725
811
  if (openapi.status === "fulfilled" && meta.status === "fulfilled") {
726
812
  const tables = introspectTables(openapi.value, meta.value);
813
+ const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
727
814
  db.push(
728
- tables.length > 0 ? { status: "pass", message: `${tables.length} table(s) exposed as tools: ${tables.map((t) => t.name).join(", ")}` } : { status: "warn", message: "no tables exposed (none have RLS on and API grants)", fix: "enable RLS on the tables agents should read" }
815
+ tables.length > 0 ? { status: "pass", message: `${tables.length} table(s) exposed as tools: ${tables.map((t) => t.name).join(", ")}` } : {
816
+ status: "warn",
817
+ message: "no tables exposed (none have RLS on and API grants)",
818
+ fix: notGranted.length > 0 ? "grant the authenticated role access (below)" : "enable RLS on the tables agents should read"
819
+ }
729
820
  );
730
- const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
731
821
  if (rlsOff.length > 0) {
732
822
  db.push({
733
823
  status: "warn",
@@ -738,8 +828,23 @@ async function runDoctor(deps) {
738
828
  if (notGranted.length > 0) {
739
829
  db.push({
740
830
  status: "warn",
741
- message: `RLS on but not exposed by PostgREST (no API role grants): ${notGranted.join(", ")}`,
742
- fix: "grant select on <name> to authenticated;"
831
+ message: `RLS on, but the authenticated role can't read them, so they aren't tools: ${notGranted.join(", ")}`,
832
+ fix: notGranted.map((t) => `grant select on public.${t} to authenticated;`).join(" ")
833
+ });
834
+ }
835
+ const { ungranted, keyless } = functionSource === null ? { ungranted: [], keyless: [] } : unavailableWrites(tables, parseFunctionFile(functionSource).config.writes);
836
+ if (keyless.length > 0) {
837
+ db.push({
838
+ status: "warn",
839
+ message: `writes in the function's config on tables without a primary key, so they aren't tools (an update or delete must name its row by key): ${keyless.map((w) => `${w.table}.${w.op}`).join(", ")}`,
840
+ fix: "add a primary key to the table"
841
+ });
842
+ }
843
+ if (ungranted.length > 0) {
844
+ db.push({
845
+ status: "warn",
846
+ message: `writes in the function's config the authenticated role isn't granted, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
847
+ fix: ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
743
848
  });
744
849
  }
745
850
  const undocumented = tablesMissingDocs(tables).filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
@@ -747,7 +852,7 @@ async function runDoctor(deps) {
747
852
  db.push({
748
853
  status: "warn",
749
854
  message: `${undocumented.length} table(s) missing comments, so tools have thin descriptions: ${undocumented.map((t) => t.name).join(", ")}`,
750
- fix: "re-run `npx exposify-supabase init` and write descriptions, or add COMMENT ON TABLE/COLUMN"
855
+ fix: "re-run `npx @exposify/supabase init` and write descriptions, or add COMMENT ON TABLE/COLUMN"
751
856
  });
752
857
  }
753
858
  }
@@ -763,8 +868,35 @@ async function runDoctor(deps) {
763
868
  }
764
869
  if (authMode === "broker") fn.push(licenseSecretCheck(deps));
765
870
  sections.push({ title: "Deployed function", checks: fn });
871
+ if (authMode === "broker" && conn) sections.push({ title: "Connect (agents sign in to your Supabase Auth)", checks: await connectChecks(deps, conn) });
766
872
  return sections;
767
873
  }
874
+ async function connectChecks(deps, conn) {
875
+ const auth = conn.anonKey ? await readSupabaseAuth(conn.url, conn.anonKey, deps.fetch) : void 0;
876
+ const checks = [];
877
+ if (!auth) {
878
+ checks.push({ status: "warn", message: "couldn't read the project's Auth settings (/auth/v1/settings)", fix: "pass the anon key with --key, or check Auth is enabled" });
879
+ return checks;
880
+ }
881
+ checks.push(
882
+ auth.providers.length ? { status: "pass", message: `social sign-in offered to agents: ${auth.providers.join(", ")}` } : {
883
+ status: "warn",
884
+ message: "no social provider is switched on, so agents can only sign in with an email code",
885
+ fix: "switch one on in Auth \u2192 Sign In / Providers, or turn on email codes in the agemno dashboard"
886
+ }
887
+ );
888
+ if (!auth.signupsDisabled) {
889
+ checks.push({ status: "warn", message: "sign-ups are allowed: a new user who signs in with a social provider through Connect gets an account" });
890
+ }
891
+ const projectId = deps.exposifyKey ? parseExposifyKey(deps.exposifyKey)?.projectId : void 0;
892
+ const callback = projectId ? `${(deps.brokerUrl ?? DEFAULT_BROKER_URL).replace(/\/+$/, "")}/projects/${projectId}/supabase-callback` : null;
893
+ checks.push({
894
+ status: "warn",
895
+ message: callback ? `Redirect URLs must include ${callback} (can't be checked from here)` : "Redirect URLs must include the broker's callback (can't be checked from here)",
896
+ fix: callback ? "add it in Auth \u2192 URL Configuration \u2192 Redirect URLs" : "the agemno dashboard's sign-in page shows it; or run doctor with EXPOSIFY_KEY set to print it"
897
+ });
898
+ return checks;
899
+ }
768
900
  function probeChecks(url, outcome, authMode, local) {
769
901
  switch (outcome.kind) {
770
902
  case "ok": {
@@ -868,7 +1000,8 @@ var FUNCTION_DIR = "supabase/functions/mcp";
868
1000
  var FUNCTION_PATH2 = `${FUNCTION_DIR}/index.ts`;
869
1001
  var MIGRATIONS_DIR2 = "supabase/migrations";
870
1002
  var CONFIG_TOML2 = "supabase/config.toml";
871
- var META_SUFFIX = "_exposify_meta.sql";
1003
+ var META_SUFFIX = "_exposify_meta_v2.sql";
1004
+ var LEGACY_META_SUFFIX = "_exposify_meta.sql";
872
1005
  var COMMENTS_SUFFIX = "_exposify_comments.sql";
873
1006
  function authLabel(config) {
874
1007
  return config.broker ? "hosted broker" : config.oauth ? "Supabase Auth OAuth" : "user JWT";
@@ -903,7 +1036,7 @@ function planInit(input) {
903
1036
  changes.push({
904
1037
  path: `${MIGRATIONS_DIR2}/${name}`,
905
1038
  action: "create",
906
- summary: "exposify_meta() migration",
1039
+ summary: findExistingMigration(input.migrations, LEGACY_META_SUFFIX) ? "exposify_meta() update: it also reports the authenticated role's grants" : "exposify_meta() migration",
907
1040
  content: input.metaMigrationSql,
908
1041
  effect: "push"
909
1042
  });
@@ -1019,14 +1152,10 @@ function staleFunctionReason(probe, config, exposedTables) {
1019
1152
  return config.oauth ? "the gateway still checks JWTs for the function, so MCP clients never get the login challenge (deploy with --no-verify-jwt)." : "the gateway still checks JWTs for the function, so broker tokens are rejected (deploy with --no-verify-jwt).";
1020
1153
  }
1021
1154
  if (probe.kind !== "ok" || !exposedTables) return null;
1022
- const exposed = new Set(exposedTables.map((t) => t.name));
1155
+ const opted = parseWrites(config.writes);
1023
1156
  const expected = /* @__PURE__ */ new Set();
1024
- for (const w of config.writes) {
1025
- const dot = w.lastIndexOf(".");
1026
- const table = w.slice(0, dot);
1027
- if (!exposed.has(table)) continue;
1028
- const op = w.slice(dot + 1);
1029
- for (const o of op === "*" ? ["insert", "update", "delete"] : [op]) expected.add(`${o}_${sanitizeIdentifier(table)}`);
1157
+ for (const table of exposedTables) {
1158
+ for (const op of writeToolOps(table, opted.get(table.name)).ops) expected.add(`${op}_${sanitizeIdentifier(table.name)}`);
1030
1159
  }
1031
1160
  for (const fn of config.rpc) expected.add(`rpc_${sanitizeIdentifier(fn)}`);
1032
1161
  const live = new Set(probe.tools.filter((t) => /^(insert|update|delete|rpc)_/.test(t)));
@@ -1042,7 +1171,7 @@ function liveVerdict(s) {
1042
1171
  let deploy = false;
1043
1172
  if (s.migrationApplied === false) {
1044
1173
  push = true;
1045
- notes.push("exposify_meta() isn't in the database yet (migration not pushed).");
1174
+ notes.push("the exposify_meta() migration isn't in the database yet (not pushed).");
1046
1175
  }
1047
1176
  if (s.pendingComments.length > 0) {
1048
1177
  push = true;
@@ -1231,7 +1360,8 @@ async function tryIntrospect(conn) {
1231
1360
  fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
1232
1361
  fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
1233
1362
  ]);
1234
- return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: true };
1363
+ const current = metaRows.length === 0 || grantsOf(metaRows[0]) !== void 0;
1364
+ return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: current };
1235
1365
  } catch (err) {
1236
1366
  if (err instanceof MissingExposifyMetaError) return { introspection: null, migrationApplied: false };
1237
1367
  console.log(`
@@ -1470,14 +1600,14 @@ No files were changed. Merge the block above into ${FUNCTION_PATH2}, then re-run
1470
1600
  }
1471
1601
  if (!c) {
1472
1602
  console.log(
1473
- "\nNothing changed locally. Couldn't check the project itself (no --url/--key, env vars, or local/linked project) -- run `npx exposify-supabase doctor` once it's reachable."
1603
+ "\nNothing changed locally. Couldn't check the project itself (no --url/--key, env vars, or local/linked project) -- run `npx @exposify/supabase doctor` once it's reachable."
1474
1604
  );
1475
1605
  return;
1476
1606
  }
1477
1607
  console.log("\nNothing changed locally, but the project is behind:");
1478
1608
  for (const n of verdict.notes) console.log(` - ${n}`);
1479
1609
  if (!verdict.push && !verdict.deploy) {
1480
- console.log("Run `npx exposify-supabase doctor` for details.");
1610
+ console.log("Run `npx @exposify/supabase doctor` for details.");
1481
1611
  return;
1482
1612
  }
1483
1613
  if (c.source === "local") {
@@ -1493,7 +1623,7 @@ That's the local stack: run ${todo.join(" and ")}.`);
1493
1623
  const again = await tryIntrospect(await getConn());
1494
1624
  if (!again.introspection) {
1495
1625
  console.log(
1496
- "\nDeployed, but couldn't reach the project to introspect tables (no --url/--key, no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, and no local stack or linked project the Supabase CLI could supply keys for). Re-run `exposify-supabase init` with those set to configure writes, RPCs, and documentation interactively."
1626
+ "\nDeployed, but couldn't reach the project to introspect tables (no --url/--key, no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, and no local stack or linked project the Supabase CLI could supply keys for). Re-run `npx @exposify/supabase init` with those set to configure writes, RPCs, and documentation interactively."
1497
1627
  );
1498
1628
  console.log(`
1499
1629
  Done. ${clientStep(effectiveAuth)}`);
@@ -1567,7 +1697,9 @@ async function cmdDoctor() {
1567
1697
  connectionNotes: notes,
1568
1698
  projectRef,
1569
1699
  fetch: fetchWithTimeout,
1570
- token: argValue("--token")
1700
+ token: argValue("--token"),
1701
+ ...process.env.EXPOSIFY_KEY ? { exposifyKey: process.env.EXPOSIFY_KEY } : {},
1702
+ ...process.env.EXPOSIFY_BROKER_URL ? { brokerUrl: process.env.EXPOSIFY_BROKER_URL } : {}
1571
1703
  });
1572
1704
  console.log(formatReport(sections));
1573
1705
  if (hasFailures(sections)) process.exitCode = 1;
@@ -1587,7 +1719,7 @@ async function main() {
1587
1719
  if (cmd === "generate") return cmdGenerate();
1588
1720
  if (cmd === "doctor") return cmdDoctor();
1589
1721
  console.log(
1590
- "Usage: exposify-supabase <init|generate|doctor> [--url <url>] [--key <service-role-key>] [--local|--linked] [--broker [--broker-url <url>] | --oauth] [--token <token>] [--check]"
1722
+ "Usage: @exposify/supabase <init|generate|doctor> [--url <url>] [--key <service-role-key>] [--local|--linked] [--broker [--broker-url <url>] | --oauth] [--token <token>] [--check]"
1591
1723
  );
1592
1724
  console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts, then (when the");
1593
1725
  console.log(" project is reachable) walk through anonymous sign-in, per-table writes, RPC exposure, and");
package/dist/index.cjs CHANGED
@@ -107,7 +107,7 @@ function warnOnce(message) {
107
107
  if (warned.has(message)) return;
108
108
  warned.add(message);
109
109
  try {
110
- console.warn(`[exposify-supabase] ${message}`);
110
+ console.warn(`[@exposify/supabase] ${message}`);
111
111
  } catch {
112
112
  }
113
113
  }
@@ -131,6 +131,22 @@ function resolveSupabaseConfig(options = {}, hostedBroker2 = false) {
131
131
  rateWindowMs: options.rateWindowMs
132
132
  };
133
133
  }
134
+ function writeToolOps(table, opted) {
135
+ const ops = [];
136
+ const ungranted = [];
137
+ const keyless = [];
138
+ const hasKey = table.columns.some((c) => c.isPrimaryKey);
139
+ for (const op of WRITE_OPS) {
140
+ if (!opted?.has(op)) continue;
141
+ if (table.grants && !table.grants[op]) ungranted.push(op);
142
+ else if (op !== "insert" && !hasKey) keyless.push(op);
143
+ else ops.push(op);
144
+ }
145
+ return { ops, ungranted, keyless };
146
+ }
147
+ function writeTools(table, cfg) {
148
+ return writeToolOps(table, cfg.writes.get(table.name));
149
+ }
134
150
 
135
151
  // src/postgrest-client.ts
136
152
  var FORWARDED_HEADERS = ["authorization", "apikey"];
@@ -206,7 +222,7 @@ async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
206
222
  });
207
223
  if (res.status === 404) {
208
224
  throw new MissingExposifyMetaError(
209
- "exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify-supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
225
+ "exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx @exposify/supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
210
226
  );
211
227
  }
212
228
  if (!res.ok) {
@@ -275,6 +291,8 @@ function introspectTables(openapi, metaRows) {
275
291
  const rlsEnabled = rows[0].rls_enabled;
276
292
  if (!rlsEnabled) continue;
277
293
  if (!exposedInOpenApi.has(name)) continue;
294
+ const grants = grantsOf(rows[0]);
295
+ if (grants && !grants.select) continue;
278
296
  const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
279
297
  const columns = [];
280
298
  for (const row of rows) {
@@ -292,11 +310,21 @@ function introspectTables(openapi, metaRows) {
292
310
  name,
293
311
  rlsEnabled: true,
294
312
  description: rows[0].table_comment ?? void 0,
295
- columns
313
+ columns,
314
+ ...grants ? { grants } : {}
296
315
  });
297
316
  }
298
317
  return tables.sort((a, b) => a.name.localeCompare(b.name));
299
318
  }
319
+ function grantsOf(row) {
320
+ if (typeof row.can_select !== "boolean") return void 0;
321
+ return {
322
+ select: row.can_select,
323
+ insert: row.can_insert === true,
324
+ update: row.can_update === true,
325
+ delete: row.can_delete === true
326
+ };
327
+ }
300
328
  function groupByTable(rows) {
301
329
  const map = /* @__PURE__ */ new Map();
302
330
  for (const row of rows) {
@@ -366,9 +394,16 @@ function buildQueryString(args, maxRows) {
366
394
  params.set("limit", String(limit));
367
395
  return params.toString();
368
396
  }
369
- function requireFilter(filter) {
370
- if (!filter || filter.length === 0) {
371
- throw new Error("At least one filter is required -- refusing a table-wide update/delete.");
397
+ function requireKeyFilter(filter, keyColumns2) {
398
+ if (keyColumns2.length === 0) {
399
+ throw new Error("This table has no primary key, so a single row can't be named -- refusing the update/delete.");
400
+ }
401
+ const pinned = new Set((filter ?? []).filter((f) => f.op === "eq").map((f) => f.column));
402
+ const unpinned = keyColumns2.filter((c) => !pinned.has(c));
403
+ if (unpinned.length > 0) {
404
+ throw new Error(
405
+ `The filter must include eq on the primary key (${unpinned.join(", ")}), so it changes one row -- refusing a filter that could match many. To change several rows, make one call per row.`
406
+ );
372
407
  }
373
408
  return filter;
374
409
  }
@@ -442,10 +477,10 @@ function buildSupabaseMcpServer(deps) {
442
477
  const taken = /* @__PURE__ */ new Set(["search_docs"]);
443
478
  for (const table of tables) {
444
479
  registerQueryTool(server, table, client, cfg, taken);
445
- const grants = cfg.writes.get(table.name);
446
- if (grants?.has("insert")) registerInsertTool(server, table, client, taken);
447
- if (grants?.has("update")) registerUpdateTool(server, table, client, taken);
448
- if (grants?.has("delete")) registerDeleteTool(server, table, client, taken);
480
+ const { ops } = writeTools(table, cfg);
481
+ if (ops.includes("insert")) registerInsertTool(server, table, client, taken);
482
+ if (ops.includes("update")) registerUpdateTool(server, table, client, taken);
483
+ if (ops.includes("delete")) registerDeleteTool(server, table, client, taken);
449
484
  }
450
485
  for (const fn of cfg.rpc) {
451
486
  registerRpcTool(server, fn, client, deps.openapi, taken);
@@ -537,23 +572,30 @@ function registerInsertTool(server, table, client, taken) {
537
572
  async (args) => toResult(() => client.insert(table.name, args.body))
538
573
  );
539
574
  }
575
+ function keyColumns(table) {
576
+ return table.columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
577
+ }
578
+ function keyFilterShape(table, verb) {
579
+ const keys = keyColumns(table);
580
+ const message = `The filter must include eq on ${keys.join(", ")} -- refusing ${verb === "update" ? "an update" : "a delete"} that could match many rows.`;
581
+ return filterShape(table.columns).refine((f) => !!f && keys.every((k) => f.some((x) => x.column === k && x.op === "eq")), { message }).describe(`Filters, ANDed together. Must include eq on ${keys.join(", ")} (the primary key), so one row changes.`);
582
+ }
540
583
  function registerUpdateTool(server, table, client, taken) {
541
584
  const name = toolName("update", table.name, taken);
542
585
  const setShape = {};
543
586
  for (const col of table.columns) setShape[col.name] = columnToZod(col).optional();
587
+ const keys = keyColumns(table);
544
588
  server.registerTool(
545
589
  name,
546
590
  {
547
- description: `Update rows in ${table.name}. At least one filter is required.`,
591
+ description: `Update one row in ${table.name}, named by its primary key (${keys.join(", ")}) with eq in the filter.`,
548
592
  inputSchema: {
549
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
550
- message: "At least one filter is required -- refusing a table-wide update."
551
- }),
593
+ filter: keyFilterShape(table, "update"),
552
594
  set: import_zod2.z.object(setShape)
553
595
  },
554
596
  annotations: { readOnlyHint: false, destructiveHint: true }
555
597
  },
556
- async (args) => toResult(() => client.update(table.name, filterQueryString(requireFilter(args.filter)), args.set))
598
+ async (args) => toResult(() => client.update(table.name, filterQueryString(requireKeyFilter(args.filter, keys)), args.set))
557
599
  );
558
600
  }
559
601
  function registerDeleteTool(server, table, client, taken) {
@@ -561,15 +603,13 @@ function registerDeleteTool(server, table, client, taken) {
561
603
  server.registerTool(
562
604
  name,
563
605
  {
564
- description: `Delete rows from ${table.name}. At least one filter is required.`,
606
+ description: `Delete one row from ${table.name}, named by its primary key (${keyColumns(table).join(", ")}) with eq in the filter.`,
565
607
  inputSchema: {
566
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
567
- message: "At least one filter is required -- refusing a table-wide delete."
568
- })
608
+ filter: keyFilterShape(table, "delete")
569
609
  },
570
610
  annotations: { readOnlyHint: false, destructiveHint: true }
571
611
  },
572
- async (args) => toResult(() => client.delete(table.name, filterQueryString(requireFilter(args.filter))))
612
+ async (args) => toResult(() => client.delete(table.name, filterQueryString(requireKeyFilter(args.filter, keyColumns(table)))))
573
613
  );
574
614
  }
575
615
  function registerRpcTool(server, fn, client, openapi, taken) {
@@ -624,7 +664,7 @@ function registerDiagnosticsResource(server, warnings) {
624
664
  server.registerResource(
625
665
  "exposify-diagnostics",
626
666
  "exposify://diagnostics",
627
- { title: "exposify-supabase diagnostics", mimeType: "application/json" },
667
+ { title: "@exposify/supabase diagnostics", mimeType: "application/json" },
628
668
  async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(warnings, null, 2) }] })
629
669
  );
630
670
  }
@@ -698,12 +738,12 @@ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
698
738
 
699
739
  // src/startup.ts
700
740
  var import_core4 = require("@exposify/core");
701
- var LOG_PREFIX = "[exposify-supabase]";
702
- var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `exposify-supabase generate`";
741
+ var LOG_PREFIX = "[@exposify/supabase]";
742
+ var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `npx @exposify/supabase generate`";
703
743
  function supabaseToolsPhrase(ladder, cfg, hasDocs) {
704
744
  const query = ladder.tables.length;
705
745
  let write = 0;
706
- for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
746
+ for (const table of ladder.tables) write += writeTools(table, cfg).ops.length;
707
747
  const rpc = cfg.rpc.length;
708
748
  const fn = Object.keys(cfg.functions).length;
709
749
  const parts = [`${query} query`];
@@ -730,6 +770,26 @@ function logColdStart(opts) {
730
770
  const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
731
771
  console.log((0, import_core4.formatReady)(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
732
772
  }
773
+ const keyless = ladder.tables.flatMap((t) => writeTools(t, cfg).keyless.map((op) => `${t.name}.${op}`));
774
+ if (keyless.length > 0) {
775
+ console.warn(
776
+ (0, import_core4.formatWarning)(
777
+ LOG_PREFIX,
778
+ `writes opted in on tables without a primary key, so they aren't tools (an update or delete must name its row by key): ${keyless.join(", ")}`,
779
+ "add a primary key to the table"
780
+ )
781
+ );
782
+ }
783
+ const ungranted = ladder.tables.flatMap((t) => writeTools(t, cfg).ungranted.map((op) => ({ table: t.name, op })));
784
+ if (ungranted.length > 0) {
785
+ console.warn(
786
+ (0, import_core4.formatWarning)(
787
+ LOG_PREFIX,
788
+ `writes opted in but not granted to the authenticated role, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
789
+ ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
790
+ )
791
+ );
792
+ }
733
793
  if (ladder.mode === "manifest") {
734
794
  console.warn(
735
795
  (0, import_core4.formatWarning)(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
@@ -797,7 +857,7 @@ function aiReady(options = {}) {
797
857
  manifest: options.manifest
798
858
  }).then((ladder) => {
799
859
  for (const w of ladder.warnings) {
800
- console.error(`[exposify-supabase] degradation ladder: ${w.rung}: ${w.reason}`);
860
+ console.error(`[@exposify/supabase] degradation ladder: ${w.rung}: ${w.reason}`);
801
861
  }
802
862
  return ladder;
803
863
  });
@@ -834,7 +894,7 @@ function aiReady(options = {}) {
834
894
  return { status: res.status, bodyText: await res.text() };
835
895
  };
836
896
  return buildSupabaseMcpServer({
837
- appName: "exposify-supabase",
897
+ appName: "@exposify/supabase",
838
898
  appVersion: "0.0.0",
839
899
  tables: ladder.tables,
840
900
  cfg,
@@ -861,6 +921,16 @@ function aiReady(options = {}) {
861
921
  const authRequired = options.authRequired ?? (envAuth !== void 0 || (0, import_core5.isHostedResolver)(auth));
862
922
  if (!auth) return core;
863
923
  const brokerGate = authRequired ? (0, import_core5.createBrokerGate)(auth) : null;
924
+ const authKey = anonKey || env?.get("SUPABASE_PUBLISHABLE_KEY") || "";
925
+ let supabaseAuth2;
926
+ const supabaseAuthOnce = () => {
927
+ if (!supabaseUrl || !authKey) return Promise.resolve(void 0);
928
+ supabaseAuth2 ??= (0, import_core5.readSupabaseAuth)(supabaseUrl, authKey, fetchImpl).then((found) => {
929
+ if (!found) supabaseAuth2 = void 0;
930
+ return found;
931
+ });
932
+ return supabaseAuth2;
933
+ };
864
934
  const functionUrl = supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : null;
865
935
  return async (req) => {
866
936
  let open = false;
@@ -868,7 +938,13 @@ function aiReady(options = {}) {
868
938
  const own = (0, import_core5.publicRequestUrl)(req.url, req.headers);
869
939
  const isPrm = own.endsWith(PRM_SUFFIX);
870
940
  const resource = brokerGate.resourceFor(functionUrl ?? (isPrm ? own.slice(0, -PRM_SUFFIX.length) : own));
871
- open = await (0, import_core5.reportAppLogin)(auth, resource, null, { stacks: ["@supabase/supabase-js"], why: "Supabase Auth sign-in for agents is the Supabase preset in the dashboard's sign-in methods.", log: logReady }) === "open";
941
+ const supabase = await supabaseAuthOnce();
942
+ open = await (0, import_core5.reportAppLogin)(auth, resource, null, {
943
+ stacks: ["@supabase/supabase-js"],
944
+ why: supabase ? "Your Supabase Auth is turned off for agents in the dashboard." : "Couldn't read your project's Supabase Auth settings (/auth/v1/settings with the anon key), so Connect can't offer it yet.",
945
+ log: logReady,
946
+ ...supabase ? { supabase } : {}
947
+ }) === "open";
872
948
  if (open) {
873
949
  if (isPrm) return new Response("not found", { status: 404 });
874
950
  } else {
@@ -881,7 +957,7 @@ function aiReady(options = {}) {
881
957
  try {
882
958
  resolved = await auth(req.headers, {});
883
959
  } catch (err) {
884
- console.error("exposify-supabase: auth resolver failed", err);
960
+ console.error("@exposify/supabase: auth resolver failed", err);
885
961
  return Response.json({ error: "internal MCP server error" }, { status: 503 });
886
962
  }
887
963
  if (authRequired && !open && !resolved.get("authorization")) {
package/dist/index.d.cts CHANGED
@@ -18,6 +18,18 @@ interface TableSchema {
18
18
  /** From `exposify_meta()`'s `table_comment` (a `COMMENT ON TABLE`). */
19
19
  description?: string;
20
20
  columns: ColumnSchema[];
21
+ /**
22
+ * What the `authenticated` role may do on the table (`exposify_meta()`'s
23
+ * `can_*` columns). Absent when the database still has the older function,
24
+ * which didn't report grants: then nothing is filtered on them.
25
+ */
26
+ grants?: TableGrants;
27
+ }
28
+ interface TableGrants {
29
+ select: boolean;
30
+ insert: boolean;
31
+ update: boolean;
32
+ delete: boolean;
21
33
  }
22
34
  /** One row of `exposify_meta()` -- the migration returns one row per column. */
23
35
  interface MetaRow {
@@ -26,6 +38,11 @@ interface MetaRow {
26
38
  table_comment: string | null;
27
39
  column_name: string;
28
40
  column_comment: string | null;
41
+ /** Absent from the older `exposify_meta()` (before the `_v2` migration). */
42
+ can_select?: boolean;
43
+ can_insert?: boolean;
44
+ can_update?: boolean;
45
+ can_delete?: boolean;
29
46
  }
30
47
  /** Registered sibling edge function exposed as a tool. */
31
48
  interface FunctionToolConfig {
@@ -126,7 +143,7 @@ interface AgentifySupabaseOptions {
126
143
  authRequired?: boolean;
127
144
  /** Injectable for tests; defaults to global `fetch`. */
128
145
  fetchImpl?: typeof fetch;
129
- /** Bundled fallback manifest (from `exposify-supabase generate`), used when runtime introspection fails. */
146
+ /** Bundled fallback manifest (from `@exposify/supabase generate`), used when runtime introspection fails. */
130
147
  manifest?: unknown;
131
148
  rateLimit?: number;
132
149
  rateWindowMs?: number;
@@ -196,7 +213,7 @@ interface ExposifyManifest {
196
213
  generatedAt: string;
197
214
  tables: TableSchema[];
198
215
  }
199
- /** Built by `npx exposify-supabase generate` against a local/linked project. */
216
+ /** Built by `npx @exposify/supabase generate` against a local/linked project. */
200
217
  declare function buildManifest(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[], generatedAt: string): ExposifyManifest;
201
218
  /**
202
219
  * Defensive load: a manifest is bundled JSON shipped alongside the function
@@ -217,6 +234,11 @@ declare function loadManifest(raw: unknown): ExposifyManifest | null;
217
234
  * appears, silently, exactly like a table PostgREST doesn't know about at
218
235
  * all. There is no override: an RLS-less table is invisible even if the
219
236
  * owner explicitly lists it in `writes`.
237
+ *
238
+ * The OpenAPI document is fetched with the service role key, so it lists
239
+ * tables the `authenticated` role can't touch. A table `exposify_meta()`
240
+ * reports no SELECT grant for is left out too: every call to its query tool
241
+ * would answer 403.
220
242
  */
221
243
  declare function introspectTables(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[]): TableSchema[];
222
244
 
package/dist/index.d.ts CHANGED
@@ -18,6 +18,18 @@ interface TableSchema {
18
18
  /** From `exposify_meta()`'s `table_comment` (a `COMMENT ON TABLE`). */
19
19
  description?: string;
20
20
  columns: ColumnSchema[];
21
+ /**
22
+ * What the `authenticated` role may do on the table (`exposify_meta()`'s
23
+ * `can_*` columns). Absent when the database still has the older function,
24
+ * which didn't report grants: then nothing is filtered on them.
25
+ */
26
+ grants?: TableGrants;
27
+ }
28
+ interface TableGrants {
29
+ select: boolean;
30
+ insert: boolean;
31
+ update: boolean;
32
+ delete: boolean;
21
33
  }
22
34
  /** One row of `exposify_meta()` -- the migration returns one row per column. */
23
35
  interface MetaRow {
@@ -26,6 +38,11 @@ interface MetaRow {
26
38
  table_comment: string | null;
27
39
  column_name: string;
28
40
  column_comment: string | null;
41
+ /** Absent from the older `exposify_meta()` (before the `_v2` migration). */
42
+ can_select?: boolean;
43
+ can_insert?: boolean;
44
+ can_update?: boolean;
45
+ can_delete?: boolean;
29
46
  }
30
47
  /** Registered sibling edge function exposed as a tool. */
31
48
  interface FunctionToolConfig {
@@ -126,7 +143,7 @@ interface AgentifySupabaseOptions {
126
143
  authRequired?: boolean;
127
144
  /** Injectable for tests; defaults to global `fetch`. */
128
145
  fetchImpl?: typeof fetch;
129
- /** Bundled fallback manifest (from `exposify-supabase generate`), used when runtime introspection fails. */
146
+ /** Bundled fallback manifest (from `@exposify/supabase generate`), used when runtime introspection fails. */
130
147
  manifest?: unknown;
131
148
  rateLimit?: number;
132
149
  rateWindowMs?: number;
@@ -196,7 +213,7 @@ interface ExposifyManifest {
196
213
  generatedAt: string;
197
214
  tables: TableSchema[];
198
215
  }
199
- /** Built by `npx exposify-supabase generate` against a local/linked project. */
216
+ /** Built by `npx @exposify/supabase generate` against a local/linked project. */
200
217
  declare function buildManifest(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[], generatedAt: string): ExposifyManifest;
201
218
  /**
202
219
  * Defensive load: a manifest is bundled JSON shipped alongside the function
@@ -217,6 +234,11 @@ declare function loadManifest(raw: unknown): ExposifyManifest | null;
217
234
  * appears, silently, exactly like a table PostgREST doesn't know about at
218
235
  * all. There is no override: an RLS-less table is invisible even if the
219
236
  * owner explicitly lists it in `writes`.
237
+ *
238
+ * The OpenAPI document is fetched with the service role key, so it lists
239
+ * tables the `authenticated` role can't touch. A table `exposify_meta()`
240
+ * reports no SELECT grant for is left out too: every call to its query tool
241
+ * would answer 403.
220
242
  */
221
243
  declare function introspectTables(openapi: PostgrestOpenApiDoc, metaRows: MetaRow[]): TableSchema[];
222
244
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { createBrokerGate, createWebMcpHandler, formatFailed as formatFailed2, publicRequestUrl, formatWarning as formatWarning2, hostedResolverFromEnv, isHostedResolver, readEnv, reportAppLogin, startupLogEnabled } from "@exposify/core";
2
+ import { createBrokerGate, createWebMcpHandler, formatFailed as formatFailed2, publicRequestUrl, formatWarning as formatWarning2, hostedResolverFromEnv, isHostedResolver, readEnv, readSupabaseAuth, reportAppLogin, startupLogEnabled } from "@exposify/core";
3
3
 
4
4
  // src/kb.ts
5
5
  import { KnowledgeBase } from "@exposify/core";
@@ -70,7 +70,7 @@ function warnOnce(message) {
70
70
  if (warned.has(message)) return;
71
71
  warned.add(message);
72
72
  try {
73
- console.warn(`[exposify-supabase] ${message}`);
73
+ console.warn(`[@exposify/supabase] ${message}`);
74
74
  } catch {
75
75
  }
76
76
  }
@@ -94,6 +94,22 @@ function resolveSupabaseConfig(options = {}, hostedBroker2 = false) {
94
94
  rateWindowMs: options.rateWindowMs
95
95
  };
96
96
  }
97
+ function writeToolOps(table, opted) {
98
+ const ops = [];
99
+ const ungranted = [];
100
+ const keyless = [];
101
+ const hasKey = table.columns.some((c) => c.isPrimaryKey);
102
+ for (const op of WRITE_OPS) {
103
+ if (!opted?.has(op)) continue;
104
+ if (table.grants && !table.grants[op]) ungranted.push(op);
105
+ else if (op !== "insert" && !hasKey) keyless.push(op);
106
+ else ops.push(op);
107
+ }
108
+ return { ops, ungranted, keyless };
109
+ }
110
+ function writeTools(table, cfg) {
111
+ return writeToolOps(table, cfg.writes.get(table.name));
112
+ }
97
113
 
98
114
  // src/postgrest-client.ts
99
115
  var FORWARDED_HEADERS = ["authorization", "apikey"];
@@ -169,7 +185,7 @@ async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
169
185
  });
170
186
  if (res.status === 404) {
171
187
  throw new MissingExposifyMetaError(
172
- "exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify-supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
188
+ "exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx @exposify/supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
173
189
  );
174
190
  }
175
191
  if (!res.ok) {
@@ -238,6 +254,8 @@ function introspectTables(openapi, metaRows) {
238
254
  const rlsEnabled = rows[0].rls_enabled;
239
255
  if (!rlsEnabled) continue;
240
256
  if (!exposedInOpenApi.has(name)) continue;
257
+ const grants = grantsOf(rows[0]);
258
+ if (grants && !grants.select) continue;
241
259
  const openApiColumns = new Map(columnsFromOpenApi(openapi, name).map((c) => [c.name, c]));
242
260
  const columns = [];
243
261
  for (const row of rows) {
@@ -255,11 +273,21 @@ function introspectTables(openapi, metaRows) {
255
273
  name,
256
274
  rlsEnabled: true,
257
275
  description: rows[0].table_comment ?? void 0,
258
- columns
276
+ columns,
277
+ ...grants ? { grants } : {}
259
278
  });
260
279
  }
261
280
  return tables.sort((a, b) => a.name.localeCompare(b.name));
262
281
  }
282
+ function grantsOf(row) {
283
+ if (typeof row.can_select !== "boolean") return void 0;
284
+ return {
285
+ select: row.can_select,
286
+ insert: row.can_insert === true,
287
+ update: row.can_update === true,
288
+ delete: row.can_delete === true
289
+ };
290
+ }
263
291
  function groupByTable(rows) {
264
292
  const map = /* @__PURE__ */ new Map();
265
293
  for (const row of rows) {
@@ -332,9 +360,16 @@ function buildQueryString(args, maxRows) {
332
360
  params.set("limit", String(limit));
333
361
  return params.toString();
334
362
  }
335
- function requireFilter(filter) {
336
- if (!filter || filter.length === 0) {
337
- throw new Error("At least one filter is required -- refusing a table-wide update/delete.");
363
+ function requireKeyFilter(filter, keyColumns2) {
364
+ if (keyColumns2.length === 0) {
365
+ throw new Error("This table has no primary key, so a single row can't be named -- refusing the update/delete.");
366
+ }
367
+ const pinned = new Set((filter ?? []).filter((f) => f.op === "eq").map((f) => f.column));
368
+ const unpinned = keyColumns2.filter((c) => !pinned.has(c));
369
+ if (unpinned.length > 0) {
370
+ throw new Error(
371
+ `The filter must include eq on the primary key (${unpinned.join(", ")}), so it changes one row -- refusing a filter that could match many. To change several rows, make one call per row.`
372
+ );
338
373
  }
339
374
  return filter;
340
375
  }
@@ -408,10 +443,10 @@ function buildSupabaseMcpServer(deps) {
408
443
  const taken = /* @__PURE__ */ new Set(["search_docs"]);
409
444
  for (const table of tables) {
410
445
  registerQueryTool(server, table, client, cfg, taken);
411
- const grants = cfg.writes.get(table.name);
412
- if (grants?.has("insert")) registerInsertTool(server, table, client, taken);
413
- if (grants?.has("update")) registerUpdateTool(server, table, client, taken);
414
- if (grants?.has("delete")) registerDeleteTool(server, table, client, taken);
446
+ const { ops } = writeTools(table, cfg);
447
+ if (ops.includes("insert")) registerInsertTool(server, table, client, taken);
448
+ if (ops.includes("update")) registerUpdateTool(server, table, client, taken);
449
+ if (ops.includes("delete")) registerDeleteTool(server, table, client, taken);
415
450
  }
416
451
  for (const fn of cfg.rpc) {
417
452
  registerRpcTool(server, fn, client, deps.openapi, taken);
@@ -503,23 +538,30 @@ function registerInsertTool(server, table, client, taken) {
503
538
  async (args) => toResult(() => client.insert(table.name, args.body))
504
539
  );
505
540
  }
541
+ function keyColumns(table) {
542
+ return table.columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
543
+ }
544
+ function keyFilterShape(table, verb) {
545
+ const keys = keyColumns(table);
546
+ const message = `The filter must include eq on ${keys.join(", ")} -- refusing ${verb === "update" ? "an update" : "a delete"} that could match many rows.`;
547
+ return filterShape(table.columns).refine((f) => !!f && keys.every((k) => f.some((x) => x.column === k && x.op === "eq")), { message }).describe(`Filters, ANDed together. Must include eq on ${keys.join(", ")} (the primary key), so one row changes.`);
548
+ }
506
549
  function registerUpdateTool(server, table, client, taken) {
507
550
  const name = toolName("update", table.name, taken);
508
551
  const setShape = {};
509
552
  for (const col of table.columns) setShape[col.name] = columnToZod(col).optional();
553
+ const keys = keyColumns(table);
510
554
  server.registerTool(
511
555
  name,
512
556
  {
513
- description: `Update rows in ${table.name}. At least one filter is required.`,
557
+ description: `Update one row in ${table.name}, named by its primary key (${keys.join(", ")}) with eq in the filter.`,
514
558
  inputSchema: {
515
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
516
- message: "At least one filter is required -- refusing a table-wide update."
517
- }),
559
+ filter: keyFilterShape(table, "update"),
518
560
  set: z2.object(setShape)
519
561
  },
520
562
  annotations: { readOnlyHint: false, destructiveHint: true }
521
563
  },
522
- async (args) => toResult(() => client.update(table.name, filterQueryString(requireFilter(args.filter)), args.set))
564
+ async (args) => toResult(() => client.update(table.name, filterQueryString(requireKeyFilter(args.filter, keys)), args.set))
523
565
  );
524
566
  }
525
567
  function registerDeleteTool(server, table, client, taken) {
@@ -527,15 +569,13 @@ function registerDeleteTool(server, table, client, taken) {
527
569
  server.registerTool(
528
570
  name,
529
571
  {
530
- description: `Delete rows from ${table.name}. At least one filter is required.`,
572
+ description: `Delete one row from ${table.name}, named by its primary key (${keyColumns(table).join(", ")}) with eq in the filter.`,
531
573
  inputSchema: {
532
- filter: filterShape(table.columns).refine((f) => !!f && f.length > 0, {
533
- message: "At least one filter is required -- refusing a table-wide delete."
534
- })
574
+ filter: keyFilterShape(table, "delete")
535
575
  },
536
576
  annotations: { readOnlyHint: false, destructiveHint: true }
537
577
  },
538
- async (args) => toResult(() => client.delete(table.name, filterQueryString(requireFilter(args.filter))))
578
+ async (args) => toResult(() => client.delete(table.name, filterQueryString(requireKeyFilter(args.filter, keyColumns(table)))))
539
579
  );
540
580
  }
541
581
  function registerRpcTool(server, fn, client, openapi, taken) {
@@ -590,7 +630,7 @@ function registerDiagnosticsResource(server, warnings) {
590
630
  server.registerResource(
591
631
  "exposify-diagnostics",
592
632
  "exposify://diagnostics",
593
- { title: "exposify-supabase diagnostics", mimeType: "application/json" },
633
+ { title: "@exposify/supabase diagnostics", mimeType: "application/json" },
594
634
  async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(warnings, null, 2) }] })
595
635
  );
596
636
  }
@@ -664,12 +704,12 @@ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
664
704
 
665
705
  // src/startup.ts
666
706
  import { formatFailed, formatReady, formatWarning } from "@exposify/core";
667
- var LOG_PREFIX = "[exposify-supabase]";
668
- var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `exposify-supabase generate`";
707
+ var LOG_PREFIX = "[@exposify/supabase]";
708
+ var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `npx @exposify/supabase generate`";
669
709
  function supabaseToolsPhrase(ladder, cfg, hasDocs) {
670
710
  const query = ladder.tables.length;
671
711
  let write = 0;
672
- for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
712
+ for (const table of ladder.tables) write += writeTools(table, cfg).ops.length;
673
713
  const rpc = cfg.rpc.length;
674
714
  const fn = Object.keys(cfg.functions).length;
675
715
  const parts = [`${query} query`];
@@ -696,6 +736,26 @@ function logColdStart(opts) {
696
736
  const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
697
737
  console.log(formatReady(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
698
738
  }
739
+ const keyless = ladder.tables.flatMap((t) => writeTools(t, cfg).keyless.map((op) => `${t.name}.${op}`));
740
+ if (keyless.length > 0) {
741
+ console.warn(
742
+ formatWarning(
743
+ LOG_PREFIX,
744
+ `writes opted in on tables without a primary key, so they aren't tools (an update or delete must name its row by key): ${keyless.join(", ")}`,
745
+ "add a primary key to the table"
746
+ )
747
+ );
748
+ }
749
+ const ungranted = ladder.tables.flatMap((t) => writeTools(t, cfg).ungranted.map((op) => ({ table: t.name, op })));
750
+ if (ungranted.length > 0) {
751
+ console.warn(
752
+ formatWarning(
753
+ LOG_PREFIX,
754
+ `writes opted in but not granted to the authenticated role, so they aren't tools: ${ungranted.map((w) => `${w.table}.${w.op}`).join(", ")}`,
755
+ ungranted.map((w) => `grant ${w.op} on public.${w.table} to authenticated;`).join(" ")
756
+ )
757
+ );
758
+ }
699
759
  if (ladder.mode === "manifest") {
700
760
  console.warn(
701
761
  formatWarning(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
@@ -763,7 +823,7 @@ function aiReady(options = {}) {
763
823
  manifest: options.manifest
764
824
  }).then((ladder) => {
765
825
  for (const w of ladder.warnings) {
766
- console.error(`[exposify-supabase] degradation ladder: ${w.rung}: ${w.reason}`);
826
+ console.error(`[@exposify/supabase] degradation ladder: ${w.rung}: ${w.reason}`);
767
827
  }
768
828
  return ladder;
769
829
  });
@@ -800,7 +860,7 @@ function aiReady(options = {}) {
800
860
  return { status: res.status, bodyText: await res.text() };
801
861
  };
802
862
  return buildSupabaseMcpServer({
803
- appName: "exposify-supabase",
863
+ appName: "@exposify/supabase",
804
864
  appVersion: "0.0.0",
805
865
  tables: ladder.tables,
806
866
  cfg,
@@ -827,6 +887,16 @@ function aiReady(options = {}) {
827
887
  const authRequired = options.authRequired ?? (envAuth !== void 0 || isHostedResolver(auth));
828
888
  if (!auth) return core;
829
889
  const brokerGate = authRequired ? createBrokerGate(auth) : null;
890
+ const authKey = anonKey || env?.get("SUPABASE_PUBLISHABLE_KEY") || "";
891
+ let supabaseAuth2;
892
+ const supabaseAuthOnce = () => {
893
+ if (!supabaseUrl || !authKey) return Promise.resolve(void 0);
894
+ supabaseAuth2 ??= readSupabaseAuth(supabaseUrl, authKey, fetchImpl).then((found) => {
895
+ if (!found) supabaseAuth2 = void 0;
896
+ return found;
897
+ });
898
+ return supabaseAuth2;
899
+ };
830
900
  const functionUrl = supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : null;
831
901
  return async (req) => {
832
902
  let open = false;
@@ -834,7 +904,13 @@ function aiReady(options = {}) {
834
904
  const own = publicRequestUrl(req.url, req.headers);
835
905
  const isPrm = own.endsWith(PRM_SUFFIX);
836
906
  const resource = brokerGate.resourceFor(functionUrl ?? (isPrm ? own.slice(0, -PRM_SUFFIX.length) : own));
837
- open = await reportAppLogin(auth, resource, null, { stacks: ["@supabase/supabase-js"], why: "Supabase Auth sign-in for agents is the Supabase preset in the dashboard's sign-in methods.", log: logReady }) === "open";
907
+ const supabase = await supabaseAuthOnce();
908
+ open = await reportAppLogin(auth, resource, null, {
909
+ stacks: ["@supabase/supabase-js"],
910
+ why: supabase ? "Your Supabase Auth is turned off for agents in the dashboard." : "Couldn't read your project's Supabase Auth settings (/auth/v1/settings with the anon key), so Connect can't offer it yet.",
911
+ log: logReady,
912
+ ...supabase ? { supabase } : {}
913
+ }) === "open";
838
914
  if (open) {
839
915
  if (isPrm) return new Response("not found", { status: 404 });
840
916
  } else {
@@ -847,7 +923,7 @@ function aiReady(options = {}) {
847
923
  try {
848
924
  resolved = await auth(req.headers, {});
849
925
  } catch (err) {
850
- console.error("exposify-supabase: auth resolver failed", err);
926
+ console.error("@exposify/supabase: auth resolver failed", err);
851
927
  return Response.json({ error: "internal MCP server error" }, { status: 503 });
852
928
  }
853
929
  if (authRequired && !open && !resolved.get("authorization")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/supabase",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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.3.14",
30
+ "@exposify/core": "0.3.16",
31
31
  "@modelcontextprotocol/sdk": "^1.12.0",
32
32
  "zod": "^3.24.0"
33
33
  },
@@ -1,9 +1,13 @@
1
- -- exposify-supabase: schema introspection function.
1
+ -- @exposify/supabase: schema introspection function.
2
2
  --
3
3
  -- Exposes, per PostgREST's RPC convention (POST /rpc/exposify_meta), one row
4
4
  -- per column of every ordinary table in the `public` schema: whether Row
5
- -- Level Security is enabled on its table, and the table/column comments an
6
- -- owner wrote via `COMMENT ON TABLE` / `COMMENT ON COLUMN`.
5
+ -- Level Security is enabled on its table, the table/column comments an
6
+ -- owner wrote via `COMMENT ON TABLE` / `COMMENT ON COLUMN`, and what the
7
+ -- `authenticated` role (the one agents' tool calls run as) is granted on the
8
+ -- table. Without a grant every call answers 403, however the RLS policies
9
+ -- read: Supabase's newer local images no longer grant `anon`/`authenticated`
10
+ -- on new public tables, so a table can have RLS on and still be unreachable.
7
11
  --
8
12
  -- This is the runtime source of truth for the DB exposure policy (RLS-gated,
9
13
  -- reads auto) and for tool/param descriptions -- see the design spec's "DB
@@ -20,13 +24,22 @@
20
24
  -- alter function public.agentify_meta() rename to exposify_meta();
21
25
  -- or drop the old one after confirming nothing else calls it:
22
26
  -- drop function public.agentify_meta();
23
- create or replace function public.exposify_meta()
27
+ --
28
+ -- The grant columns were added later, as a new migration
29
+ -- (`<timestamp>_exposify_meta_v2.sql`): adding output columns changes the
30
+ -- function's return type, which `create or replace` can't do, hence the drop.
31
+ drop function if exists public.exposify_meta();
32
+ create function public.exposify_meta()
24
33
  returns table (
25
34
  table_name text,
26
35
  rls_enabled boolean,
27
36
  table_comment text,
28
37
  column_name text,
29
- column_comment text
38
+ column_comment text,
39
+ can_select boolean,
40
+ can_insert boolean,
41
+ can_update boolean,
42
+ can_delete boolean
30
43
  )
31
44
  language sql
32
45
  security definer
@@ -37,7 +50,12 @@ as $$
37
50
  c.relrowsecurity as rls_enabled,
38
51
  obj_description(c.oid, 'pg_class') as table_comment,
39
52
  a.attname as column_name,
40
- col_description(c.oid, a.attnum) as column_comment
53
+ col_description(c.oid, a.attnum) as column_comment,
54
+ -- "any column" so a grant on some columns counts, as PostgREST sees it.
55
+ has_any_column_privilege('authenticated', c.oid, 'SELECT') as can_select,
56
+ has_any_column_privilege('authenticated', c.oid, 'INSERT') as can_insert,
57
+ has_any_column_privilege('authenticated', c.oid, 'UPDATE') as can_update,
58
+ has_table_privilege('authenticated', c.oid, 'DELETE') as can_delete
41
59
  from pg_catalog.pg_class c
42
60
  join pg_catalog.pg_namespace n on n.oid = c.relnamespace
43
61
  join pg_catalog.pg_attribute a on a.attrelid = c.oid