@exposify/next 0.0.2 → 0.0.3

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
@@ -6,7 +6,7 @@ your own app.
6
6
 
7
7
  ```sh
8
8
  npm install @exposify/next
9
- npx exposify-next setup # pick the endpoints, get the route file to create
9
+ npx @exposify/next setup # pick the endpoints, get the route file to create
10
10
  ```
11
11
 
12
12
  Or by hand, in two files:
@@ -32,7 +32,7 @@ export { handler as GET, handler as POST, handler as DELETE };
32
32
  build` starts, and keeps it current in dev as route files change. It holds
33
33
  the route table, your Server Actions, your docs (`README.md`, `docs/**/*.md`) for `search_docs`,
34
34
  and `exposify.descriptions.json`'s texts, so nothing is read from disk at
35
- runtime. Commit it or ignore it; `npx exposify-next generate` writes it too.
35
+ runtime. Commit it or ignore it; `npx @exposify/next generate` writes it too.
36
36
 
37
37
  ## Which routes become tools
38
38
 
@@ -79,6 +79,22 @@ it. The MCP client's address and host are forwarded as
79
79
  - **Vercel Deployment Protection** blocks those requests on protected
80
80
  previews: set `VERCEL_AUTOMATION_BYPASS_SECRET` (Vercel's "Protection
81
81
  Bypass for Automation") and it's sent as `x-vercel-protection-bypass`.
82
+ Without it, a tool call fails with an error that says so. An MCP client
83
+ outside Vercel reaches a protected preview with
84
+ `?x-vercel-protection-bypass=<secret>` on the MCP URL.
85
+ - **The agent's address:** some hosts (Vercel) replace `x-forwarded-for` on
86
+ these requests with their own. Where a route rate-limits or logs by IP,
87
+ use `agentIp()`: the agent's address on a tool call (signed with
88
+ `EXPOSIFY_KEY`, so it can't be forged), the usual `x-forwarded-for`
89
+ otherwise.
90
+
91
+ ```ts
92
+ import { agentIp } from "@exposify/next";
93
+
94
+ export async function POST(req: Request) {
95
+ const ip = await agentIp(req);
96
+ }
97
+ ```
82
98
  - `basePath` and `trailingSlash` from `next.config` are applied.
83
99
  - `rateLimit` / `rateWindowMs`: MCP requests per client address per window
84
100
  (default 60 per 60 s), in memory and per instance.
@@ -286,8 +302,8 @@ errors; the thrown message stays in your server log.
286
302
 
287
303
  ## CLI
288
304
 
289
- `npx exposify-next setup | inspect | describe | generate`. The first three
290
- are the same as `exposify-express`'s; the scan reads your route files
305
+ `npx @exposify/next setup | inspect | describe | generate`. The first three
306
+ are the same as `@exposify/express`'s; the scan reads your route files
291
307
  instead of booting the app.
292
308
 
293
309
  Server Actions get their own rows:
package/dist/config.cjs CHANGED
@@ -1930,9 +1930,9 @@ function renderManifest(d, opts) {
1930
1930
  return ` { name: ${j(p.name)}, path: ${j(p.path)}, params: ${j(p.params)}, file: ${j(p.file)}${extra} },`;
1931
1931
  });
1932
1932
  const lines = [
1933
- "// Generated by exposify-next from your route files. Don't edit: withExposify()",
1933
+ "// Generated by @exposify/next from your route files. Don't edit: withExposify()",
1934
1934
  "// in next.config regenerates it on `next dev` and `next build`, or run",
1935
- "// `npx exposify-next generate`.",
1935
+ "// `npx @exposify/next generate`.",
1936
1936
  "/* eslint-disable */",
1937
1937
  ...ts ? ['import type { ExposifyManifest } from "@exposify/next";', ""] : ["", '/** @type {import("@exposify/next").ExposifyManifest} */'],
1938
1938
  `export const exposify${ts ? ": ExposifyManifest" : ""} = {`,
@@ -1969,11 +1969,11 @@ function generate(root, opts = {}, warn = (m) => console.warn(`[exposify] ${m}`)
1969
1969
  authStacks: (0, import_core2.authStacksOf)((0, import_core2.dependencyNames)(pkg)).map((s) => s.pkg)
1970
1970
  });
1971
1971
  for (const a of discovery.actions.filter((x) => x.proposed?.status === "stale")) {
1972
- warn(`the input accepted for the ${a.action.name} server action (${a.file}) is out of date, since the action changed: it's not used until \`exposify-next describe\` reviews it again`);
1972
+ warn(`the input accepted for the ${a.action.name} server action (${a.file}) is out of date, since the action changed: it's not used until \`@exposify/next describe\` reviews it again`);
1973
1973
  }
1974
1974
  if (discovery.actions.length > 0 && !discovery.mcpPath) {
1975
1975
  warn(
1976
- discovery.mcpRoute ? `found ${discovery.actions.length} Server Action(s), but the MCP route (${discovery.mcpRoute.file}) isn't a catch-all route, which they're served under: move it to app/mcp/[[...transport]]/route.ts` : `found ${discovery.actions.length} Server Action(s), but no MCP route to serve them under: create app/mcp/[[...transport]]/route.ts (npx exposify-next setup)`
1976
+ discovery.mcpRoute ? `found ${discovery.actions.length} Server Action(s), but the MCP route (${discovery.mcpRoute.file}) isn't a catch-all route, which they're served under: move it to app/mcp/[[...transport]]/route.ts` : `found ${discovery.actions.length} Server Action(s), but no MCP route to serve them under: create app/mcp/[[...transport]]/route.ts (npx @exposify/next setup)`
1977
1977
  );
1978
1978
  }
1979
1979
  const file = (0, import_node_path6.join)(root, outFile);
package/dist/config.js CHANGED
@@ -1863,9 +1863,9 @@ function renderManifest(d, opts) {
1863
1863
  return ` { name: ${j(p.name)}, path: ${j(p.path)}, params: ${j(p.params)}, file: ${j(p.file)}${extra} },`;
1864
1864
  });
1865
1865
  const lines = [
1866
- "// Generated by exposify-next from your route files. Don't edit: withExposify()",
1866
+ "// Generated by @exposify/next from your route files. Don't edit: withExposify()",
1867
1867
  "// in next.config regenerates it on `next dev` and `next build`, or run",
1868
- "// `npx exposify-next generate`.",
1868
+ "// `npx @exposify/next generate`.",
1869
1869
  "/* eslint-disable */",
1870
1870
  ...ts ? ['import type { ExposifyManifest } from "@exposify/next";', ""] : ["", '/** @type {import("@exposify/next").ExposifyManifest} */'],
1871
1871
  `export const exposify${ts ? ": ExposifyManifest" : ""} = {`,
@@ -1902,11 +1902,11 @@ function generate(root, opts = {}, warn = (m) => console.warn(`[exposify] ${m}`)
1902
1902
  authStacks: authStacksOf(dependencyNames(pkg)).map((s) => s.pkg)
1903
1903
  });
1904
1904
  for (const a of discovery.actions.filter((x) => x.proposed?.status === "stale")) {
1905
- warn(`the input accepted for the ${a.action.name} server action (${a.file}) is out of date, since the action changed: it's not used until \`exposify-next describe\` reviews it again`);
1905
+ warn(`the input accepted for the ${a.action.name} server action (${a.file}) is out of date, since the action changed: it's not used until \`@exposify/next describe\` reviews it again`);
1906
1906
  }
1907
1907
  if (discovery.actions.length > 0 && !discovery.mcpPath) {
1908
1908
  warn(
1909
- discovery.mcpRoute ? `found ${discovery.actions.length} Server Action(s), but the MCP route (${discovery.mcpRoute.file}) isn't a catch-all route, which they're served under: move it to app/mcp/[[...transport]]/route.ts` : `found ${discovery.actions.length} Server Action(s), but no MCP route to serve them under: create app/mcp/[[...transport]]/route.ts (npx exposify-next setup)`
1909
+ discovery.mcpRoute ? `found ${discovery.actions.length} Server Action(s), but the MCP route (${discovery.mcpRoute.file}) isn't a catch-all route, which they're served under: move it to app/mcp/[[...transport]]/route.ts` : `found ${discovery.actions.length} Server Action(s), but no MCP route to serve them under: create app/mcp/[[...transport]]/route.ts (npx @exposify/next setup)`
1910
1910
  );
1911
1911
  }
1912
1912
  const file = join6(root, outFile);
@@ -1961,9 +1961,9 @@ function renderManifest(d, opts) {
1961
1961
  return ` { name: ${j(p.name)}, path: ${j(p.path)}, params: ${j(p.params)}, file: ${j(p.file)}${extra} },`;
1962
1962
  });
1963
1963
  const lines = [
1964
- "// Generated by exposify-next from your route files. Don't edit: withExposify()",
1964
+ "// Generated by @exposify/next from your route files. Don't edit: withExposify()",
1965
1965
  "// in next.config regenerates it on `next dev` and `next build`, or run",
1966
- "// `npx exposify-next generate`.",
1966
+ "// `npx @exposify/next generate`.",
1967
1967
  "/* eslint-disable */",
1968
1968
  ...ts ? ['import type { ExposifyManifest } from "@exposify/next";', ""] : ["", '/** @type {import("@exposify/next").ExposifyManifest} */'],
1969
1969
  `export const exposify${ts ? ": ExposifyManifest" : ""} = {`,
@@ -2000,11 +2000,11 @@ function generate(root, opts = {}, warn = (m) => console.warn(`[exposify] ${m}`)
2000
2000
  authStacks: authStacksOf(dependencyNames(pkg)).map((s) => s.pkg)
2001
2001
  });
2002
2002
  for (const a of discovery.actions.filter((x) => x.proposed?.status === "stale")) {
2003
- warn(`the input accepted for the ${a.action.name} server action (${a.file}) is out of date, since the action changed: it's not used until \`exposify-next describe\` reviews it again`);
2003
+ warn(`the input accepted for the ${a.action.name} server action (${a.file}) is out of date, since the action changed: it's not used until \`@exposify/next describe\` reviews it again`);
2004
2004
  }
2005
2005
  if (discovery.actions.length > 0 && !discovery.mcpPath) {
2006
2006
  warn(
2007
- discovery.mcpRoute ? `found ${discovery.actions.length} Server Action(s), but the MCP route (${discovery.mcpRoute.file}) isn't a catch-all route, which they're served under: move it to app/mcp/[[...transport]]/route.ts` : `found ${discovery.actions.length} Server Action(s), but no MCP route to serve them under: create app/mcp/[[...transport]]/route.ts (npx exposify-next setup)`
2007
+ discovery.mcpRoute ? `found ${discovery.actions.length} Server Action(s), but the MCP route (${discovery.mcpRoute.file}) isn't a catch-all route, which they're served under: move it to app/mcp/[[...transport]]/route.ts` : `found ${discovery.actions.length} Server Action(s), but no MCP route to serve them under: create app/mcp/[[...transport]]/route.ts (npx @exposify/next setup)`
2008
2008
  );
2009
2009
  }
2010
2010
  const file = join6(root, outFile);
@@ -2387,7 +2387,7 @@ function findNextRoot(start) {
2387
2387
  }
2388
2388
  }
2389
2389
  var nextAdapter = {
2390
- bin: "exposify-next",
2390
+ bin: "npx @exposify/next",
2391
2391
  entryExample: "apps/web",
2392
2392
  entryHelp: "The Next.js project directory",
2393
2393
  locate(cwd, entryFlag) {
@@ -2591,7 +2591,7 @@ function applyReplace(site, config, entries, usesZod, relFile) {
2591
2591
  }
2592
2592
 
2593
2593
  // bin/exposify-next.ts
2594
- var GENERATE_USAGE = `Usage: exposify-next generate [options]
2594
+ var GENERATE_USAGE = `Usage: @exposify/next generate [options]
2595
2595
 
2596
2596
  Writes exposify.generated.ts (the route table and docs the MCP route uses).
2597
2597
  withExposify() in next.config runs this on \`next dev\` and \`next build\`.
@@ -2638,7 +2638,7 @@ ${GENERATE_USAGE}`);
2638
2638
  console.log(
2639
2639
  `${result.changed ? "Wrote" : "Unchanged:"} ${relative4(cwd, result.file) || result.file}: ${app} App Router route file${app === 1 ? "" : "s"}, ${routes.length - app} Pages Router API file${routes.length - app === 1 ? "" : "s"}.`
2640
2640
  );
2641
- if (!result.discovery.mcpRoute) console.log("No MCP route yet: run `npx exposify-next setup`.");
2641
+ if (!result.discovery.mcpRoute) console.log("No MCP route yet: run `npx @exposify/next setup`.");
2642
2642
  return 0;
2643
2643
  }
2644
2644
  var argv = process.argv.slice(2);
package/dist/index.cjs CHANGED
@@ -20,7 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AGENT_IP_HEADER: () => AGENT_IP_HEADER,
23
24
  DISPATCH_HEADER: () => DISPATCH_HEADER,
25
+ agentIp: () => agentIp,
24
26
  aiReady: () => aiReady,
25
27
  completeMcpLogin: () => import_core2.completeMcpLogin,
26
28
  createHostedAuthResolver: () => import_core2.createHostedAuthResolver,
@@ -637,6 +639,49 @@ async function readPageResponse(res, url) {
637
639
  // src/routes.ts
638
640
  var HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
639
641
 
642
+ // src/agent-ip.ts
643
+ var AGENT_IP_HEADER = "x-exposify-agent-ip";
644
+ var MAX_AGE_S = 300;
645
+ function env2() {
646
+ return globalThis.process?.env ?? {};
647
+ }
648
+ var keyCache = /* @__PURE__ */ new Map();
649
+ function hmacKey(secret) {
650
+ let key = keyCache.get(secret);
651
+ if (!key) {
652
+ key = (async () => {
653
+ const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`exposify agent-ip v1
654
+ ${secret}`));
655
+ return crypto.subtle.importKey("raw", material, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
656
+ })();
657
+ keyCache.set(secret, key);
658
+ }
659
+ return key;
660
+ }
661
+ var hex2 = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
662
+ async function signAgentIp(ip, secret = env2().EXPOSIFY_KEY, now = Date.now()) {
663
+ if (!ip || !secret || /[;\s]/.test(ip) || ip.length > 64) return null;
664
+ const t = Math.floor(now / 1e3);
665
+ const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(`${ip};${t}`));
666
+ return `${ip};${t};${hex2(sig)}`;
667
+ }
668
+ function forwardedIp(headers) {
669
+ return headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip")?.trim() || void 0;
670
+ }
671
+ async function agentIp(request, options = {}) {
672
+ const signed = request.headers.get(AGENT_IP_HEADER);
673
+ const secret = options.key ?? env2().EXPOSIFY_KEY;
674
+ if (signed && secret) {
675
+ const [ip, t, sig] = signed.split(";");
676
+ const age = Math.floor((options.now ?? Date.now()) / 1e3) - Number(t);
677
+ if (ip && sig && /^[0-9a-f]{64}$/.test(sig) && Number.isInteger(Number(t)) && age >= -60 && age <= MAX_AGE_S) {
678
+ const bytes = new Uint8Array(sig.match(/../g).map((b) => parseInt(b, 16)));
679
+ if (await crypto.subtle.verify("HMAC", await hmacKey(secret), bytes, new TextEncoder().encode(`${ip};${t}`))) return ip;
680
+ }
681
+ }
682
+ return forwardedIp(request.headers);
683
+ }
684
+
640
685
  // src/index.ts
641
686
  var import_core2 = require("@exposify/core");
642
687
  var DISPATCH_HEADER = "x-exposify-dispatch";
@@ -645,7 +690,7 @@ var LOG_PREFIX = "[agentready]";
645
690
  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.";
646
691
  var LEFT_BLANK = "A field you leave out is as if left blank on the form.";
647
692
  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.";
648
- function env2() {
693
+ function env3() {
649
694
  return globalThis.process?.env ?? {};
650
695
  }
651
696
  function errorMessage(err) {
@@ -708,7 +753,7 @@ function derivedInput(action) {
708
753
  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 } : {} }));
709
754
  return { input: Object.fromEntries(fields.map((f) => [f.name, fieldSchema(f)])) };
710
755
  }
711
- if (reason) return { reason: action.fields.some((f) => f.type === "file") ? reason : `${reason}; \`exposify-next describe\` can propose it for you to review` };
756
+ if (reason) return { reason: action.fields.some((f) => f.type === "file") ? reason : `${reason}; \`@exposify/next describe\` can propose it for you to review` };
712
757
  return { input: Object.fromEntries(action.fields.map((f) => [f.name, fieldSchema(f)])) };
713
758
  }
714
759
  var sentence = (s) => /[.!?]$/.test(s.trim()) ? s.trim() : `${s.trim()}.`;
@@ -959,7 +1004,7 @@ function aiReady(manifest, options = {}) {
959
1004
  (0, import_core.formatWarning)(
960
1005
  LOG_PREFIX,
961
1006
  `MCP is up at ${endpoint} but exposes no route tools`,
962
- "check that exposify.generated.ts is current (withExposify() in next.config, or `npx exposify-next generate`)"
1007
+ "check that exposify.generated.ts is current (withExposify() in next.config, or `npx @exposify/next generate`)"
963
1008
  )
964
1009
  );
965
1010
  return;
@@ -973,7 +1018,7 @@ function aiReady(manifest, options = {}) {
973
1018
  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)`);
974
1019
  if (exposedActions > 0) details.push(`${exposedActions} server action${exposedActions === 1 ? "" : "s"} exposed, each checking auth itself`);
975
1020
  if (hiddenActions > 0) details.push(`${hiddenActions} server action${hiddenActions === 1 ? "" : "s"} hidden (opt in with aiReady({ actions }))`);
976
- const e = env2();
1021
+ const e = env3();
977
1022
  if (e.VERCEL && e.VERCEL_ENV !== "production" && !e.VERCEL_AUTOMATION_BYPASS_SECRET) {
978
1023
  details.push("on a protected Vercel preview, tool calls need VERCEL_AUTOMATION_BYPASS_SECRET");
979
1024
  }
@@ -983,7 +1028,7 @@ function aiReady(manifest, options = {}) {
983
1028
  const selfFetch = async (request, mcpReq, route) => {
984
1029
  const target = new URL(request.url);
985
1030
  const incoming = new URL(mcpReq.url);
986
- const origin = options.baseUrl ?? env2().EXPOSIFY_BASE_URL ?? incoming.origin;
1031
+ const origin = options.baseUrl ?? env3().EXPOSIFY_BASE_URL ?? incoming.origin;
987
1032
  let path = `${manifest.basePath ?? ""}${target.pathname}`;
988
1033
  if (manifest.trailingSlash && !path.endsWith("/")) path += "/";
989
1034
  const headers = new Headers(request.headers);
@@ -994,8 +1039,11 @@ function aiReady(manifest, options = {}) {
994
1039
  }
995
1040
  headers.set("x-forwarded-proto", mcpReq.headers.get("x-forwarded-proto") ?? incoming.protocol.slice(0, -1));
996
1041
  headers.set("x-forwarded-host", mcpReq.headers.get("x-forwarded-host") ?? mcpReq.headers.get("host") ?? incoming.host);
997
- const bypass = env2().VERCEL_AUTOMATION_BYPASS_SECRET;
1042
+ const bypass = env3().VERCEL_AUTOMATION_BYPASS_SECRET;
998
1043
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
1044
+ const signedIp = await signAgentIp(clientIp(mcpReq.headers));
1045
+ if (signedIp) headers.set(AGENT_IP_HEADER, signedIp);
1046
+ else headers.delete(AGENT_IP_HEADER);
999
1047
  const body = request.body ? await request.text() : void 0;
1000
1048
  const actionKey = actionUrl?.exec(target.pathname)?.[1];
1001
1049
  if (actionKey !== void 0) {
@@ -1043,13 +1091,13 @@ function aiReady(manifest, options = {}) {
1043
1091
  fields: spec.fields,
1044
1092
  signedOutPaths: cfg.signedOutPaths ?? [],
1045
1093
  attempt: async (values, ctx) => {
1046
- const target = options.baseUrl ?? env2().EXPOSIFY_BASE_URL ?? new URL(req.url).origin;
1094
+ const target = options.baseUrl ?? env3().EXPOSIFY_BASE_URL ?? new URL(req.url).origin;
1047
1095
  const pub = new URL(ctx.origin);
1048
1096
  const headers = new Headers({ origin: pub.origin, "x-forwarded-proto": pub.protocol.slice(0, -1), "x-forwarded-host": pub.host });
1049
1097
  if (ctx.ip) headers.set("x-forwarded-for", ctx.ip);
1050
1098
  const ua = req.headers.get("user-agent");
1051
1099
  if (ua) headers.set("user-agent", ua);
1052
- const bypass = env2().VERCEL_AUTOMATION_BYPASS_SECRET;
1100
+ const bypass = env3().VERCEL_AUTOMATION_BYPASS_SECRET;
1053
1101
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
1054
1102
  const pathOf = (p) => {
1055
1103
  let path = `${manifest.basePath ?? ""}${p}`;
@@ -1168,7 +1216,9 @@ function aiReady(manifest, options = {}) {
1168
1216
  }
1169
1217
  // Annotate the CommonJS export names for ESM import in node:
1170
1218
  0 && (module.exports = {
1219
+ AGENT_IP_HEADER,
1171
1220
  DISPATCH_HEADER,
1221
+ agentIp,
1172
1222
  aiReady,
1173
1223
  completeMcpLogin,
1174
1224
  createHostedAuthResolver,
package/dist/index.d.cts CHANGED
@@ -122,6 +122,22 @@ interface ExposifyManifest {
122
122
  descriptions?: Record<string, string>;
123
123
  }
124
124
 
125
+ /** Header carrying `<ip>;<unix seconds>;<signature>` on a tool call. */
126
+ declare const AGENT_IP_HEADER = "x-exposify-agent-ip";
127
+ /**
128
+ * The address of whoever made this request: on a tool call, the AI agent's
129
+ * (verified with EXPOSIFY_KEY, so it can't be forged); otherwise the
130
+ * platform's `x-forwarded-for`, as you'd read it anyway. Use it where a
131
+ * route rate-limits or logs by IP: on Vercel, a tool call's own
132
+ * `x-forwarded-for` is the function's address, not the agent's.
133
+ */
134
+ declare function agentIp(request: {
135
+ headers: Headers;
136
+ }, options?: {
137
+ key?: string;
138
+ now?: number;
139
+ }): Promise<string | undefined>;
140
+
125
141
  /** Set on every tool call; the MCP route refuses requests carrying it, so a tool can never call the MCP endpoint. */
126
142
  declare const DISPATCH_HEADER = "x-exposify-dispatch";
127
143
  type InputShape = NonNullable<Extract<RouteMarker, {
@@ -198,4 +214,4 @@ interface NextPageOptions {
198
214
  type NextMcpHandler = (req: Request) => Promise<Response>;
199
215
  declare function aiReady(manifest: ExposifyManifest, options?: NextAiReadyOptions): NextMcpHandler;
200
216
 
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 };
217
+ export { AGENT_IP_HEADER, type AppRouteEntry, DISPATCH_HEADER, type ExposifyManifest, type ManifestAction, type ManifestLogin, type ManifestPage, type ManifestRoute, type NextActionOptions, type NextAiReadyOptions, type NextMcpHandler, type NextPageOptions, type PagesRouteEntry, agentIp, aiReady };
package/dist/index.d.ts CHANGED
@@ -122,6 +122,22 @@ interface ExposifyManifest {
122
122
  descriptions?: Record<string, string>;
123
123
  }
124
124
 
125
+ /** Header carrying `<ip>;<unix seconds>;<signature>` on a tool call. */
126
+ declare const AGENT_IP_HEADER = "x-exposify-agent-ip";
127
+ /**
128
+ * The address of whoever made this request: on a tool call, the AI agent's
129
+ * (verified with EXPOSIFY_KEY, so it can't be forged); otherwise the
130
+ * platform's `x-forwarded-for`, as you'd read it anyway. Use it where a
131
+ * route rate-limits or logs by IP: on Vercel, a tool call's own
132
+ * `x-forwarded-for` is the function's address, not the agent's.
133
+ */
134
+ declare function agentIp(request: {
135
+ headers: Headers;
136
+ }, options?: {
137
+ key?: string;
138
+ now?: number;
139
+ }): Promise<string | undefined>;
140
+
125
141
  /** Set on every tool call; the MCP route refuses requests carrying it, so a tool can never call the MCP endpoint. */
126
142
  declare const DISPATCH_HEADER = "x-exposify-dispatch";
127
143
  type InputShape = NonNullable<Extract<RouteMarker, {
@@ -198,4 +214,4 @@ interface NextPageOptions {
198
214
  type NextMcpHandler = (req: Request) => Promise<Response>;
199
215
  declare function aiReady(manifest: ExposifyManifest, options?: NextAiReadyOptions): NextMcpHandler;
200
216
 
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 };
217
+ export { AGENT_IP_HEADER, type AppRouteEntry, DISPATCH_HEADER, type ExposifyManifest, type ManifestAction, type ManifestLogin, type ManifestPage, type ManifestRoute, type NextActionOptions, type NextAiReadyOptions, type NextMcpHandler, type NextPageOptions, type PagesRouteEntry, agentIp, aiReady };
package/dist/index.js CHANGED
@@ -638,6 +638,49 @@ async function readPageResponse(res, url) {
638
638
  return Response.json(body);
639
639
  }
640
640
 
641
+ // src/agent-ip.ts
642
+ var AGENT_IP_HEADER = "x-exposify-agent-ip";
643
+ var MAX_AGE_S = 300;
644
+ function env2() {
645
+ return globalThis.process?.env ?? {};
646
+ }
647
+ var keyCache = /* @__PURE__ */ new Map();
648
+ function hmacKey(secret) {
649
+ let key = keyCache.get(secret);
650
+ if (!key) {
651
+ key = (async () => {
652
+ const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`exposify agent-ip v1
653
+ ${secret}`));
654
+ return crypto.subtle.importKey("raw", material, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
655
+ })();
656
+ keyCache.set(secret, key);
657
+ }
658
+ return key;
659
+ }
660
+ var hex2 = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
661
+ async function signAgentIp(ip, secret = env2().EXPOSIFY_KEY, now = Date.now()) {
662
+ if (!ip || !secret || /[;\s]/.test(ip) || ip.length > 64) return null;
663
+ const t = Math.floor(now / 1e3);
664
+ const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(`${ip};${t}`));
665
+ return `${ip};${t};${hex2(sig)}`;
666
+ }
667
+ function forwardedIp(headers) {
668
+ return headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip")?.trim() || void 0;
669
+ }
670
+ async function agentIp(request, options = {}) {
671
+ const signed = request.headers.get(AGENT_IP_HEADER);
672
+ const secret = options.key ?? env2().EXPOSIFY_KEY;
673
+ if (signed && secret) {
674
+ const [ip, t, sig] = signed.split(";");
675
+ const age = Math.floor((options.now ?? Date.now()) / 1e3) - Number(t);
676
+ if (ip && sig && /^[0-9a-f]{64}$/.test(sig) && Number.isInteger(Number(t)) && age >= -60 && age <= MAX_AGE_S) {
677
+ const bytes = new Uint8Array(sig.match(/../g).map((b) => parseInt(b, 16)));
678
+ if (await crypto.subtle.verify("HMAC", await hmacKey(secret), bytes, new TextEncoder().encode(`${ip};${t}`))) return ip;
679
+ }
680
+ }
681
+ return forwardedIp(request.headers);
682
+ }
683
+
641
684
  // src/index.ts
642
685
  import {
643
686
  exposed,
@@ -653,7 +696,7 @@ var LOG_PREFIX = "[agentready]";
653
696
  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
697
  var LEFT_BLANK = "A field you leave out is as if left blank on the form.";
655
698
  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() {
699
+ function env3() {
657
700
  return globalThis.process?.env ?? {};
658
701
  }
659
702
  function errorMessage(err) {
@@ -716,7 +759,7 @@ function derivedInput(action) {
716
759
  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
760
  return { input: Object.fromEntries(fields.map((f) => [f.name, fieldSchema(f)])) };
718
761
  }
719
- if (reason) return { reason: action.fields.some((f) => f.type === "file") ? reason : `${reason}; \`exposify-next describe\` can propose it for you to review` };
762
+ if (reason) return { reason: action.fields.some((f) => f.type === "file") ? reason : `${reason}; \`@exposify/next describe\` can propose it for you to review` };
720
763
  return { input: Object.fromEntries(action.fields.map((f) => [f.name, fieldSchema(f)])) };
721
764
  }
722
765
  var sentence = (s) => /[.!?]$/.test(s.trim()) ? s.trim() : `${s.trim()}.`;
@@ -967,7 +1010,7 @@ function aiReady(manifest, options = {}) {
967
1010
  formatWarning(
968
1011
  LOG_PREFIX,
969
1012
  `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`)"
1013
+ "check that exposify.generated.ts is current (withExposify() in next.config, or `npx @exposify/next generate`)"
971
1014
  )
972
1015
  );
973
1016
  return;
@@ -981,7 +1024,7 @@ function aiReady(manifest, options = {}) {
981
1024
  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
1025
  if (exposedActions > 0) details.push(`${exposedActions} server action${exposedActions === 1 ? "" : "s"} exposed, each checking auth itself`);
983
1026
  if (hiddenActions > 0) details.push(`${hiddenActions} server action${hiddenActions === 1 ? "" : "s"} hidden (opt in with aiReady({ actions }))`);
984
- const e = env2();
1027
+ const e = env3();
985
1028
  if (e.VERCEL && e.VERCEL_ENV !== "production" && !e.VERCEL_AUTOMATION_BYPASS_SECRET) {
986
1029
  details.push("on a protected Vercel preview, tool calls need VERCEL_AUTOMATION_BYPASS_SECRET");
987
1030
  }
@@ -991,7 +1034,7 @@ function aiReady(manifest, options = {}) {
991
1034
  const selfFetch = async (request, mcpReq, route) => {
992
1035
  const target = new URL(request.url);
993
1036
  const incoming = new URL(mcpReq.url);
994
- const origin = options.baseUrl ?? env2().EXPOSIFY_BASE_URL ?? incoming.origin;
1037
+ const origin = options.baseUrl ?? env3().EXPOSIFY_BASE_URL ?? incoming.origin;
995
1038
  let path = `${manifest.basePath ?? ""}${target.pathname}`;
996
1039
  if (manifest.trailingSlash && !path.endsWith("/")) path += "/";
997
1040
  const headers = new Headers(request.headers);
@@ -1002,8 +1045,11 @@ function aiReady(manifest, options = {}) {
1002
1045
  }
1003
1046
  headers.set("x-forwarded-proto", mcpReq.headers.get("x-forwarded-proto") ?? incoming.protocol.slice(0, -1));
1004
1047
  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;
1048
+ const bypass = env3().VERCEL_AUTOMATION_BYPASS_SECRET;
1006
1049
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
1050
+ const signedIp = await signAgentIp(clientIp(mcpReq.headers));
1051
+ if (signedIp) headers.set(AGENT_IP_HEADER, signedIp);
1052
+ else headers.delete(AGENT_IP_HEADER);
1007
1053
  const body = request.body ? await request.text() : void 0;
1008
1054
  const actionKey = actionUrl?.exec(target.pathname)?.[1];
1009
1055
  if (actionKey !== void 0) {
@@ -1051,13 +1097,13 @@ function aiReady(manifest, options = {}) {
1051
1097
  fields: spec.fields,
1052
1098
  signedOutPaths: cfg.signedOutPaths ?? [],
1053
1099
  attempt: async (values, ctx) => {
1054
- const target = options.baseUrl ?? env2().EXPOSIFY_BASE_URL ?? new URL(req.url).origin;
1100
+ const target = options.baseUrl ?? env3().EXPOSIFY_BASE_URL ?? new URL(req.url).origin;
1055
1101
  const pub = new URL(ctx.origin);
1056
1102
  const headers = new Headers({ origin: pub.origin, "x-forwarded-proto": pub.protocol.slice(0, -1), "x-forwarded-host": pub.host });
1057
1103
  if (ctx.ip) headers.set("x-forwarded-for", ctx.ip);
1058
1104
  const ua = req.headers.get("user-agent");
1059
1105
  if (ua) headers.set("user-agent", ua);
1060
- const bypass = env2().VERCEL_AUTOMATION_BYPASS_SECRET;
1106
+ const bypass = env3().VERCEL_AUTOMATION_BYPASS_SECRET;
1061
1107
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
1062
1108
  const pathOf = (p) => {
1063
1109
  let path = `${manifest.basePath ?? ""}${p}`;
@@ -1175,7 +1221,9 @@ function aiReady(manifest, options = {}) {
1175
1221
  };
1176
1222
  }
1177
1223
  export {
1224
+ AGENT_IP_HEADER,
1178
1225
  DISPATCH_HEADER,
1226
+ agentIp,
1179
1227
  aiReady,
1180
1228
  completeMcpLogin,
1181
1229
  createHostedAuthResolver,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/next",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@babel/parser": "^8.0.0",
35
35
  "@exposify/cli": "0.0.2",
36
- "@exposify/core": "0.3.14",
36
+ "@exposify/core": "0.3.15",
37
37
  "tinyglobby": "^0.2.10",
38
38
  "zod": "^3.24.0"
39
39
  },