@exposify/supabase 0.3.4 → 0.3.5

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
@@ -227,6 +227,47 @@ Ask the agent to query or mutate data. **It should succeed for that user's
227
227
  own rows and be refused everyone else's** — that's the definition of done:
228
228
  RLS enforcement, not anything in this SDK, is what makes that true.
229
229
 
230
+ ## Long-lived tokens for clients that can't refresh (token broker)
231
+
232
+ Supabase user JWTs expire (default 1h) and their refresh tokens rotate; a plain
233
+ MCP client can hold a bearer token but can't refresh it. Pass an `auth`
234
+ resolver (any `(incoming: Headers) => Headers | Promise<Headers>`, such as
235
+ `broker.resolver` from [`@exposify/token-broker`](../token-broker/README.md))
236
+ and the client sends a long-lived broker token instead:
237
+
238
+ ```ts
239
+ // supabase/functions/mcp/index.ts
240
+ import { aiReady } from "npm:@exposify/supabase";
241
+ import { broker } from "./broker.ts"; // createTokenBroker(...) over a shared store
242
+
243
+ Deno.serve(aiReady({ auth: broker.resolver, authRequired: true, writes: ["todos.*"] }));
244
+ ```
245
+
246
+ - The resolver receives the request's original headers (the `extb_...` token)
247
+ and its result **replaces** them for PostgREST and `functions` calls, so RLS
248
+ applies as that user and the broker token is never forwarded. `apikey` and a
249
+ missing `authorization` still fall back to the anon key.
250
+ - `docsAuth: "required"` checks the raw incoming headers, so a broker token
251
+ counts as authenticated.
252
+ - A broker resolver returns empty headers for an unknown/revoked token or a
253
+ failed refresh. By default the request then runs as the `anon` role (RLS
254
+ decides). Set `authRequired: true` to answer `401` instead. Recommended.
255
+ - A resolver that throws yields a 503.
256
+
257
+ **Deployment constraints** (not verified end to end, see HANDOVER):
258
+
259
+ - The broker's OAuth server (`listenTokenBroker`) is a Node HTTP server and
260
+ can't run in an Edge Function; run it as a separate service.
261
+ - The edge function builds its own `createTokenBroker(...)` over the **same
262
+ persistent `TokenStore`** (Redis/Postgres, see the token-broker README's
263
+ `ioredis` example) and the same `TOKEN_BROKER_ENCRYPTION_KEY`. The in-memory
264
+ store is per-process and won't work.
265
+ - The broker service does proactive refresh; short-lived edge instances rely on
266
+ the resolver's lazy refresh when `expiresAt` has passed.
267
+ - `@exposify/token-broker` imports `node:crypto` (`scryptSync`, AES-256-GCM,
268
+ RS256). Whether that works under Supabase's Deno runtime has **not** been
269
+ tested; try `supabase functions serve` before depending on it.
270
+
230
271
  ## Knowledge base
231
272
 
232
273
  Any markdown files matching the `docs` glob (default `docs/**/*.md`,
package/dist/index.cjs CHANGED
@@ -674,7 +674,8 @@ function aiReady(options = {}) {
674
674
  });
675
675
  return ladderPromise;
676
676
  };
677
- return (0, import_core3.createWebMcpHandler)(
677
+ const resolvedAuth = /* @__PURE__ */ new WeakMap();
678
+ const core = (0, import_core3.createWebMcpHandler)(
678
679
  {
679
680
  getServer: async (req) => {
680
681
  if (!supabaseUrl) {
@@ -684,7 +685,7 @@ function aiReady(options = {}) {
684
685
  }
685
686
  const postgrestUrl = `${supabaseUrl.replace(/\/+$/, "")}/rest/v1`;
686
687
  const incoming = req.headers;
687
- const headers = buildPostgrestHeaders(incoming, anonKey);
688
+ const headers = buildPostgrestHeaders(resolvedAuth.get(req) ?? incoming, anonKey);
688
689
  const ladder = await getLadder(postgrestUrl);
689
690
  const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
690
691
  const invokeFunction = async (name, body) => {
@@ -711,6 +712,22 @@ function aiReady(options = {}) {
711
712
  },
712
713
  { rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
713
714
  );
715
+ const auth = options.auth;
716
+ if (!auth) return core;
717
+ return async (req) => {
718
+ let resolved;
719
+ try {
720
+ resolved = await auth(req.headers);
721
+ } catch (err) {
722
+ console.error("exposify-supabase: auth resolver failed", err);
723
+ return Response.json({ error: "internal MCP server error" }, { status: 503 });
724
+ }
725
+ if (options.authRequired && !resolved.get("authorization")) {
726
+ return Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } });
727
+ }
728
+ resolvedAuth.set(req, resolved);
729
+ return core(req);
730
+ };
714
731
  }
715
732
  // Annotate the CommonJS export names for ESM import in node:
716
733
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { WebMcpHandler } from '@exposify/core';
1
+ import { AuthResolver, WebMcpHandler } from '@exposify/core';
2
2
 
3
3
  type ColumnType = "string" | "number" | "integer" | "boolean" | "object" | "array";
4
4
  interface ColumnSchema {
@@ -72,6 +72,22 @@ interface AgentifySupabaseOptions {
72
72
  * deployed edge function; this option exists for local dev/testing only.
73
73
  */
74
74
  serviceRoleKey?: string;
75
+ /**
76
+ * Resolves the caller's incoming headers into the headers used for
77
+ * PostgREST and edge-function calls, e.g. `broker.resolver` from
78
+ * `@exposify/token-broker` to swap a long-lived `extb_...` token for a live
79
+ * Supabase JWT. Only `authorization`/`apikey` from the result are used; a
80
+ * missing `authorization` falls back to the anon key (unless
81
+ * `authRequired`). Absent: the caller's own `authorization`/`apikey` are
82
+ * forwarded as-is.
83
+ */
84
+ auth?: AuthResolver;
85
+ /**
86
+ * With `auth` set: respond 401 when the resolver returns no `authorization`
87
+ * (unknown/revoked broker token, failed refresh) instead of silently
88
+ * running the request as the `anon` role. Default false.
89
+ */
90
+ authRequired?: boolean;
75
91
  /** Injectable for tests; defaults to global `fetch`. */
76
92
  fetchImpl?: typeof fetch;
77
93
  /** Bundled fallback manifest (from `exposify-supabase generate`), used when runtime introspection fails. */
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { WebMcpHandler } from '@exposify/core';
1
+ import { AuthResolver, WebMcpHandler } from '@exposify/core';
2
2
 
3
3
  type ColumnType = "string" | "number" | "integer" | "boolean" | "object" | "array";
4
4
  interface ColumnSchema {
@@ -72,6 +72,22 @@ interface AgentifySupabaseOptions {
72
72
  * deployed edge function; this option exists for local dev/testing only.
73
73
  */
74
74
  serviceRoleKey?: string;
75
+ /**
76
+ * Resolves the caller's incoming headers into the headers used for
77
+ * PostgREST and edge-function calls, e.g. `broker.resolver` from
78
+ * `@exposify/token-broker` to swap a long-lived `extb_...` token for a live
79
+ * Supabase JWT. Only `authorization`/`apikey` from the result are used; a
80
+ * missing `authorization` falls back to the anon key (unless
81
+ * `authRequired`). Absent: the caller's own `authorization`/`apikey` are
82
+ * forwarded as-is.
83
+ */
84
+ auth?: AuthResolver;
85
+ /**
86
+ * With `auth` set: respond 401 when the resolver returns no `authorization`
87
+ * (unknown/revoked broker token, failed refresh) instead of silently
88
+ * running the request as the `anon` role. Default false.
89
+ */
90
+ authRequired?: boolean;
75
91
  /** Injectable for tests; defaults to global `fetch`. */
76
92
  fetchImpl?: typeof fetch;
77
93
  /** Bundled fallback manifest (from `exposify-supabase generate`), used when runtime introspection fails. */
package/dist/index.js CHANGED
@@ -644,7 +644,8 @@ function aiReady(options = {}) {
644
644
  });
645
645
  return ladderPromise;
646
646
  };
647
- return createWebMcpHandler(
647
+ const resolvedAuth = /* @__PURE__ */ new WeakMap();
648
+ const core = createWebMcpHandler(
648
649
  {
649
650
  getServer: async (req) => {
650
651
  if (!supabaseUrl) {
@@ -654,7 +655,7 @@ function aiReady(options = {}) {
654
655
  }
655
656
  const postgrestUrl = `${supabaseUrl.replace(/\/+$/, "")}/rest/v1`;
656
657
  const incoming = req.headers;
657
- const headers = buildPostgrestHeaders(incoming, anonKey);
658
+ const headers = buildPostgrestHeaders(resolvedAuth.get(req) ?? incoming, anonKey);
658
659
  const ladder = await getLadder(postgrestUrl);
659
660
  const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
660
661
  const invokeFunction = async (name, body) => {
@@ -681,6 +682,22 @@ function aiReady(options = {}) {
681
682
  },
682
683
  { rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
683
684
  );
685
+ const auth = options.auth;
686
+ if (!auth) return core;
687
+ return async (req) => {
688
+ let resolved;
689
+ try {
690
+ resolved = await auth(req.headers);
691
+ } catch (err) {
692
+ console.error("exposify-supabase: auth resolver failed", err);
693
+ return Response.json({ error: "internal MCP server error" }, { status: 503 });
694
+ }
695
+ if (options.authRequired && !resolved.get("authorization")) {
696
+ return Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } });
697
+ }
698
+ resolvedAuth.set(req, resolved);
699
+ return core(req);
700
+ };
684
701
  }
685
702
  export {
686
703
  MANIFEST_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/supabase",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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.8",
30
+ "@exposify/core": "0.3.9",
31
31
  "@modelcontextprotocol/sdk": "^1.12.0",
32
32
  "zod": "^3.24.0"
33
33
  },