@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 +20 -4
- package/dist/config.cjs +4 -4
- package/dist/config.js +4 -4
- package/dist/exposify-next.js +7 -7
- package/dist/index.cjs +58 -8
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +56 -8
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
290
|
-
are the same as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/dist/exposify-next.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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};
|
|
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
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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
|
|
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};
|
|
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
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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.
|
|
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.
|
|
36
|
+
"@exposify/core": "0.3.15",
|
|
37
37
|
"tinyglobby": "^0.2.10",
|
|
38
38
|
"zod": "^3.24.0"
|
|
39
39
|
},
|