@exposify/next 0.0.2 → 0.0.4

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.d.ts CHANGED
@@ -1,126 +1,23 @@
1
- import { ProposedField, LoginField, DocSource, RouteMarker, AgentReadyOptions } from '@exposify/core';
1
+ import { RouteMarker, AgentReadyOptions } from '@exposify/core';
2
2
  export { HostedAuthResolverConfig, completeMcpLogin, createHostedAuthResolver, describe, exposed, hidden, hostedBroker } from '@exposify/core';
3
- import { A as ActionField } from './fields-olvyFnxj.js';
3
+ import { E as ExposifyManifest } from './manifest-DZQCvzQy.js';
4
+ export { a as AppRouteEntry, b as ManifestAction, c as ManifestLogin, d as ManifestLoginStep, e as ManifestPage, f as ManifestRoute, P as PagesRouteEntry } from './manifest-DZQCvzQy.js';
4
5
 
5
- interface AppRouteEntry {
6
- router: "app";
7
- /** `/api/orders/:id`; catch-alls as `:slug*`, optional ones as `:slug*?`. */
8
- path: string;
9
- params: string[];
10
- /** Project-relative, for messages. */
11
- file: string;
12
- /** Exported methods when generated; the loaded module's exports win at runtime. */
13
- methods: string[];
14
- /** Loads the route module to read exposed()/hidden()/describe() off its exports. Absent for edge routes. */
15
- load?: () => Promise<Record<string, unknown>>;
16
- /** `export const runtime = "edge"`: not loaded into the Node MCP route, so markers there aren't read. */
17
- edge?: boolean;
18
- }
19
- interface PagesRouteEntry {
20
- router: "pages";
21
- path: string;
22
- params: string[];
23
- file: string;
24
- }
25
- type ManifestRoute = AppRouteEntry | PagesRouteEntry;
26
- /** An exported function of a `"use server"` module. */
27
- interface ManifestAction {
28
- /** Unique among actions: the default tool name, and the URL segment under `<mcpPath>/actions/`. */
29
- key: string;
30
- file: string;
31
- export: string;
32
- /** How each parameter is filled on a call, in order: see `ActionParamRole` in parse.ts. */
33
- params: Array<{
34
- name: string;
35
- role: "formData" | "prev" | "input" | "object";
36
- }>;
37
- /** It sets or deletes a cookie, which would land on exposify's internal call rather than the agent's client. */
38
- setsCookies?: boolean;
39
- /** What it takes, worked out from its code (fields.ts): its tool schema when the app doesn't write one. */
40
- fields: ActionField[];
41
- /** It reads every form field, so its code doesn't say which exist. */
42
- readsAll?: boolean;
43
- /** What the analysis couldn't work out ("line N: …"): with any, the app writes the schema. */
44
- unresolved?: string[];
45
- /** The forms that submit to it (pass 3), where they are. */
46
- forms?: Array<{
47
- file: string;
48
- line: number;
49
- }>;
50
- /** An input `describe` proposed and a person accepted, current with the code: used when `fields` can't be the schema. */
51
- proposed?: ProposedField[];
52
- /**
53
- * A page that uses it (`/checkout`, `/admin/orders/:id`): tool calls are
54
- * sent there as a real Server Action request. Absent when no page uses it;
55
- * it's then called in-process by the MCP route.
56
- */
57
- host?: string;
58
- /**
59
- * The app's own checks it runs first (`lib/auth.ts#requireAdmin`): a
60
- * user who doesn't pass one doesn't see the tool (guards.ts).
61
- */
62
- guards?: string[];
63
- load: () => Promise<Record<string, unknown>>;
64
- }
65
- /** An App Router page: a read tool that fetches it like a browser (pages.ts). */
66
- interface ManifestPage {
67
- /** Tool name, e.g. `view_product_slug`. */
68
- name: string;
69
- /** `/product/:slug`, as routes are written. */
70
- path: string;
71
- params: string[];
72
- file: string;
73
- /** Search params its code reads: optional tool arguments, sent as the query. */
74
- search?: string[];
75
- /** From its static `metadata`. */
76
- title?: string;
77
- description?: string;
78
- /** Just a form posting to a Server Action: not a tool unless `pages` lists it. */
79
- formOnly?: boolean;
80
- /** The app's own checks it runs (`lib/auth.ts#requireAdmin`), see ManifestAction.guards. */
81
- guards?: string[];
82
- }
6
+ /** Header carrying `<ip>;<unix seconds>;<signature>` on a tool call. */
7
+ declare const AGENT_IP_HEADER = "x-exposify-agent-ip";
83
8
  /**
84
- * The app's own login (login.ts): a sign-in form under the MCP route makes
85
- * this request, for Connect with no code. `host` is the page with the
86
- * action's form, which tool calls redirected to mean "signed out".
9
+ * The address of whoever made this request: on a tool call, the AI agent's
10
+ * (verified with EXPOSIFY_KEY, so it can't be forged); otherwise the
11
+ * platform's `x-forwarded-for`, as you'd read it anyway. Use it where a
12
+ * route rate-limits or logs by IP: on Vercel, a tool call's own
13
+ * `x-forwarded-for` is the function's address, not the agent's.
87
14
  */
88
- type ManifestLogin = ({
89
- kind: "action";
90
- key: string;
91
- fields: LoginField[];
92
- host?: string;
93
- } | {
94
- kind: "route";
95
- path: string;
96
- fields: LoginField[];
97
- body: "json" | "form";
98
- }) & {
99
- /** Where it is, for the startup log and the dashboard: "signIn in app/actions/auth.ts". */
100
- where?: string;
101
- };
102
- interface ExposifyManifest {
103
- info: {
104
- name: string;
105
- version: string;
106
- };
107
- /** next.config's basePath, prefixed to every tool call's path. */
108
- basePath?: string;
109
- /** next.config's trailingSlash: tool calls add one, so they aren't redirected. */
110
- trailingSlash?: boolean;
111
- routes: ManifestRoute[];
112
- /** The MCP route's URL without its catch-all segment, e.g. `/mcp`: Server Actions are served under `<mcpPath>/actions/`. */
113
- mcpPath?: string;
114
- actions?: ManifestAction[];
115
- pages?: ManifestPage[];
116
- login?: ManifestLogin;
117
- /** Auth libraries in package.json ("next-auth", "@supabase/ssr", ...), reported with the login. */
118
- authStacks?: string[];
119
- /** Docs for search_docs, embedded so they work without a file system. */
120
- docs: DocSource[];
121
- /** exposify.descriptions.json's texts, "METHOD /path" -> text. */
122
- descriptions?: Record<string, string>;
123
- }
15
+ declare function agentIp(request: {
16
+ headers: Headers;
17
+ }, options?: {
18
+ key?: string;
19
+ now?: number;
20
+ }): Promise<string | undefined>;
124
21
 
125
22
  /** Set on every tool call; the MCP route refuses requests carrying it, so a tool can never call the MCP endpoint. */
126
23
  declare const DISPATCH_HEADER = "x-exposify-dispatch";
@@ -189,6 +86,29 @@ interface NextAiReadyOptions extends Omit<AgentReadyOptions, "docs"> {
189
86
  fields: string[];
190
87
  body?: "json" | "form";
191
88
  };
89
+ /**
90
+ * The login's second step, when it asks for a code after the first (a
91
+ * password, then a code; or an email, then a code). Found by
92
+ * `withExposify` (a Server Action or POST route named like `verify`,
93
+ * `2fa`, `otp` that takes one code and sets the session). Name another
94
+ * Server Action, a route (`{ path, fields }`), or `false` for none.
95
+ */
96
+ loginVerify?: false | string | {
97
+ path: string;
98
+ fields: string[];
99
+ body?: "json" | "form";
100
+ };
101
+ /**
102
+ * The app's Supabase project, for Connect when the app uses Supabase Auth
103
+ * with `@supabase/ssr`: AI assistants sign in to it directly, and tool
104
+ * calls carry the session cookie `@supabase/ssr` reads. Default: read from
105
+ * `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`
106
+ * (or `NEXT_PUBLIC_SUPABASE_ANON_KEY`). `false` to not offer it.
107
+ */
108
+ supabase?: false | {
109
+ url: string;
110
+ key: string;
111
+ };
192
112
  }
193
113
  interface NextPageOptions {
194
114
  /** Tool name. Default: `view_` and the path, snake_cased. */
@@ -198,4 +118,4 @@ interface NextPageOptions {
198
118
  type NextMcpHandler = (req: Request) => Promise<Response>;
199
119
  declare function aiReady(manifest: ExposifyManifest, options?: NextAiReadyOptions): NextMcpHandler;
200
120
 
201
- export { type AppRouteEntry, DISPATCH_HEADER, type ExposifyManifest, type ManifestAction, type ManifestLogin, type ManifestPage, type ManifestRoute, type NextActionOptions, type NextAiReadyOptions, type NextMcpHandler, type NextPageOptions, type PagesRouteEntry, aiReady };
121
+ export { AGENT_IP_HEADER, DISPATCH_HEADER, ExposifyManifest, type NextActionOptions, type NextAiReadyOptions, type NextMcpHandler, type NextPageOptions, agentIp, aiReady };
package/dist/index.js CHANGED
@@ -20,10 +20,18 @@ import {
20
20
  handleLoginPage,
21
21
  hostedResolverInfo,
22
22
  loginResultFromResponse,
23
+ keepFailedCookies,
24
+ readStep,
25
+ rejectedBody,
26
+ readSupabaseAuth,
23
27
  reportAppLogin,
24
28
  samePath,
29
+ sessionFrom,
25
30
  setCookieLines,
26
31
  SIGNED_OUT_HEADER,
32
+ supabaseCookieName,
33
+ supabasePasswordLogin,
34
+ verifyFields,
27
35
  matchesPathGlob,
28
36
  formatFailed,
29
37
  formatReady,
@@ -638,6 +646,49 @@ async function readPageResponse(res, url) {
638
646
  return Response.json(body);
639
647
  }
640
648
 
649
+ // src/agent-ip.ts
650
+ var AGENT_IP_HEADER = "x-exposify-agent-ip";
651
+ var MAX_AGE_S = 300;
652
+ function env2() {
653
+ return globalThis.process?.env ?? {};
654
+ }
655
+ var keyCache = /* @__PURE__ */ new Map();
656
+ function hmacKey(secret) {
657
+ let key = keyCache.get(secret);
658
+ if (!key) {
659
+ key = (async () => {
660
+ const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`exposify agent-ip v1
661
+ ${secret}`));
662
+ return crypto.subtle.importKey("raw", material, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
663
+ })();
664
+ keyCache.set(secret, key);
665
+ }
666
+ return key;
667
+ }
668
+ var hex2 = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
669
+ async function signAgentIp(ip, secret = env2().EXPOSIFY_KEY, now = Date.now()) {
670
+ if (!ip || !secret || /[;\s]/.test(ip) || ip.length > 64) return null;
671
+ const t = Math.floor(now / 1e3);
672
+ const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(`${ip};${t}`));
673
+ return `${ip};${t};${hex2(sig)}`;
674
+ }
675
+ function forwardedIp(headers) {
676
+ return headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip")?.trim() || void 0;
677
+ }
678
+ async function agentIp(request, options = {}) {
679
+ const signed = request.headers.get(AGENT_IP_HEADER);
680
+ const secret = options.key ?? env2().EXPOSIFY_KEY;
681
+ if (signed && secret) {
682
+ const [ip, t, sig] = signed.split(";");
683
+ const age = Math.floor((options.now ?? Date.now()) / 1e3) - Number(t);
684
+ if (ip && sig && /^[0-9a-f]{64}$/.test(sig) && Number.isInteger(Number(t)) && age >= -60 && age <= MAX_AGE_S) {
685
+ const bytes = new Uint8Array(sig.match(/../g).map((b) => parseInt(b, 16)));
686
+ if (await crypto.subtle.verify("HMAC", await hmacKey(secret), bytes, new TextEncoder().encode(`${ip};${t}`))) return ip;
687
+ }
688
+ }
689
+ return forwardedIp(request.headers);
690
+ }
691
+
641
692
  // src/index.ts
642
693
  import {
643
694
  exposed,
@@ -653,7 +704,7 @@ var LOG_PREFIX = "[agentready]";
653
704
  var ACTION_NOTE = "It returns the action's result, or { ok: true, redirectedTo } when the action redirects; a redirect to a sign-in page means the call wasn't authorised.";
654
705
  var LEFT_BLANK = "A field you leave out is as if left blank on the form.";
655
706
  var PAGE_NOTE = "Returns the page's main content as text, with links, and the data it was rendered with when the page passes any to its components. An error naming a sign-in page means signing in first.";
656
- function env2() {
707
+ function env3() {
657
708
  return globalThis.process?.env ?? {};
658
709
  }
659
710
  function errorMessage(err) {
@@ -716,7 +767,7 @@ function derivedInput(action) {
716
767
  const fields = action.proposed.map((p) => ({ name: p.name, type: p.type, ...p.required ? { required: true } : {}, ...p.enum ? { enum: p.enum } : {}, ...p.description ? { description: p.description } : {} }));
717
768
  return { input: Object.fromEntries(fields.map((f) => [f.name, fieldSchema(f)])) };
718
769
  }
719
- if (reason) return { reason: action.fields.some((f) => f.type === "file") ? reason : `${reason}; \`exposify-next describe\` can propose it for you to review` };
770
+ if (reason) return { reason: action.fields.some((f) => f.type === "file") ? reason : `${reason}; \`@exposify/next describe\` can propose it for you to review` };
720
771
  return { input: Object.fromEntries(action.fields.map((f) => [f.name, fieldSchema(f)])) };
721
772
  }
722
773
  var sentence = (s) => /[.!?]$/.test(s.trim()) ? s.trim() : `${s.trim()}.`;
@@ -836,29 +887,65 @@ function pagesFrom(manifest, cfg, config) {
836
887
  }
837
888
  return { routes, hidden: hidden3, forms };
838
889
  }
839
- function loginSpec(manifest, config, warnings) {
890
+ function loginSpec(manifest, options, warnings) {
891
+ const config = options.login;
840
892
  if (config === false) return null;
841
- if (config === void 0) return manifest.login ?? null;
893
+ let login;
894
+ if (config === void 0) login = manifest.login ?? null;
895
+ else {
896
+ const step = stepSpec(manifest, config, (name) => fieldKind(name), warnings, "login", "AI assistants can't sign in with your app's login");
897
+ login = step ? { ...step.step, where: step.where } : null;
898
+ }
899
+ if (!login || options.loginVerify === void 0) return login;
900
+ const { verify: _found, ...first } = login;
901
+ if (options.loginVerify === false) return first;
902
+ const kind = (name) => verifyFields([name], true)?.[0]?.kind ?? fieldKind(name);
903
+ const verify = stepSpec(manifest, options.loginVerify, kind, warnings, "loginVerify", "AI assistants can't finish your app's two-step login");
904
+ return verify ? { ...first, verify: verify.step, where: `${first.where ?? "your login"}, then a code: ${verify.where}` } : first;
905
+ }
906
+ function supabaseSpec(manifest, options, warnings) {
907
+ if (!manifest.supabase || options.supabase === false) return null;
908
+ if (options.supabase) return options.supabase;
909
+ const e = env3();
910
+ const url = e.NEXT_PUBLIC_SUPABASE_URL ?? e.SUPABASE_URL;
911
+ const key = e.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? e.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? e.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY;
912
+ if (!url || !key) {
913
+ warnings.push("your app uses Supabase Auth, but NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY (or _ANON_KEY) aren't both set where the MCP route runs, so AI assistants can't sign in to it; set them, or pass aiReady({ supabase: { url, key } })");
914
+ return null;
915
+ }
916
+ try {
917
+ new URL(url);
918
+ } catch {
919
+ warnings.push(`NEXT_PUBLIC_SUPABASE_URL isn't a URL (${url}), so AI assistants can't sign in to your Supabase Auth`);
920
+ return null;
921
+ }
922
+ return { url, key };
923
+ }
924
+ function stepSpec(manifest, config, kind, warnings, option, consequence) {
842
925
  if (typeof config === "object") {
843
- const fields2 = config.fields.map((name) => ({ name, kind: fieldKind(name) }));
844
- return { kind: "route", path: config.path, fields: fields2, body: config.body ?? "json", where: `POST ${config.path}` };
926
+ const fields2 = config.fields.map((name) => ({ name, kind: kind(name) }));
927
+ return { step: { kind: "route", path: config.path, fields: fields2, body: config.body ?? "json" }, where: `POST ${config.path}` };
845
928
  }
846
929
  const action = (manifest.actions ?? []).find((a) => a.key === config || a.export === config);
847
930
  if (!action) {
848
- warnings.push(`aiReady({ login: "${config}" }): no server action by that name, so AI assistants can't sign in with your app's login`);
931
+ warnings.push(`aiReady({ ${option}: "${config}" }): no server action by that name, so ${consequence}`);
849
932
  return null;
850
933
  }
851
- const fields = action.fields.map((f) => ({ name: f.name, kind: fieldKind(f.name) }));
852
- return { kind: "action", key: action.key, fields, ...action.host ? { host: action.host } : {}, where: `${action.export} in ${action.file}` };
934
+ const fields = action.fields.map((f) => ({ name: f.name, kind: kind(f.name) }));
935
+ return { step: { kind: "action", key: action.key, fields, ...action.host ? { host: action.host } : {} }, where: `${action.export} in ${action.file}` };
853
936
  }
854
- async function actionLoginResult(raw, exportName) {
937
+ async function actionLoginResult(raw, exportName, reading = {}) {
855
938
  if (actionNotFound(raw)) return { ok: false, message: "The app's sign-in page didn't run its login. Its developer needs to rebuild the app." };
856
- const cookies = cookieChanges(raw).filter((c) => !c.deleted && c.value !== "");
939
+ const changes = cookieChanges(raw);
857
940
  const out = await readActionResponse(raw, exportName);
858
941
  const body = await out.json().catch(() => void 0);
942
+ const location = raw.headers.get("x-action-redirect")?.split(";")[0] ?? (typeof body?.redirectedTo === "string" ? body.redirectedTo : null);
943
+ const step = readStep({ status: location ? 303 : out.status, location, body, cookies: changes }, reading);
944
+ if (step) return keepFailedCookies(step, changes, reading);
859
945
  const message = errorMessageOf(body);
860
- if (out.status >= 400 || message && !body?.redirectedTo) return { ok: false, message };
861
- return cookies.length > 0 ? { ok: true, session: { cookies } } : { ok: false, message };
946
+ if (out.status >= 400 || message && !body?.redirectedTo || rejectedBody(body)) return keepFailedCookies({ ok: false, message }, changes, reading);
947
+ const session = sessionFrom(changes, reading, Boolean(location) || body !== void 0 && body !== null && typeof body === "object");
948
+ return session ? { ok: true, session } : keepFailedCookies({ ok: false, message }, changes, reading);
862
949
  }
863
950
  var clientIp = (headers) => headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip") || void 0;
864
951
  function aiReady(manifest, options = {}) {
@@ -871,9 +958,39 @@ function aiReady(manifest, options = {}) {
871
958
  const actionsBase = (manifest.mcpPath ?? "").replace(/\/$/, "");
872
959
  const actionUrl = manifest.mcpPath === void 0 ? null : new RegExp(`${actionsBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/actions/([^/]+)/?$`);
873
960
  const loginWarnings = [];
874
- const login = hostedResolverInfo(cfg.auth) ? loginSpec(manifest, options.login, loginWarnings) : null;
961
+ const login = hostedResolverInfo(cfg.auth) ? loginSpec(manifest, options, loginWarnings) : null;
875
962
  const loginPagePath = `${(manifest.mcpPath ?? "/mcp").replace(/\/$/, "")}/login`;
876
963
  if (login) cfg.signedOutPaths = [login.kind === "action" ? login.host : login.path].filter((p) => Boolean(p)).map((p) => `${manifest.basePath ?? ""}${hostPath(p)}`);
964
+ const supabase = hostedResolverInfo(cfg.auth) ? supabaseSpec(manifest, options, loginWarnings) : null;
965
+ if (supabase && manifest.supabase?.loginPages?.length) {
966
+ cfg.signedOutPaths = [...cfg.signedOutPaths ?? [], ...manifest.supabase.loginPages.map((p) => `${manifest.basePath ?? ""}${hostPath(p)}`)];
967
+ }
968
+ const supabasePassword = supabase ? supabasePasswordLogin({
969
+ ...supabase,
970
+ onCaptcha: () => warnOnce("your Supabase project asks for a CAPTCHA to sign in, so AI assistants can't sign in with a password; social sign-ins still work")
971
+ }) : null;
972
+ const supabaseLoginNow = () => {
973
+ if (!supabasePassword) return null;
974
+ const choice = hostedResolverInfo(cfg.auth)?.supabasePasswordDirect();
975
+ return choice ?? (!login && manifest.supabase?.direct.includes("password")) ? supabasePassword : null;
976
+ };
977
+ let supabaseAuth;
978
+ const supabaseReport = () => {
979
+ if (!supabase || !manifest.supabase) return Promise.resolve(void 0);
980
+ const found = manifest.supabase;
981
+ supabaseAuth ??= readSupabaseAuth(supabase.url, supabase.key).then((read) => {
982
+ if (!read) {
983
+ supabaseAuth = void 0;
984
+ return void 0;
985
+ }
986
+ return {
987
+ ...read,
988
+ cookie: found.cookieName ?? supabaseCookieName(supabase.url),
989
+ found: { direct: found.direct, where: found.found, ...found.unusable ? { unusable: found.unusable } : {}, ...found.callbacks ? { callbacks: found.callbacks } : {} }
990
+ };
991
+ });
992
+ return supabaseAuth;
993
+ };
877
994
  let built = null;
878
995
  const build = async (endpoint) => {
879
996
  const warnings = [...loginWarnings];
@@ -967,7 +1084,7 @@ function aiReady(manifest, options = {}) {
967
1084
  formatWarning(
968
1085
  LOG_PREFIX,
969
1086
  `MCP is up at ${endpoint} but exposes no route tools`,
970
- "check that exposify.generated.ts is current (withExposify() in next.config, or `npx exposify-next generate`)"
1087
+ "check that exposify.generated.ts is current (withExposify() in next.config, or `npx @exposify/next generate`)"
971
1088
  )
972
1089
  );
973
1090
  return;
@@ -977,11 +1094,16 @@ function aiReady(manifest, options = {}) {
977
1094
  if (cfg.expose === "*") details.push('all write routes exposed (expose: "*")');
978
1095
  if (counts.hiddenWrites > 0) details.push(`${counts.hiddenWrites} write route${counts.hiddenWrites === 1 ? "" : "s"} hidden (opt in with exposed())`);
979
1096
  if (pages.routes.length > 0) details.push(`${pages.routes.length} page${pages.routes.length === 1 ? "" : "s"} readable`);
980
- if (login) details.push(`AI assistants sign in with your app's own login (${login.kind === "action" ? `server action ${login.key}` : `POST ${login.path}`}) at ${loginPagePath}`);
1097
+ if (supabaseLoginNow()) {
1098
+ details.push(`AI assistants sign in to your Supabase Auth with a password at ${loginPagePath}`);
1099
+ } else if (login) {
1100
+ const step = (l) => l.kind === "action" ? `server action ${l.key}` : `POST ${l.path}`;
1101
+ details.push(`AI assistants sign in with your app's own login (${step(login)}${login.verify ? `, then a code: ${step(login.verify)}` : ""}) at ${loginPagePath}`);
1102
+ }
981
1103
  if (pages.forms > 0) details.push(`${pages.forms} page${pages.forms === 1 ? "" : "s"} left out as just a form (list in aiReady({ pages }) to include)`);
982
1104
  if (exposedActions > 0) details.push(`${exposedActions} server action${exposedActions === 1 ? "" : "s"} exposed, each checking auth itself`);
983
1105
  if (hiddenActions > 0) details.push(`${hiddenActions} server action${hiddenActions === 1 ? "" : "s"} hidden (opt in with aiReady({ actions }))`);
984
- const e = env2();
1106
+ const e = env3();
985
1107
  if (e.VERCEL && e.VERCEL_ENV !== "production" && !e.VERCEL_AUTOMATION_BYPASS_SECRET) {
986
1108
  details.push("on a protected Vercel preview, tool calls need VERCEL_AUTOMATION_BYPASS_SECRET");
987
1109
  }
@@ -991,7 +1113,7 @@ function aiReady(manifest, options = {}) {
991
1113
  const selfFetch = async (request, mcpReq, route) => {
992
1114
  const target = new URL(request.url);
993
1115
  const incoming = new URL(mcpReq.url);
994
- const origin = options.baseUrl ?? env2().EXPOSIFY_BASE_URL ?? incoming.origin;
1116
+ const origin = options.baseUrl ?? env3().EXPOSIFY_BASE_URL ?? incoming.origin;
995
1117
  let path = `${manifest.basePath ?? ""}${target.pathname}`;
996
1118
  if (manifest.trailingSlash && !path.endsWith("/")) path += "/";
997
1119
  const headers = new Headers(request.headers);
@@ -1002,8 +1124,11 @@ function aiReady(manifest, options = {}) {
1002
1124
  }
1003
1125
  headers.set("x-forwarded-proto", mcpReq.headers.get("x-forwarded-proto") ?? incoming.protocol.slice(0, -1));
1004
1126
  headers.set("x-forwarded-host", mcpReq.headers.get("x-forwarded-host") ?? mcpReq.headers.get("host") ?? incoming.host);
1005
- const bypass = env2().VERCEL_AUTOMATION_BYPASS_SECRET;
1127
+ const bypass = env3().VERCEL_AUTOMATION_BYPASS_SECRET;
1006
1128
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
1129
+ const signedIp = await signAgentIp(clientIp(mcpReq.headers));
1130
+ if (signedIp) headers.set(AGENT_IP_HEADER, signedIp);
1131
+ else headers.delete(AGENT_IP_HEADER);
1007
1132
  const body = request.body ? await request.text() : void 0;
1008
1133
  const actionKey = actionUrl?.exec(target.pathname)?.[1];
1009
1134
  if (actionKey !== void 0) {
@@ -1046,34 +1171,34 @@ function aiReady(manifest, options = {}) {
1046
1171
  const reply = encodeReply(actionArguments(live.action.params, fields, jsonFieldsOf(live.action)));
1047
1172
  return fetch(new URL(path, origin), { method: "POST", headers: out, body: reply, redirect: "manual", cache: "no-store" });
1048
1173
  };
1049
- const appLoginFor = (req, spec) => ({
1050
- label: spec.kind === "action" ? `server action ${spec.key}` : `POST ${spec.path}`,
1051
- fields: spec.fields,
1052
- signedOutPaths: cfg.signedOutPaths ?? [],
1053
- attempt: async (values, ctx) => {
1054
- const target = options.baseUrl ?? env2().EXPOSIFY_BASE_URL ?? new URL(req.url).origin;
1174
+ const appLoginFor = (req, spec) => {
1175
+ const pathOf = (p) => {
1176
+ let path = `${manifest.basePath ?? ""}${p}`;
1177
+ if (manifest.trailingSlash && !path.endsWith("/")) path += "/";
1178
+ return path;
1179
+ };
1180
+ const stepPath = (step) => step.kind === "action" ? step.host ? pathOf(hostPath(step.host)) : void 0 : pathOf(step.path);
1181
+ const verifyPaths = spec.verify ? [stepPath(spec.verify)].filter((p) => Boolean(p)) : void 0;
1182
+ const send = async (step, values, ctx, reading, cookies = []) => {
1183
+ const target = options.baseUrl ?? env3().EXPOSIFY_BASE_URL ?? new URL(req.url).origin;
1055
1184
  const pub = new URL(ctx.origin);
1056
1185
  const headers = new Headers({ origin: pub.origin, "x-forwarded-proto": pub.protocol.slice(0, -1), "x-forwarded-host": pub.host });
1057
1186
  if (ctx.ip) headers.set("x-forwarded-for", ctx.ip);
1058
1187
  const ua = req.headers.get("user-agent");
1059
1188
  if (ua) headers.set("user-agent", ua);
1060
- const bypass = env2().VERCEL_AUTOMATION_BYPASS_SECRET;
1189
+ if (cookies.length) headers.set("cookie", cookies.map((c) => `${c.name}=${c.value}`).join("; "));
1190
+ const bypass = env3().VERCEL_AUTOMATION_BYPASS_SECRET;
1061
1191
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
1062
- const pathOf = (p) => {
1063
- let path = `${manifest.basePath ?? ""}${p}`;
1064
- if (manifest.trailingSlash && !path.endsWith("/")) path += "/";
1065
- return path;
1066
- };
1067
- if (spec.kind === "route") {
1068
- headers.set("content-type", spec.body === "form" ? "application/x-www-form-urlencoded" : "application/json");
1069
- const body = spec.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
1070
- return loginResultFromResponse(await fetch(new URL(pathOf(spec.path), target), { method: "POST", headers, body, redirect: "manual", cache: "no-store" }));
1192
+ if (step.kind === "route") {
1193
+ headers.set("content-type", step.body === "form" ? "application/x-www-form-urlencoded" : "application/json");
1194
+ const body = step.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
1195
+ return loginResultFromResponse(await fetch(new URL(pathOf(step.path), target), { method: "POST", headers, body, redirect: "manual", cache: "no-store" }), reading);
1071
1196
  }
1072
- const action = (manifest.actions ?? []).find((a) => a.key === spec.key);
1197
+ const action = (manifest.actions ?? []).find((a) => a.key === step.key);
1073
1198
  const fn = action ? (await action.load())[action.export] : void 0;
1074
1199
  const id = fn?.$$id;
1075
1200
  if (!action?.host || typeof id !== "string") {
1076
- warnOnce(`server action ${spec.key} is your app's login, but no page uses it, so the sign-in form can't run it; use it from a page's form`);
1201
+ warnOnce(`server action ${step.key} is part of your app's login, but no page uses it, so the sign-in form can't run it; use it from a page's form`);
1077
1202
  return { ok: false, message: "This app's sign-in can't be used by AI assistants yet." };
1078
1203
  }
1079
1204
  headers.set("next-action", id);
@@ -1081,9 +1206,28 @@ function aiReady(manifest, options = {}) {
1081
1206
  headers.set("accept", "text/x-component");
1082
1207
  const reply = encodeReply(actionArguments(action.params, values, jsonFieldsOf(action)));
1083
1208
  const raw = await fetch(new URL(pathOf(hostPath(action.host)), target), { method: "POST", headers, body: reply, redirect: "manual", cache: "no-store" });
1084
- return actionLoginResult(raw, action.export);
1085
- }
1086
- });
1209
+ return actionLoginResult(raw, action.export, reading);
1210
+ };
1211
+ const verify = spec.verify;
1212
+ return {
1213
+ label: spec.kind === "action" ? `server action ${spec.key}` : `POST ${spec.path}`,
1214
+ fields: spec.fields,
1215
+ signedOutPaths: cfg.signedOutPaths ?? [],
1216
+ ...spec.passwordless ? { passwordless: true } : {},
1217
+ attempt: (values, ctx) => send(spec, values, ctx, {
1218
+ ...verifyPaths ? { verifyPaths } : {},
1219
+ ...verify ? { verifyFields: verify.fields.map((f) => f.name) } : {},
1220
+ ...spec.passwordless ? { start: true } : {}
1221
+ }),
1222
+ ...verify ? {
1223
+ verify: {
1224
+ label: verify.kind === "action" ? `server action ${verify.key}` : `POST ${verify.path}`,
1225
+ fields: verify.fields,
1226
+ attempt: (values, ctx) => send(verify, values, ctx, { carried: ctx.carry.cookies, verifyPaths: verifyPaths ?? [] }, ctx.carry.cookies)
1227
+ }
1228
+ } : {}
1229
+ };
1230
+ };
1087
1231
  const carrySession = (raw, out) => {
1088
1232
  const lines = setCookieLines(raw);
1089
1233
  const redirect = raw.headers.get("x-action-redirect")?.split(";")[0];
@@ -1128,9 +1272,10 @@ function aiReady(manifest, options = {}) {
1128
1272
  };
1129
1273
  return async (req) => {
1130
1274
  const pathname = new URL(req.url).pathname;
1131
- if (login && samePath(pathname, [loginPagePath, `${manifest.basePath ?? ""}${loginPagePath}`])) {
1275
+ const supabaseLogin = supabaseLoginNow();
1276
+ if ((login || supabaseLogin) && samePath(pathname, [loginPagePath, `${manifest.basePath ?? ""}${loginPagePath}`])) {
1132
1277
  const pub = new URL(publicRequestUrl(req.url, req.headers));
1133
- return handleLoginPage(req, appLoginFor(req, login), hostedResolverInfo(cfg.auth), {
1278
+ return handleLoginPage(req, supabaseLogin ?? appLoginFor(req, login), hostedResolverInfo(cfg.auth), {
1134
1279
  path: pub.pathname,
1135
1280
  secure: pub.protocol === "https:",
1136
1281
  origin: pub.origin,
@@ -1157,7 +1302,13 @@ function aiReady(manifest, options = {}) {
1157
1302
  const url = publicRequestUrl(req.url, req.headers);
1158
1303
  const isPrm = url.endsWith(PRM_SUFFIX);
1159
1304
  const resource = brokerGate.resourceFor(isPrm ? url.slice(0, -PRM_SUFFIX.length) : url);
1160
- const verdict = await reportAppLogin(cfg.auth, resource, login, { where: login?.where, stacks: manifest.authStacks, log: logReady });
1305
+ const supabaseAuth2 = await supabaseReport();
1306
+ const verdict = await reportAppLogin(cfg.auth, resource, supabaseLogin ?? login, {
1307
+ where: supabaseLogin ? "your Supabase Auth (password)" : login?.where,
1308
+ stacks: manifest.authStacks,
1309
+ log: logReady,
1310
+ ...supabaseAuth2 ? { supabase: supabaseAuth2 } : {}
1311
+ });
1161
1312
  if (verdict === "open") {
1162
1313
  if (isPrm) return new Response("not found", { status: 404 });
1163
1314
  } else {
@@ -1175,7 +1326,9 @@ function aiReady(manifest, options = {}) {
1175
1326
  };
1176
1327
  }
1177
1328
  export {
1329
+ AGENT_IP_HEADER,
1178
1330
  DISPATCH_HEADER,
1331
+ agentIp,
1179
1332
  aiReady,
1180
1333
  completeMcpLogin,
1181
1334
  createHostedAuthResolver,