@exposify/supabase 0.3.6 → 0.3.8

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/dist/index.cjs CHANGED
@@ -29,10 +29,11 @@ __export(index_exports, {
29
29
  fetchOpenApi: () => fetchOpenApi,
30
30
  introspectTables: () => introspectTables,
31
31
  loadManifest: () => loadManifest,
32
- rpcArgSchema: () => rpcArgSchema
32
+ rpcArgSchema: () => rpcArgSchema,
33
+ supabaseAuth: () => supabaseAuth
33
34
  });
34
35
  module.exports = __toCommonJS(index_exports);
35
- var import_core3 = require("@exposify/core");
36
+ var import_core5 = require("@exposify/core");
36
37
 
37
38
  // src/kb.ts
38
39
  var import_core = require("@exposify/core");
@@ -108,12 +109,20 @@ function warnOnce(message) {
108
109
  }
109
110
  }
110
111
  function resolveSupabaseConfig(options = {}) {
112
+ if (options.oauth) {
113
+ if (options.docsAuth === "public") {
114
+ throw new Error('docsAuth: "public" cannot be combined with oauth (the whole MCP endpoint requires a token)');
115
+ }
116
+ if (options.auth || options.authRequired) {
117
+ throw new Error("oauth cannot be combined with auth/authRequired (oauth already requires a valid token on every request)");
118
+ }
119
+ }
111
120
  return {
112
121
  writes: parseWrites(options.writes),
113
122
  rpc: options.rpc ?? [],
114
123
  functions: options.functions ?? {},
115
124
  docs: options.docs ?? ["docs/**/*.md"],
116
- docsAuth: options.docsAuth ?? "public",
125
+ docsAuth: options.docsAuth ?? (options.oauth ? "required" : "public"),
117
126
  maxRows: options.maxRows ?? 100,
118
127
  rateLimit: options.rateLimit,
119
128
  rateWindowMs: options.rateWindowMs
@@ -635,6 +644,96 @@ async function toResult(run) {
635
644
  }
636
645
  }
637
646
 
647
+ // src/oauth.ts
648
+ var import_core3 = require("@exposify/core");
649
+ var PRM_SUFFIX = "/.well-known/oauth-protected-resource";
650
+ function supabaseAuth(overrides = {}) {
651
+ return (supabaseUrl) => {
652
+ const base = supabaseUrl.replace(/\/+$/, "");
653
+ return {
654
+ issuer: `${base}/auth/v1`,
655
+ jwksUri: `${base}/auth/v1/.well-known/jwks.json`,
656
+ audience: "authenticated",
657
+ ...overrides
658
+ };
659
+ };
660
+ }
661
+ function functionNameFromMainModule() {
662
+ const mainModule = globalThis.Deno?.mainModule;
663
+ if (!mainModule) return void 0;
664
+ try {
665
+ const parts = new URL(mainModule).pathname.split("/").filter(Boolean);
666
+ return parts.length >= 2 ? parts[parts.length - 2] : void 0;
667
+ } catch {
668
+ return void 0;
669
+ }
670
+ }
671
+ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
672
+ if (typeof oauth === "function" && !supabaseUrl) {
673
+ throw new Error("oauth: SUPABASE_URL is not set. In a deployed function this is automatic; for local dev pass { supabaseUrl }.");
674
+ }
675
+ const opts = typeof oauth === "function" ? oauth(supabaseUrl) : oauth;
676
+ let resource = opts.resource;
677
+ if (!resource) {
678
+ const name = functionNameFromMainModule();
679
+ if (!supabaseUrl || !name) {
680
+ throw new Error("oauth.resource could not be derived (needs SUPABASE_URL and Deno.mainModule); pass oauth.resource explicitly.");
681
+ }
682
+ resource = `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`;
683
+ }
684
+ const { metadataUrl, ...oauthOptions } = opts;
685
+ const cfg = (0, import_core3.resolveOAuthOptions)({ ...oauthOptions, resource });
686
+ if (metadataUrl !== void 0) {
687
+ try {
688
+ new URL(metadataUrl);
689
+ } catch {
690
+ throw new Error(`oauth.metadataUrl must be an absolute URL, got ${JSON.stringify(metadataUrl)}`);
691
+ }
692
+ }
693
+ return (0, import_core3.createOAuthGate)(cfg, { metadataUrl: metadataUrl ?? `${cfg.resource}${PRM_SUFFIX}`, fetchImpl });
694
+ }
695
+
696
+ // src/startup.ts
697
+ var import_core4 = require("@exposify/core");
698
+ var LOG_PREFIX = "[exposify-supabase]";
699
+ 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`";
700
+ function supabaseToolsPhrase(ladder, cfg, hasDocs) {
701
+ const query = ladder.tables.length;
702
+ let write = 0;
703
+ for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
704
+ const rpc = cfg.rpc.length;
705
+ const fn = Object.keys(cfg.functions).length;
706
+ const parts = [`${query} query`];
707
+ if (write) parts.push(`${write} write`);
708
+ if (rpc) parts.push(`${rpc} rpc`);
709
+ if (fn) parts.push(`${fn} function${fn === 1 ? "" : "s"}`);
710
+ if (hasDocs) parts.push("search_docs");
711
+ const total = query + write + rpc + fn + (hasDocs ? 1 : 0);
712
+ return `${total} tool${total === 1 ? "" : "s"} (${parts.join(", ")})`;
713
+ }
714
+ function logColdStart(opts) {
715
+ const { ladder, cfg, docs, endpoint, auth, logReady } = opts;
716
+ if (ladder.mode === "functions-only") {
717
+ console.error(
718
+ (0, import_core4.formatFailed)(
719
+ LOG_PREFIX,
720
+ "couldn't read the database schema and no bundled manifest exists, so only registered functions and docs are exposed",
721
+ INTROSPECTION_FIX
722
+ )
723
+ );
724
+ return;
725
+ }
726
+ if (logReady) {
727
+ const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
728
+ console.log((0, import_core4.formatReady)(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
729
+ }
730
+ if (ladder.mode === "manifest") {
731
+ console.warn(
732
+ (0, import_core4.formatWarning)(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
733
+ );
734
+ }
735
+ }
736
+
638
737
  // src/index.ts
639
738
  function denoEnv() {
640
739
  return globalThis.Deno?.env;
@@ -646,13 +745,38 @@ function aiReady(options = {}) {
646
745
  const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
647
746
  const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
648
747
  const serviceRoleKey = options.serviceRoleKey ?? env?.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
748
+ const gate = options.oauth ? createSupabaseOAuthGate(options.oauth, supabaseUrl, fetchImpl) : null;
749
+ const logReady = (0, import_core5.startupLogEnabled)(options.startupLog);
750
+ const functionName = functionNameFromMainModule();
751
+ const endpoint = gate?.metadata.resource ?? (supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : "this function");
752
+ const authLabel = gate ? `oauth (${String(gate.metadata.authorization_servers[0])})` : options.auth ? "custom resolver" : "passthrough (caller's JWT)";
753
+ if (!supabaseUrl) {
754
+ console.error(
755
+ (0, import_core5.formatFailed)(LOG_PREFIX, "SUPABASE_URL is not set", "deployed functions get it automatically; for local dev pass { supabaseUrl }")
756
+ );
757
+ } else if (gate && logReady) {
758
+ void gate.probe().then((problem) => {
759
+ if (!problem) return;
760
+ console.warn(
761
+ (0, import_core5.formatWarning)(
762
+ LOG_PREFIX,
763
+ `can't verify access tokens from oauth.issuer ${String(gate.metadata.authorization_servers[0])}: ${problem}`,
764
+ "check the issuer (supabaseAuth() needs the project's OAuth server enabled and asymmetric JWT signing keys); until then MCP clients get 503"
765
+ )
766
+ );
767
+ });
768
+ }
769
+ let coldStartLogged = false;
649
770
  let kbPromise = null;
771
+ let docCount = 0;
650
772
  const getKb = () => {
651
773
  kbPromise ??= (async () => {
652
774
  const mainModule = globalThis.Deno?.mainModule;
653
775
  if (!mainModule) return import_core.KnowledgeBase.build([]);
654
776
  try {
655
- return import_core.KnowledgeBase.build(await loadDocsFromDeno(cfg.docs, new URL(mainModule)));
777
+ const docs = await loadDocsFromDeno(cfg.docs, new URL(mainModule));
778
+ docCount = docs.length;
779
+ return import_core.KnowledgeBase.build(docs);
656
780
  } catch {
657
781
  return import_core.KnowledgeBase.build([]);
658
782
  }
@@ -675,7 +799,7 @@ function aiReady(options = {}) {
675
799
  return ladderPromise;
676
800
  };
677
801
  const resolvedAuth = /* @__PURE__ */ new WeakMap();
678
- const core = (0, import_core3.createWebMcpHandler)(
802
+ const core = (0, import_core5.createWebMcpHandler)(
679
803
  {
680
804
  getServer: async (req) => {
681
805
  if (!supabaseUrl) {
@@ -687,6 +811,14 @@ function aiReady(options = {}) {
687
811
  const incoming = req.headers;
688
812
  const headers = buildPostgrestHeaders(resolvedAuth.get(req) ?? incoming, anonKey);
689
813
  const ladder = await getLadder(postgrestUrl);
814
+ const kb = await getKb();
815
+ if (!coldStartLogged) {
816
+ coldStartLogged = true;
817
+ try {
818
+ logColdStart({ ladder, cfg, docs: docCount, endpoint, auth: authLabel, logReady });
819
+ } catch {
820
+ }
821
+ }
690
822
  const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
691
823
  const invokeFunction = async (name, body) => {
692
824
  const res = await fetchImpl(`${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`, {
@@ -703,7 +835,7 @@ function aiReady(options = {}) {
703
835
  cfg,
704
836
  client,
705
837
  openapi: ladder.openapi,
706
- kb: await getKb(),
838
+ kb,
707
839
  incoming,
708
840
  invokeFunction,
709
841
  warnings: ladder.warnings
@@ -712,12 +844,19 @@ function aiReady(options = {}) {
712
844
  },
713
845
  { rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
714
846
  );
847
+ if (gate) {
848
+ return async (req) => {
849
+ if (new URL(req.url).pathname.endsWith(PRM_SUFFIX)) return gate.metadataResponse(req.method);
850
+ const result = await gate.check(req.headers);
851
+ return result.ok ? core(req) : result.response;
852
+ };
853
+ }
715
854
  const auth = options.auth;
716
855
  if (!auth) return core;
717
856
  return async (req) => {
718
857
  let resolved;
719
858
  try {
720
- resolved = await auth(req.headers);
859
+ resolved = await auth(req.headers, {});
721
860
  } catch (err) {
722
861
  console.error("exposify-supabase: auth resolver failed", err);
723
862
  return Response.json({ error: "internal MCP server error" }, { status: 503 });
@@ -740,5 +879,6 @@ function aiReady(options = {}) {
740
879
  fetchOpenApi,
741
880
  introspectTables,
742
881
  loadManifest,
743
- rpcArgSchema
882
+ rpcArgSchema,
883
+ supabaseAuth
744
884
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AuthResolver, WebMcpHandler } from '@exposify/core';
1
+ import { OAuthOptions, AuthResolver, WebMcpHandler } from '@exposify/core';
2
2
 
3
3
  type ColumnType = "string" | "number" | "integer" | "boolean" | "object" | "array";
4
4
  interface ColumnSchema {
@@ -45,6 +45,23 @@ interface JsonSchemaObject {
45
45
  properties?: Record<string, JsonSchemaProperty>;
46
46
  required?: string[];
47
47
  }
48
+ /**
49
+ * `OAuthOptions` with `resource` optional: it defaults to this function's own
50
+ * URL, `${SUPABASE_URL}/functions/v1/<function name>`.
51
+ */
52
+ type SupabaseOAuthOptions = Omit<OAuthOptions, "resource"> & {
53
+ resource?: string;
54
+ /**
55
+ * Where the `401` challenge says the Protected Resource Metadata lives.
56
+ * Default: `${resource}/.well-known/oauth-protected-resource`, under the
57
+ * function's own URL. Set it when a proxy you control serves the function
58
+ * on your own domain and forwards the RFC 9728 root-level well-known URI
59
+ * (e.g. `https://mcp.acme.com/.well-known/oauth-protected-resource/mcp`)
60
+ * to the function; the function answers any path ending in
61
+ * `/.well-known/oauth-protected-resource`.
62
+ */
63
+ metadataUrl?: string;
64
+ };
48
65
  interface AgentifySupabaseOptions {
49
66
  /** `"<table>.insert"` / `"<table>.update"` / `"<table>.delete"` / `"<table>.*"`. */
50
67
  writes?: string[];
@@ -54,7 +71,25 @@ interface AgentifySupabaseOptions {
54
71
  functions?: Record<string, FunctionToolConfig>;
55
72
  /** Doc globs bundled in the function directory. Default: `["docs/**\/*.md"]`. */
56
73
  docs?: string[];
74
+ /** Default `"public"`, or `"required"` when `oauth` is set (which can't be combined with `"public"`). */
57
75
  docsAuth?: "public" | "required";
76
+ /**
77
+ * Require an OAuth access token on the whole endpoint, so MCP clients can
78
+ * "click Connect and log in": a tokenless request gets a `401` pointing at
79
+ * Protected Resource Metadata served under this function's own path. Use
80
+ * `supabaseAuth()` for Supabase Auth's OAuth 2.1 server, or pass any
81
+ * spec-compliant AS (a function form receives the resolved `SUPABASE_URL`).
82
+ * The validated token is forwarded to PostgREST as-is, so RLS runs as the
83
+ * signed-in user. Can't be combined with `auth`/`authRequired`; deploy with
84
+ * `verify_jwt = false` so the gateway lets the first tokenless request
85
+ * through to answer it.
86
+ */
87
+ oauth?: SupabaseOAuthOptions | ((supabaseUrl: string) => SupabaseOAuthOptions);
88
+ /**
89
+ * The cold-start "MCP ready" line and the oauth issuer check. Default: on,
90
+ * except under `NODE_ENV=test`. Failures are logged regardless.
91
+ */
92
+ startupLog?: boolean;
58
93
  /** Row cap applied to every `query_<table>` call regardless of caller-supplied `limit`. Default 100. */
59
94
  maxRows?: number;
60
95
  /** Overrides for local/CI use -- production reads these from the Deno env automatically. */
@@ -96,6 +131,20 @@ interface AgentifySupabaseOptions {
96
131
  rateWindowMs?: number;
97
132
  }
98
133
 
134
+ /**
135
+ * Supabase Auth's OAuth 2.1 server as the authorization server. Per
136
+ * Supabase's docs its access tokens are ordinary project JWTs: `iss` is
137
+ * `${SUPABASE_URL}/auth/v1`, keys are at `/auth/v1/.well-known/jwks.json`,
138
+ * and `aud` is always `"authenticated"` -- it can't bind tokens to this
139
+ * function's URL, hence the `audience` override (any of the project's user
140
+ * JWTs is accepted, which is exactly what RLS already trusts). Needs, in the
141
+ * project: the OAuth 2.1 server enabled (beta as of 2026-10) with dynamic
142
+ * client registration on, an authorization path pointing at a consent page
143
+ * the app hosts, and asymmetric JWT signing keys (legacy HS256 projects have
144
+ * no JWKS to verify against).
145
+ */
146
+ declare function supabaseAuth(overrides?: Partial<SupabaseOAuthOptions>): (supabaseUrl: string) => SupabaseOAuthOptions;
147
+
99
148
  /** Subset of PostgREST's generated OpenAPI (Swagger 2.0) document that we read. */
100
149
  interface PostgrestOpenApiDoc {
101
150
  definitions?: Record<string, PostgrestTableDefinition>;
@@ -205,4 +254,4 @@ declare function fetchOpenApi(postgrestUrl: string, headers: Headers, fetchImpl:
205
254
  */
206
255
  declare function aiReady(options?: AgentifySupabaseOptions): WebMcpHandler;
207
256
 
208
- export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema };
257
+ export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, type SupabaseOAuthOptions, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema, supabaseAuth };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AuthResolver, WebMcpHandler } from '@exposify/core';
1
+ import { OAuthOptions, AuthResolver, WebMcpHandler } from '@exposify/core';
2
2
 
3
3
  type ColumnType = "string" | "number" | "integer" | "boolean" | "object" | "array";
4
4
  interface ColumnSchema {
@@ -45,6 +45,23 @@ interface JsonSchemaObject {
45
45
  properties?: Record<string, JsonSchemaProperty>;
46
46
  required?: string[];
47
47
  }
48
+ /**
49
+ * `OAuthOptions` with `resource` optional: it defaults to this function's own
50
+ * URL, `${SUPABASE_URL}/functions/v1/<function name>`.
51
+ */
52
+ type SupabaseOAuthOptions = Omit<OAuthOptions, "resource"> & {
53
+ resource?: string;
54
+ /**
55
+ * Where the `401` challenge says the Protected Resource Metadata lives.
56
+ * Default: `${resource}/.well-known/oauth-protected-resource`, under the
57
+ * function's own URL. Set it when a proxy you control serves the function
58
+ * on your own domain and forwards the RFC 9728 root-level well-known URI
59
+ * (e.g. `https://mcp.acme.com/.well-known/oauth-protected-resource/mcp`)
60
+ * to the function; the function answers any path ending in
61
+ * `/.well-known/oauth-protected-resource`.
62
+ */
63
+ metadataUrl?: string;
64
+ };
48
65
  interface AgentifySupabaseOptions {
49
66
  /** `"<table>.insert"` / `"<table>.update"` / `"<table>.delete"` / `"<table>.*"`. */
50
67
  writes?: string[];
@@ -54,7 +71,25 @@ interface AgentifySupabaseOptions {
54
71
  functions?: Record<string, FunctionToolConfig>;
55
72
  /** Doc globs bundled in the function directory. Default: `["docs/**\/*.md"]`. */
56
73
  docs?: string[];
74
+ /** Default `"public"`, or `"required"` when `oauth` is set (which can't be combined with `"public"`). */
57
75
  docsAuth?: "public" | "required";
76
+ /**
77
+ * Require an OAuth access token on the whole endpoint, so MCP clients can
78
+ * "click Connect and log in": a tokenless request gets a `401` pointing at
79
+ * Protected Resource Metadata served under this function's own path. Use
80
+ * `supabaseAuth()` for Supabase Auth's OAuth 2.1 server, or pass any
81
+ * spec-compliant AS (a function form receives the resolved `SUPABASE_URL`).
82
+ * The validated token is forwarded to PostgREST as-is, so RLS runs as the
83
+ * signed-in user. Can't be combined with `auth`/`authRequired`; deploy with
84
+ * `verify_jwt = false` so the gateway lets the first tokenless request
85
+ * through to answer it.
86
+ */
87
+ oauth?: SupabaseOAuthOptions | ((supabaseUrl: string) => SupabaseOAuthOptions);
88
+ /**
89
+ * The cold-start "MCP ready" line and the oauth issuer check. Default: on,
90
+ * except under `NODE_ENV=test`. Failures are logged regardless.
91
+ */
92
+ startupLog?: boolean;
58
93
  /** Row cap applied to every `query_<table>` call regardless of caller-supplied `limit`. Default 100. */
59
94
  maxRows?: number;
60
95
  /** Overrides for local/CI use -- production reads these from the Deno env automatically. */
@@ -96,6 +131,20 @@ interface AgentifySupabaseOptions {
96
131
  rateWindowMs?: number;
97
132
  }
98
133
 
134
+ /**
135
+ * Supabase Auth's OAuth 2.1 server as the authorization server. Per
136
+ * Supabase's docs its access tokens are ordinary project JWTs: `iss` is
137
+ * `${SUPABASE_URL}/auth/v1`, keys are at `/auth/v1/.well-known/jwks.json`,
138
+ * and `aud` is always `"authenticated"` -- it can't bind tokens to this
139
+ * function's URL, hence the `audience` override (any of the project's user
140
+ * JWTs is accepted, which is exactly what RLS already trusts). Needs, in the
141
+ * project: the OAuth 2.1 server enabled (beta as of 2026-10) with dynamic
142
+ * client registration on, an authorization path pointing at a consent page
143
+ * the app hosts, and asymmetric JWT signing keys (legacy HS256 projects have
144
+ * no JWKS to verify against).
145
+ */
146
+ declare function supabaseAuth(overrides?: Partial<SupabaseOAuthOptions>): (supabaseUrl: string) => SupabaseOAuthOptions;
147
+
99
148
  /** Subset of PostgREST's generated OpenAPI (Swagger 2.0) document that we read. */
100
149
  interface PostgrestOpenApiDoc {
101
150
  definitions?: Record<string, PostgrestTableDefinition>;
@@ -205,4 +254,4 @@ declare function fetchOpenApi(postgrestUrl: string, headers: Headers, fetchImpl:
205
254
  */
206
255
  declare function aiReady(options?: AgentifySupabaseOptions): WebMcpHandler;
207
256
 
208
- export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema };
257
+ export { type AgentifySupabaseOptions, type ExposifyManifest, MANIFEST_VERSION, MissingExposifyMetaError, type PostgrestOpenApiDoc, type SupabaseOAuthOptions, aiReady, buildManifest, columnsFromOpenApi, fetchExposifyMeta, fetchOpenApi, introspectTables, loadManifest, rpcArgSchema, supabaseAuth };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { createWebMcpHandler } from "@exposify/core";
2
+ import { createWebMcpHandler, formatFailed as formatFailed2, formatWarning as formatWarning2, startupLogEnabled } from "@exposify/core";
3
3
 
4
4
  // src/kb.ts
5
5
  import { KnowledgeBase } from "@exposify/core";
@@ -75,12 +75,20 @@ function warnOnce(message) {
75
75
  }
76
76
  }
77
77
  function resolveSupabaseConfig(options = {}) {
78
+ if (options.oauth) {
79
+ if (options.docsAuth === "public") {
80
+ throw new Error('docsAuth: "public" cannot be combined with oauth (the whole MCP endpoint requires a token)');
81
+ }
82
+ if (options.auth || options.authRequired) {
83
+ throw new Error("oauth cannot be combined with auth/authRequired (oauth already requires a valid token on every request)");
84
+ }
85
+ }
78
86
  return {
79
87
  writes: parseWrites(options.writes),
80
88
  rpc: options.rpc ?? [],
81
89
  functions: options.functions ?? {},
82
90
  docs: options.docs ?? ["docs/**/*.md"],
83
- docsAuth: options.docsAuth ?? "public",
91
+ docsAuth: options.docsAuth ?? (options.oauth ? "required" : "public"),
84
92
  maxRows: options.maxRows ?? 100,
85
93
  rateLimit: options.rateLimit,
86
94
  rateWindowMs: options.rateWindowMs
@@ -605,6 +613,96 @@ async function toResult(run) {
605
613
  }
606
614
  }
607
615
 
616
+ // src/oauth.ts
617
+ import { createOAuthGate, resolveOAuthOptions } from "@exposify/core";
618
+ var PRM_SUFFIX = "/.well-known/oauth-protected-resource";
619
+ function supabaseAuth(overrides = {}) {
620
+ return (supabaseUrl) => {
621
+ const base = supabaseUrl.replace(/\/+$/, "");
622
+ return {
623
+ issuer: `${base}/auth/v1`,
624
+ jwksUri: `${base}/auth/v1/.well-known/jwks.json`,
625
+ audience: "authenticated",
626
+ ...overrides
627
+ };
628
+ };
629
+ }
630
+ function functionNameFromMainModule() {
631
+ const mainModule = globalThis.Deno?.mainModule;
632
+ if (!mainModule) return void 0;
633
+ try {
634
+ const parts = new URL(mainModule).pathname.split("/").filter(Boolean);
635
+ return parts.length >= 2 ? parts[parts.length - 2] : void 0;
636
+ } catch {
637
+ return void 0;
638
+ }
639
+ }
640
+ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
641
+ if (typeof oauth === "function" && !supabaseUrl) {
642
+ throw new Error("oauth: SUPABASE_URL is not set. In a deployed function this is automatic; for local dev pass { supabaseUrl }.");
643
+ }
644
+ const opts = typeof oauth === "function" ? oauth(supabaseUrl) : oauth;
645
+ let resource = opts.resource;
646
+ if (!resource) {
647
+ const name = functionNameFromMainModule();
648
+ if (!supabaseUrl || !name) {
649
+ throw new Error("oauth.resource could not be derived (needs SUPABASE_URL and Deno.mainModule); pass oauth.resource explicitly.");
650
+ }
651
+ resource = `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`;
652
+ }
653
+ const { metadataUrl, ...oauthOptions } = opts;
654
+ const cfg = resolveOAuthOptions({ ...oauthOptions, resource });
655
+ if (metadataUrl !== void 0) {
656
+ try {
657
+ new URL(metadataUrl);
658
+ } catch {
659
+ throw new Error(`oauth.metadataUrl must be an absolute URL, got ${JSON.stringify(metadataUrl)}`);
660
+ }
661
+ }
662
+ return createOAuthGate(cfg, { metadataUrl: metadataUrl ?? `${cfg.resource}${PRM_SUFFIX}`, fetchImpl });
663
+ }
664
+
665
+ // src/startup.ts
666
+ 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`";
669
+ function supabaseToolsPhrase(ladder, cfg, hasDocs) {
670
+ const query = ladder.tables.length;
671
+ let write = 0;
672
+ for (const table of ladder.tables) write += cfg.writes.get(table.name)?.size ?? 0;
673
+ const rpc = cfg.rpc.length;
674
+ const fn = Object.keys(cfg.functions).length;
675
+ const parts = [`${query} query`];
676
+ if (write) parts.push(`${write} write`);
677
+ if (rpc) parts.push(`${rpc} rpc`);
678
+ if (fn) parts.push(`${fn} function${fn === 1 ? "" : "s"}`);
679
+ if (hasDocs) parts.push("search_docs");
680
+ const total = query + write + rpc + fn + (hasDocs ? 1 : 0);
681
+ return `${total} tool${total === 1 ? "" : "s"} (${parts.join(", ")})`;
682
+ }
683
+ function logColdStart(opts) {
684
+ const { ladder, cfg, docs, endpoint, auth, logReady } = opts;
685
+ if (ladder.mode === "functions-only") {
686
+ console.error(
687
+ formatFailed(
688
+ LOG_PREFIX,
689
+ "couldn't read the database schema and no bundled manifest exists, so only registered functions and docs are exposed",
690
+ INTROSPECTION_FIX
691
+ )
692
+ );
693
+ return;
694
+ }
695
+ if (logReady) {
696
+ const details = ladder.mode === "manifest" ? ["from the bundled manifest"] : [];
697
+ console.log(formatReady(LOG_PREFIX, { endpoint, tools: supabaseToolsPhrase(ladder, cfg, docs > 0), details, docs, auth }));
698
+ }
699
+ if (ladder.mode === "manifest") {
700
+ console.warn(
701
+ formatWarning(LOG_PREFIX, "live schema introspection failed; tools come from the bundled manifest and may be out of date", INTROSPECTION_FIX)
702
+ );
703
+ }
704
+ }
705
+
608
706
  // src/index.ts
609
707
  function denoEnv() {
610
708
  return globalThis.Deno?.env;
@@ -616,13 +714,38 @@ function aiReady(options = {}) {
616
714
  const supabaseUrl = options.supabaseUrl ?? env?.get("SUPABASE_URL");
617
715
  const anonKey = options.anonKey ?? env?.get("SUPABASE_ANON_KEY") ?? "";
618
716
  const serviceRoleKey = options.serviceRoleKey ?? env?.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
717
+ const gate = options.oauth ? createSupabaseOAuthGate(options.oauth, supabaseUrl, fetchImpl) : null;
718
+ const logReady = startupLogEnabled(options.startupLog);
719
+ const functionName = functionNameFromMainModule();
720
+ const endpoint = gate?.metadata.resource ?? (supabaseUrl && functionName ? `${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${functionName}` : "this function");
721
+ const authLabel = gate ? `oauth (${String(gate.metadata.authorization_servers[0])})` : options.auth ? "custom resolver" : "passthrough (caller's JWT)";
722
+ if (!supabaseUrl) {
723
+ console.error(
724
+ formatFailed2(LOG_PREFIX, "SUPABASE_URL is not set", "deployed functions get it automatically; for local dev pass { supabaseUrl }")
725
+ );
726
+ } else if (gate && logReady) {
727
+ void gate.probe().then((problem) => {
728
+ if (!problem) return;
729
+ console.warn(
730
+ formatWarning2(
731
+ LOG_PREFIX,
732
+ `can't verify access tokens from oauth.issuer ${String(gate.metadata.authorization_servers[0])}: ${problem}`,
733
+ "check the issuer (supabaseAuth() needs the project's OAuth server enabled and asymmetric JWT signing keys); until then MCP clients get 503"
734
+ )
735
+ );
736
+ });
737
+ }
738
+ let coldStartLogged = false;
619
739
  let kbPromise = null;
740
+ let docCount = 0;
620
741
  const getKb = () => {
621
742
  kbPromise ??= (async () => {
622
743
  const mainModule = globalThis.Deno?.mainModule;
623
744
  if (!mainModule) return KnowledgeBase.build([]);
624
745
  try {
625
- return KnowledgeBase.build(await loadDocsFromDeno(cfg.docs, new URL(mainModule)));
746
+ const docs = await loadDocsFromDeno(cfg.docs, new URL(mainModule));
747
+ docCount = docs.length;
748
+ return KnowledgeBase.build(docs);
626
749
  } catch {
627
750
  return KnowledgeBase.build([]);
628
751
  }
@@ -657,6 +780,14 @@ function aiReady(options = {}) {
657
780
  const incoming = req.headers;
658
781
  const headers = buildPostgrestHeaders(resolvedAuth.get(req) ?? incoming, anonKey);
659
782
  const ladder = await getLadder(postgrestUrl);
783
+ const kb = await getKb();
784
+ if (!coldStartLogged) {
785
+ coldStartLogged = true;
786
+ try {
787
+ logColdStart({ ladder, cfg, docs: docCount, endpoint, auth: authLabel, logReady });
788
+ } catch {
789
+ }
790
+ }
660
791
  const client = createPostgrestClient(postgrestUrl, headers, fetchImpl);
661
792
  const invokeFunction = async (name, body) => {
662
793
  const res = await fetchImpl(`${supabaseUrl.replace(/\/+$/, "")}/functions/v1/${name}`, {
@@ -673,7 +804,7 @@ function aiReady(options = {}) {
673
804
  cfg,
674
805
  client,
675
806
  openapi: ladder.openapi,
676
- kb: await getKb(),
807
+ kb,
677
808
  incoming,
678
809
  invokeFunction,
679
810
  warnings: ladder.warnings
@@ -682,12 +813,19 @@ function aiReady(options = {}) {
682
813
  },
683
814
  { rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
684
815
  );
816
+ if (gate) {
817
+ return async (req) => {
818
+ if (new URL(req.url).pathname.endsWith(PRM_SUFFIX)) return gate.metadataResponse(req.method);
819
+ const result = await gate.check(req.headers);
820
+ return result.ok ? core(req) : result.response;
821
+ };
822
+ }
685
823
  const auth = options.auth;
686
824
  if (!auth) return core;
687
825
  return async (req) => {
688
826
  let resolved;
689
827
  try {
690
- resolved = await auth(req.headers);
828
+ resolved = await auth(req.headers, {});
691
829
  } catch (err) {
692
830
  console.error("exposify-supabase: auth resolver failed", err);
693
831
  return Response.json({ error: "internal MCP server error" }, { status: 503 });
@@ -709,5 +847,6 @@ export {
709
847
  fetchOpenApi,
710
848
  introspectTables,
711
849
  loadManifest,
712
- rpcArgSchema
850
+ rpcArgSchema,
851
+ supabaseAuth
713
852
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/supabase",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -27,12 +27,13 @@
27
27
  "prepack": "yarn build"
28
28
  },
29
29
  "dependencies": {
30
- "@exposify/core": "0.3.10",
30
+ "@exposify/core": "0.3.12",
31
31
  "@modelcontextprotocol/sdk": "^1.12.0",
32
32
  "zod": "^3.24.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.0.0",
36
+ "jose": "^6.1.3",
36
37
  "tsup": "^8.3.0",
37
38
  "typescript": "^5.6.0",
38
39
  "vitest": "^3.0.0"
@@ -40,15 +41,6 @@
40
41
  "publishConfig": {
41
42
  "access": "public"
42
43
  },
43
- "repository": {
44
- "type": "git",
45
- "url": "git+https://github.com/ayadagain/agentready.git",
46
- "directory": "packages/supabase"
47
- },
48
- "homepage": "https://github.com/ayadagain/agentready/tree/main/packages/supabase#readme",
49
- "bugs": {
50
- "url": "https://github.com/ayadagain/agentready/issues"
51
- },
52
44
  "description": "Expose a Supabase-backed app's database and edge functions to AI agents over MCP, generated from PostgREST + Row Level Security — RLS is the authorization layer, so agents can do exactly what the logged-in user can do.",
53
45
  "author": "Exposify",
54
46
  "keywords": [