@exposify/express 0.3.9 → 0.4.0

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
@@ -33,7 +33,7 @@ Tools get Nest's full paths (global prefix, URI versioning, `RouterModule`
33
33
  paths), and tool calls go through Nest itself: guards, pipes, interceptors
34
34
  and exception filters apply exactly as for any HTTP client, so a guarded
35
35
  route refuses an agent without credentials and a `ValidationPipe` error
36
- reaches the agent as an actionable 400. `npx exposify-express setup` and
36
+ reaches the agent as an actionable 400. `npx @exposify/express setup` and
37
37
  `inspect` find `src/main.ts` from `nest-cli.json` and load it with your
38
38
  `ts-node`/`swc-node`. The Fastify platform isn't supported yet. See
39
39
  `examples/nest-demo`.
@@ -54,12 +54,12 @@ takes a path to a JSON file, for any Express app with an OpenAPI document.
54
54
  Description precedence: `describe()` in code, then
55
55
  `exposify.descriptions.json`, then OpenAPI, then the derived text.
56
56
 
57
- ## Guided setup: `npx exposify-express setup`
57
+ ## Guided setup: `npx @exposify/express setup`
58
58
 
59
59
  Instead of writing the config by hand, run this in your app's directory:
60
60
 
61
61
  ```bash
62
- npx exposify-express setup
62
+ npx @exposify/express setup
63
63
  ```
64
64
 
65
65
  It loads your app without starting a server, lists every endpoint (reads
@@ -110,7 +110,7 @@ give your tools real descriptions (below).
110
110
 
111
111
  **Tool descriptions.** Agents pick tools by their descriptions, and by default
112
112
  they get one derived from the route ("Create an order (POST /orders)").
113
- `setup`'s step 5, or `npx exposify-express describe` any time, replaces those:
113
+ `setup`'s step 5, or `npx @exposify/express describe` any time, replaces those:
114
114
 
115
115
  - `a` sends each route's handler source to Exposify's description service,
116
116
  which writes a proposal with an LLM (`--no-source` sends only method, path
@@ -137,15 +137,15 @@ flagged for you to check. In CI, `describe --check` exits 1 if a tool has no
137
137
  description or one is out of date, and `describe --ci` writes AI
138
138
  descriptions for read routes only, listing the writes for a person.
139
139
 
140
- `npx exposify-express inspect` prints every route with its tool name,
140
+ `npx @exposify/express inspect` prints every route with its tool name,
141
141
  whether agents get it, and why (`--json` for scripts): the answer to "why
142
142
  isn't my route showing up?".
143
143
 
144
- **Catch tool changes in CI:** `npx exposify-express inspect --snapshot` writes
144
+ **Catch tool changes in CI:** `npx @exposify/express inspect --snapshot` writes
145
145
  `exposify.tools.json` (each route agents can call, and its tool name).
146
146
  `setup` offers to save the same file as its last question (default no;
147
147
  `--snapshot` to save without asking). Commit it, and run
148
- `npx exposify-express inspect --check` in CI: it exits 1,
148
+ `npx @exposify/express inspect --check` in CI: it exits 1,
149
149
  listing what changed, when a route becomes a tool (writes flagged), stops
150
150
  being one, or gets a new tool name. Re-run `--snapshot` when the change is
151
151
  intended, so it shows up in the PR diff for review.
@@ -168,11 +168,30 @@ signs in with your app's own login, with no code:
168
168
  as that person; cookies you set later are kept; a 401 or a redirect to the
169
169
  login ends the connection, so the assistant asks to connect again.
170
170
  - `aiReady()` tells Exposify about the login and its MCP address before
171
- answering `/mcp`, so there's nothing to add in the dashboard.
171
+ answering `/mcp`, so there's nothing to add in the dashboard. The address
172
+ comes from the request's `Host` (and `X-Forwarded-Host` only when you've
173
+ set Express's `trust proxy`, as you should behind a proxy). The first
174
+ domain reported is used as is; a new domain after that waits for you to
175
+ approve it on the dashboard, since any client can send a made-up `Host`.
172
176
 
173
177
  `login: { path, fields, body? }` names another route; `login: false` turns
174
178
  it off. Routers introspection can't see (sub-apps) need `mounts`.
175
179
 
180
+ Logins in two steps work too: when the login asks for a code next (a
181
+ redirect to a code page, or `{ mfaRequired: true }`), the form asks for it
182
+ and sends your second-step route (`POST /auth/2fa/verify`: named like
183
+ `verify`, `2fa`, `otp`, one code, sets the session) the code with the
184
+ cookies the first step set. Passwordless (an email, then a code) is found the
185
+ same way. `loginVerify: { path, fields, body? }` names the second step;
186
+ `loginVerify: false` turns it off.
187
+
188
+ **No login found, and no sign-in method in the dashboard?** Then agents use
189
+ your app signed out: `/mcp` doesn't ask them to sign in, and your own checks
190
+ decide what they can do. The startup log says which it is
191
+ (`[agentready] How agents sign in: ...`), and so does the dashboard's "How
192
+ agents sign in" page. Adding a sign-in method there takes effect within a
193
+ minute.
194
+
176
195
  ## "Click Connect and log in" (OAuth)
177
196
 
178
197
  By default the MCP client has to be handed a token by hand. If your app's
package/dist/cli.js CHANGED
@@ -227,7 +227,7 @@ ${last.output}`)) {
227
227
 
228
228
  // bin/adapter.ts
229
229
  var expressAdapter = {
230
- bin: "exposify-express",
230
+ bin: "npx @exposify/express",
231
231
  entryExample: "src/server.ts",
232
232
  entryHelp: "The file that creates your Express app",
233
233
  locate(cwd, entryFlag) {
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  routesFromExpress: () => routesFromExpress
32
32
  });
33
33
  module.exports = __toCommonJS(index_exports);
34
+ var import_node_fs = require("fs");
34
35
  var import_node_path3 = require("path");
35
36
  var import_core6 = require("@exposify/core");
36
37
  var import_node2 = require("@exposify/core/node");
@@ -503,6 +504,26 @@ function parsesJson(app) {
503
504
  if (names.includes("urlencodedParser")) return false;
504
505
  return null;
505
506
  }
507
+ var LOGIN_RULE = { not: import_core5.NOT_A_LOGIN, name: import_core5.LOGIN_NAME, mustBeNamed: false, fields: (n) => (0, import_core5.loginFields)(n), session: true };
508
+ var VERIFY_RULE = { not: import_core5.NOT_A_VERIFY, name: import_core5.VERIFY_NAME, mustBeNamed: true, fields: (n, named) => (0, import_core5.verifyFields)(n, named), session: true };
509
+ var START_RULE = { not: import_core5.NOT_A_LOGIN, name: new RegExp(`${import_core5.LOGIN_NAME.source}|${import_core5.START_NAME.source}`, "i"), mustBeNamed: true, fields: (n) => (0, import_core5.startFields)(n), session: false };
510
+ function candidates(app, routes, rule) {
511
+ const out = [];
512
+ for (const route of routes) {
513
+ if (route.method !== "POST" || route.params.length > 0 || rule.not.test(route.path)) continue;
514
+ const named = rule.name.test(route.path);
515
+ if (rule.mustBeNamed && !named) continue;
516
+ const handlers = routeHandlers.get(route) ?? [];
517
+ const own = sourceOf(handlers.at(-1));
518
+ const all = handlers.map(sourceOf).join("\n");
519
+ const fields = rule.fields(bodyFieldsOf(own), named);
520
+ if (!fields) continue;
521
+ if (rule.session && !import_core5.LOGIN_NAME.test(route.path) && !SESSION_CODE.test(all)) continue;
522
+ out.push({ step: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
523
+ }
524
+ out.sort((a, b) => Number(b.named) - Number(a.named) || a.step.fields.length - b.step.fields.length);
525
+ return out.map((c) => c.step);
526
+ }
506
527
  function findExpressLogin(app, mounts) {
507
528
  let routes;
508
529
  try {
@@ -510,21 +531,21 @@ function findExpressLogin(app, mounts) {
510
531
  } catch {
511
532
  return null;
512
533
  }
513
- const candidates = [];
514
- for (const route of routes) {
515
- if (route.method !== "POST" || route.params.length > 0 || import_core5.NOT_A_LOGIN.test(route.path)) continue;
516
- const handlers = routeHandlers.get(route) ?? [];
517
- const own = sourceOf(handlers.at(-1));
518
- const all = handlers.map(sourceOf).join("\n");
519
- const fields = (0, import_core5.loginFields)(bodyFieldsOf(own));
520
- if (!fields) continue;
521
- const named = import_core5.LOGIN_NAME.test(route.path);
522
- if (!named && !SESSION_CODE.test(all)) continue;
523
- candidates.push({ login: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
534
+ const verifies = candidates(app, routes, VERIFY_RULE);
535
+ const logins = candidates(app, routes, LOGIN_RULE);
536
+ if (logins.length > 0) {
537
+ const [first2, ...others2] = logins;
538
+ return { login: { ...first2, ...verifies[0] ? { verify: verifies[0] } : {} }, others: others2.map((c) => `POST ${c.path}`) };
539
+ }
540
+ const pairs = [];
541
+ for (const start of candidates(app, routes, START_RULE)) {
542
+ const who = new Set(start.fields.filter((f) => f.kind === "identity").map((f) => f.name));
543
+ const verify = verifies.find((v) => v.fields.some((f) => who.has(f.name))) ?? verifies.find((v) => !v.fields.some((f) => f.kind === "identity"));
544
+ if (verify) pairs.push({ ...start, passwordless: true, verify });
524
545
  }
525
- if (candidates.length === 0) return null;
526
- candidates.sort((a, b) => Number(b.named) - Number(a.named) || a.login.fields.length - b.login.fields.length);
527
- return { login: candidates[0].login, others: candidates.slice(1).map((c) => `POST ${c.login.path}`) };
546
+ if (pairs.length === 0) return null;
547
+ const [first, ...others] = pairs;
548
+ return { login: first, others: others.map((c) => `POST ${c.path}`) };
528
549
  }
529
550
 
530
551
  // src/index.ts
@@ -557,7 +578,7 @@ function aiReady(options = {}) {
557
578
  const original = String(req.originalUrl ?? req.url).split("?")[0];
558
579
  const prefix = original.endsWith(path) ? original.slice(0, original.length - path.length) : "";
559
580
  const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${prefix}${mcpPath}`;
560
- return brokerGate.resourceFor((0, import_core6.publicRequestUrl)(local, headersOf(req)));
581
+ return brokerGate.resourceFor((0, import_core6.publicRequestUrl)(local, headersOf(req), { trustForwardedHost: trustsProxy(req) }));
561
582
  };
562
583
  const cwd = options.cwd ?? process.cwd();
563
584
  const mounts = options.mounts;
@@ -567,29 +588,67 @@ function aiReady(options = {}) {
567
588
  if (!brokerInfo || options.login === false) return null;
568
589
  if (!loginFound) {
569
590
  const given = options.login;
570
- const value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: (0, import_core6.fieldKind)(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
571
- loginFound = { value };
572
- if (value) {
573
- cfg.signedOutPaths = [value.path];
574
- if (logReady) console.log(`${LOG_PREFIX} AI assistants sign in with your app's own login (POST ${value.path}) at ${MCP_PATH}/login`);
591
+ let value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: (0, import_core6.fieldKind)(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
592
+ const verify = options.loginVerify;
593
+ if (value && verify !== void 0) {
594
+ const { verify: _found, ...first } = value;
595
+ const kind = (name) => (0, import_core6.verifyFields)([name], true)?.[0]?.kind ?? (0, import_core6.fieldKind)(name);
596
+ value = verify === false ? first : { ...first, verify: { path: verify.path, fields: verify.fields.map((name) => ({ name, kind: kind(name) })), body: verify.body ?? "json" } };
575
597
  }
598
+ loginFound = { value };
599
+ if (value) cfg.signedOutPaths = [value.path];
576
600
  }
577
601
  return loginFound.value;
578
602
  };
579
- const appLoginFor = (req, login) => ({
580
- label: `POST ${login.path}`,
581
- fields: login.fields,
582
- signedOutPaths: [login.path],
583
- attempt: (values) => {
584
- const body = login.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
585
- const request = new Request(`http://app.internal${login.path}`, {
603
+ let stacks;
604
+ const stacksOf = () => {
605
+ if (!stacks) {
606
+ try {
607
+ stacks = (0, import_core6.authStacksOf)((0, import_core6.dependencyNames)(JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path3.join)(cwd, "package.json"), "utf8")))).map((s) => s.pkg);
608
+ } catch {
609
+ stacks = [];
610
+ }
611
+ }
612
+ return stacks;
613
+ };
614
+ const report = (req, resource) => {
615
+ const login = loginOf(req.app);
616
+ const where = login ? `POST ${login.path}${login.verify ? `, then a code: POST ${login.verify.path}` : ""}` : void 0;
617
+ return (0, import_core6.reportAppLogin)(cfg.auth, resource, login, { ...where ? { where } : {}, stacks: stacksOf(), log: logReady });
618
+ };
619
+ const appLoginFor = (req, login) => {
620
+ const send = (step, values, reading, cookies = []) => {
621
+ const body = step.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
622
+ const request = new Request(`http://app.internal${step.path}`, {
586
623
  method: "POST",
587
- headers: { "content-type": login.body === "form" ? "application/x-www-form-urlencoded" : "application/json", ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {} },
624
+ headers: {
625
+ "content-type": step.body === "form" ? "application/x-www-form-urlencoded" : "application/json",
626
+ ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {},
627
+ ...cookies.length ? { cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; ") } : {}
628
+ },
588
629
  body
589
630
  });
590
- return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then(import_core6.loginResultFromResponse);
591
- }
592
- });
631
+ return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then((res) => (0, import_core6.loginResultFromResponse)(res, reading));
632
+ };
633
+ const verify = login.verify;
634
+ return {
635
+ label: `POST ${login.path}`,
636
+ fields: login.fields,
637
+ signedOutPaths: [login.path],
638
+ ...login.passwordless ? { passwordless: true } : {},
639
+ attempt: (values) => send(login, values, {
640
+ ...verify ? { verifyPaths: [verify.path], verifyFields: verify.fields.map((f) => f.name) } : {},
641
+ ...login.passwordless ? { start: true } : {}
642
+ }),
643
+ ...verify ? {
644
+ verify: {
645
+ label: `POST ${verify.path}`,
646
+ fields: verify.fields,
647
+ attempt: (values, ctx) => send(verify, values, { carried: ctx.carry.cookies, verifyPaths: [verify.path] }, ctx.carry.cookies)
648
+ }
649
+ } : {}
650
+ };
651
+ };
593
652
  const descriptions = options.descriptions;
594
653
  const openapi = options.openapi;
595
654
  try {
@@ -633,7 +692,9 @@ function aiReady(options = {}) {
633
692
  }
634
693
  if (brokerGate && (path === PRM_ROOT || path === `${PRM_ROOT}${MCP_PATH}`)) {
635
694
  const resource = resourceOf(req, path, MCP_PATH);
636
- void (0, import_core6.reportAppLogin)(cfg.auth, resource, loginOf(req.app)).then(() => sendWebResponse(res, brokerGate.metadataResponse(req.method, resource)));
695
+ void report(req, resource).then(
696
+ (verdict) => verdict === "open" ? void res.writeHead(404).end("not found") : sendWebResponse(res, brokerGate.metadataResponse(req.method, resource))
697
+ );
637
698
  return;
638
699
  }
639
700
  if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
@@ -649,11 +710,13 @@ function aiReady(options = {}) {
649
710
  console.warn("[agentready] oauth gate failed:", err);
650
711
  if (!res.headersSent) res.writeHead(503).end("authorization check failed");
651
712
  return null;
652
- }) : brokerGate ? (0, import_core6.reportAppLogin)(cfg.auth, resourceOf(req, path, MCP_PATH), loginOf(req.app)).then(() => brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH))).then(async (result) => {
713
+ }) : brokerGate ? report(req, resourceOf(req, path, MCP_PATH)).then(async (verdict) => {
714
+ if (verdict === "open") return {};
715
+ const result = await brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH));
653
716
  if (result.ok) return {};
654
717
  await sendWebResponse(res, result.response);
655
718
  return null;
656
- }, (err) => {
719
+ }).catch((err) => {
657
720
  console.warn("[agentready] broker check failed:", err);
658
721
  if (!res.headersSent) res.writeHead(503).end("authorization check failed");
659
722
  return null;
@@ -695,7 +758,7 @@ function aiReady(options = {}) {
695
758
  async function serveLoginPage(req, res, login) {
696
759
  const original = String(req.originalUrl ?? req.url);
697
760
  const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${original}`;
698
- const url = new URL((0, import_core6.publicRequestUrl)(local, headersOf(req)));
761
+ const url = new URL((0, import_core6.publicRequestUrl)(local, headersOf(req), { trustForwardedHost: trustsProxy(req) }));
699
762
  url.search = new URL(local).search;
700
763
  let body;
701
764
  if (req.method === "POST") {
@@ -706,7 +769,7 @@ function aiReady(options = {}) {
706
769
  let size = 0;
707
770
  for await (const chunk of req) {
708
771
  size += chunk.length;
709
- if (size > 16 * 1024) break;
772
+ if (size > 32 * 1024) break;
710
773
  chunks.push(chunk);
711
774
  }
712
775
  body = Buffer.concat(chunks).toString("utf8");
@@ -793,6 +856,9 @@ async function initialize(app, cfg, cwd, mounts, extras, onReady) {
793
856
  }
794
857
  return (0, import_node2.createNodeMcpHandler)({ getSurface, getKb, cfg });
795
858
  }
859
+ function trustsProxy(req) {
860
+ return Boolean(req.app?.get?.("trust proxy"));
861
+ }
796
862
  // Annotate the CommonJS export names for ESM import in node:
797
863
  0 && (module.exports = {
798
864
  AIREADY_OPTIONS_TAG,
package/dist/index.d.cts CHANGED
@@ -31,6 +31,18 @@ interface ExpressAiReadyOptions extends AgentReadyOptions {
31
31
  fields: string[];
32
32
  body?: "json" | "form";
33
33
  };
34
+ /**
35
+ * The login's second step, when it asks for a code after the first (a
36
+ * password, then a code; or an email, then a code). Found by itself (a
37
+ * POST route named like `verify`, `2fa`, `otp` that reads one code and
38
+ * sets the session); name another route (`{ path, fields }`), or `false`
39
+ * for none.
40
+ */
41
+ loginVerify?: false | {
42
+ path: string;
43
+ fields: string[];
44
+ body?: "json" | "form";
45
+ };
34
46
  /** Where docs globs and the .exposify cache resolve. Default: process.cwd() */
35
47
  cwd?: string;
36
48
  /**
@@ -45,7 +57,7 @@ interface ExpressAiReadyOptions extends AgentReadyOptions {
45
57
  */
46
58
  mounts?: Record<string, unknown>;
47
59
  /**
48
- * Tool descriptions file written by `exposify-express setup`/`describe`.
60
+ * Tool descriptions file written by `@exposify/express setup`/`describe`.
49
61
  * Default: exposify.descriptions.json in `cwd`, if present. `false` ignores it.
50
62
  * describe() text in code still wins over it.
51
63
  */
package/dist/index.d.ts CHANGED
@@ -31,6 +31,18 @@ interface ExpressAiReadyOptions extends AgentReadyOptions {
31
31
  fields: string[];
32
32
  body?: "json" | "form";
33
33
  };
34
+ /**
35
+ * The login's second step, when it asks for a code after the first (a
36
+ * password, then a code; or an email, then a code). Found by itself (a
37
+ * POST route named like `verify`, `2fa`, `otp` that reads one code and
38
+ * sets the session); name another route (`{ path, fields }`), or `false`
39
+ * for none.
40
+ */
41
+ loginVerify?: false | {
42
+ path: string;
43
+ fields: string[];
44
+ body?: "json" | "form";
45
+ };
34
46
  /** Where docs globs and the .exposify cache resolve. Default: process.cwd() */
35
47
  cwd?: string;
36
48
  /**
@@ -45,7 +57,7 @@ interface ExpressAiReadyOptions extends AgentReadyOptions {
45
57
  */
46
58
  mounts?: Record<string, unknown>;
47
59
  /**
48
- * Tool descriptions file written by `exposify-express setup`/`describe`.
60
+ * Tool descriptions file written by `@exposify/express setup`/`describe`.
49
61
  * Default: exposify.descriptions.json in `cwd`, if present. `false` ignores it.
50
62
  * describe() text in code still wins over it.
51
63
  */
package/dist/index.js CHANGED
@@ -6,11 +6,14 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/index.ts
9
+ import { readFileSync } from "fs";
9
10
  import { join as join3 } from "path";
10
11
  import {
12
+ authStacksOf,
11
13
  countTools,
12
14
  createBrokerGate,
13
15
  createOAuthGate,
16
+ dependencyNames,
14
17
  describeAuth,
15
18
  formatFailed,
16
19
  formatReady,
@@ -21,6 +24,7 @@ import {
21
24
  loginResultFromResponse,
22
25
  publicRequestUrl,
23
26
  reportAppLogin,
27
+ verifyFields as verifyFields2,
24
28
  resolveOptions,
25
29
  routeToolsPhrase,
26
30
  startupLogEnabled
@@ -472,7 +476,7 @@ async function readPackageInfo(cwd) {
472
476
  }
473
477
 
474
478
  // src/login.ts
475
- import { LOGIN_NAME, NOT_A_LOGIN, loginFields } from "@exposify/core";
479
+ import { LOGIN_NAME, NOT_A_LOGIN, NOT_A_VERIFY, START_NAME, VERIFY_NAME, loginFields, startFields, verifyFields } from "@exposify/core";
476
480
  var SESSION_CODE = /\bres\.cookie\(|\.session\b|\.login\(|\blogIn\(|set-cookie|setHeader\(\s*["']set-cookie|\bjwt\.sign\(|new\s+SignJWT|\bsign\(|createSession|issueToken|generateToken/i;
477
481
  function bodyFieldsOf(source) {
478
482
  const names = /* @__PURE__ */ new Set();
@@ -501,6 +505,26 @@ function parsesJson(app) {
501
505
  if (names.includes("urlencodedParser")) return false;
502
506
  return null;
503
507
  }
508
+ var LOGIN_RULE = { not: NOT_A_LOGIN, name: LOGIN_NAME, mustBeNamed: false, fields: (n) => loginFields(n), session: true };
509
+ var VERIFY_RULE = { not: NOT_A_VERIFY, name: VERIFY_NAME, mustBeNamed: true, fields: (n, named) => verifyFields(n, named), session: true };
510
+ var START_RULE = { not: NOT_A_LOGIN, name: new RegExp(`${LOGIN_NAME.source}|${START_NAME.source}`, "i"), mustBeNamed: true, fields: (n) => startFields(n), session: false };
511
+ function candidates(app, routes, rule) {
512
+ const out = [];
513
+ for (const route of routes) {
514
+ if (route.method !== "POST" || route.params.length > 0 || rule.not.test(route.path)) continue;
515
+ const named = rule.name.test(route.path);
516
+ if (rule.mustBeNamed && !named) continue;
517
+ const handlers = routeHandlers.get(route) ?? [];
518
+ const own = sourceOf(handlers.at(-1));
519
+ const all = handlers.map(sourceOf).join("\n");
520
+ const fields = rule.fields(bodyFieldsOf(own), named);
521
+ if (!fields) continue;
522
+ if (rule.session && !LOGIN_NAME.test(route.path) && !SESSION_CODE.test(all)) continue;
523
+ out.push({ step: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
524
+ }
525
+ out.sort((a, b) => Number(b.named) - Number(a.named) || a.step.fields.length - b.step.fields.length);
526
+ return out.map((c) => c.step);
527
+ }
504
528
  function findExpressLogin(app, mounts) {
505
529
  let routes;
506
530
  try {
@@ -508,21 +532,21 @@ function findExpressLogin(app, mounts) {
508
532
  } catch {
509
533
  return null;
510
534
  }
511
- const candidates = [];
512
- for (const route of routes) {
513
- if (route.method !== "POST" || route.params.length > 0 || NOT_A_LOGIN.test(route.path)) continue;
514
- const handlers = routeHandlers.get(route) ?? [];
515
- const own = sourceOf(handlers.at(-1));
516
- const all = handlers.map(sourceOf).join("\n");
517
- const fields = loginFields(bodyFieldsOf(own));
518
- if (!fields) continue;
519
- const named = LOGIN_NAME.test(route.path);
520
- if (!named && !SESSION_CODE.test(all)) continue;
521
- candidates.push({ login: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
535
+ const verifies = candidates(app, routes, VERIFY_RULE);
536
+ const logins = candidates(app, routes, LOGIN_RULE);
537
+ if (logins.length > 0) {
538
+ const [first2, ...others2] = logins;
539
+ return { login: { ...first2, ...verifies[0] ? { verify: verifies[0] } : {} }, others: others2.map((c) => `POST ${c.path}`) };
540
+ }
541
+ const pairs = [];
542
+ for (const start of candidates(app, routes, START_RULE)) {
543
+ const who = new Set(start.fields.filter((f) => f.kind === "identity").map((f) => f.name));
544
+ const verify = verifies.find((v) => v.fields.some((f) => who.has(f.name))) ?? verifies.find((v) => !v.fields.some((f) => f.kind === "identity"));
545
+ if (verify) pairs.push({ ...start, passwordless: true, verify });
522
546
  }
523
- if (candidates.length === 0) return null;
524
- candidates.sort((a, b) => Number(b.named) - Number(a.named) || a.login.fields.length - b.login.fields.length);
525
- return { login: candidates[0].login, others: candidates.slice(1).map((c) => `POST ${c.login.path}`) };
547
+ if (pairs.length === 0) return null;
548
+ const [first, ...others] = pairs;
549
+ return { login: first, others: others.map((c) => `POST ${c.path}`) };
526
550
  }
527
551
 
528
552
  // src/index.ts
@@ -562,7 +586,7 @@ function aiReady(options = {}) {
562
586
  const original = String(req.originalUrl ?? req.url).split("?")[0];
563
587
  const prefix = original.endsWith(path) ? original.slice(0, original.length - path.length) : "";
564
588
  const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${prefix}${mcpPath}`;
565
- return brokerGate.resourceFor(publicRequestUrl(local, headersOf(req)));
589
+ return brokerGate.resourceFor(publicRequestUrl(local, headersOf(req), { trustForwardedHost: trustsProxy(req) }));
566
590
  };
567
591
  const cwd = options.cwd ?? process.cwd();
568
592
  const mounts = options.mounts;
@@ -572,29 +596,67 @@ function aiReady(options = {}) {
572
596
  if (!brokerInfo || options.login === false) return null;
573
597
  if (!loginFound) {
574
598
  const given = options.login;
575
- const value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: fieldKind(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
576
- loginFound = { value };
577
- if (value) {
578
- cfg.signedOutPaths = [value.path];
579
- if (logReady) console.log(`${LOG_PREFIX} AI assistants sign in with your app's own login (POST ${value.path}) at ${MCP_PATH}/login`);
599
+ let value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: fieldKind(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
600
+ const verify = options.loginVerify;
601
+ if (value && verify !== void 0) {
602
+ const { verify: _found, ...first } = value;
603
+ const kind = (name) => verifyFields2([name], true)?.[0]?.kind ?? fieldKind(name);
604
+ value = verify === false ? first : { ...first, verify: { path: verify.path, fields: verify.fields.map((name) => ({ name, kind: kind(name) })), body: verify.body ?? "json" } };
580
605
  }
606
+ loginFound = { value };
607
+ if (value) cfg.signedOutPaths = [value.path];
581
608
  }
582
609
  return loginFound.value;
583
610
  };
584
- const appLoginFor = (req, login) => ({
585
- label: `POST ${login.path}`,
586
- fields: login.fields,
587
- signedOutPaths: [login.path],
588
- attempt: (values) => {
589
- const body = login.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
590
- const request = new Request(`http://app.internal${login.path}`, {
611
+ let stacks;
612
+ const stacksOf = () => {
613
+ if (!stacks) {
614
+ try {
615
+ stacks = authStacksOf(dependencyNames(JSON.parse(readFileSync(join3(cwd, "package.json"), "utf8")))).map((s) => s.pkg);
616
+ } catch {
617
+ stacks = [];
618
+ }
619
+ }
620
+ return stacks;
621
+ };
622
+ const report = (req, resource) => {
623
+ const login = loginOf(req.app);
624
+ const where = login ? `POST ${login.path}${login.verify ? `, then a code: POST ${login.verify.path}` : ""}` : void 0;
625
+ return reportAppLogin(cfg.auth, resource, login, { ...where ? { where } : {}, stacks: stacksOf(), log: logReady });
626
+ };
627
+ const appLoginFor = (req, login) => {
628
+ const send = (step, values, reading, cookies = []) => {
629
+ const body = step.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
630
+ const request = new Request(`http://app.internal${step.path}`, {
591
631
  method: "POST",
592
- headers: { "content-type": login.body === "form" ? "application/x-www-form-urlencoded" : "application/json", ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {} },
632
+ headers: {
633
+ "content-type": step.body === "form" ? "application/x-www-form-urlencoded" : "application/json",
634
+ ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {},
635
+ ...cookies.length ? { cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; ") } : {}
636
+ },
593
637
  body
594
638
  });
595
- return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then(loginResultFromResponse);
596
- }
597
- });
639
+ return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then((res) => loginResultFromResponse(res, reading));
640
+ };
641
+ const verify = login.verify;
642
+ return {
643
+ label: `POST ${login.path}`,
644
+ fields: login.fields,
645
+ signedOutPaths: [login.path],
646
+ ...login.passwordless ? { passwordless: true } : {},
647
+ attempt: (values) => send(login, values, {
648
+ ...verify ? { verifyPaths: [verify.path], verifyFields: verify.fields.map((f) => f.name) } : {},
649
+ ...login.passwordless ? { start: true } : {}
650
+ }),
651
+ ...verify ? {
652
+ verify: {
653
+ label: `POST ${verify.path}`,
654
+ fields: verify.fields,
655
+ attempt: (values, ctx) => send(verify, values, { carried: ctx.carry.cookies, verifyPaths: [verify.path] }, ctx.carry.cookies)
656
+ }
657
+ } : {}
658
+ };
659
+ };
598
660
  const descriptions = options.descriptions;
599
661
  const openapi = options.openapi;
600
662
  try {
@@ -638,7 +700,9 @@ function aiReady(options = {}) {
638
700
  }
639
701
  if (brokerGate && (path === PRM_ROOT || path === `${PRM_ROOT}${MCP_PATH}`)) {
640
702
  const resource = resourceOf(req, path, MCP_PATH);
641
- void reportAppLogin(cfg.auth, resource, loginOf(req.app)).then(() => sendWebResponse(res, brokerGate.metadataResponse(req.method, resource)));
703
+ void report(req, resource).then(
704
+ (verdict) => verdict === "open" ? void res.writeHead(404).end("not found") : sendWebResponse(res, brokerGate.metadataResponse(req.method, resource))
705
+ );
642
706
  return;
643
707
  }
644
708
  if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
@@ -654,11 +718,13 @@ function aiReady(options = {}) {
654
718
  console.warn("[agentready] oauth gate failed:", err);
655
719
  if (!res.headersSent) res.writeHead(503).end("authorization check failed");
656
720
  return null;
657
- }) : brokerGate ? reportAppLogin(cfg.auth, resourceOf(req, path, MCP_PATH), loginOf(req.app)).then(() => brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH))).then(async (result) => {
721
+ }) : brokerGate ? report(req, resourceOf(req, path, MCP_PATH)).then(async (verdict) => {
722
+ if (verdict === "open") return {};
723
+ const result = await brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH));
658
724
  if (result.ok) return {};
659
725
  await sendWebResponse(res, result.response);
660
726
  return null;
661
- }, (err) => {
727
+ }).catch((err) => {
662
728
  console.warn("[agentready] broker check failed:", err);
663
729
  if (!res.headersSent) res.writeHead(503).end("authorization check failed");
664
730
  return null;
@@ -700,7 +766,7 @@ function aiReady(options = {}) {
700
766
  async function serveLoginPage(req, res, login) {
701
767
  const original = String(req.originalUrl ?? req.url);
702
768
  const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${original}`;
703
- const url = new URL(publicRequestUrl(local, headersOf(req)));
769
+ const url = new URL(publicRequestUrl(local, headersOf(req), { trustForwardedHost: trustsProxy(req) }));
704
770
  url.search = new URL(local).search;
705
771
  let body;
706
772
  if (req.method === "POST") {
@@ -711,7 +777,7 @@ function aiReady(options = {}) {
711
777
  let size = 0;
712
778
  for await (const chunk of req) {
713
779
  size += chunk.length;
714
- if (size > 16 * 1024) break;
780
+ if (size > 32 * 1024) break;
715
781
  chunks.push(chunk);
716
782
  }
717
783
  body = Buffer.concat(chunks).toString("utf8");
@@ -798,6 +864,9 @@ async function initialize(app, cfg, cwd, mounts, extras, onReady) {
798
864
  }
799
865
  return createNodeMcpHandler({ getSurface, getKb, cfg });
800
866
  }
867
+ function trustsProxy(req) {
868
+ return Boolean(req.app?.get?.("trust proxy"));
869
+ }
801
870
  export {
802
871
  AIREADY_OPTIONS_TAG,
803
872
  aiReady,
@@ -2074,12 +2074,83 @@ function requireHttpsBaseUrl(optionName, value) {
2074
2074
  return value.replace(/\/+$/, "");
2075
2075
  }
2076
2076
 
2077
+ // ../core/src/seal.ts
2078
+ init_cjs_shims();
2079
+ function toBase64Url(bytes) {
2080
+ let s = "";
2081
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
2082
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2083
+ }
2084
+ function fromBase64Url(text) {
2085
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
2086
+ try {
2087
+ const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
2088
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
2089
+ } catch {
2090
+ return null;
2091
+ }
2092
+ }
2093
+ function createSealer(secret, label) {
2094
+ let key;
2095
+ const keyOf2 = () => key ??= crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${label}
2096
+ ${secret}`)).then((material) => crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]));
2097
+ return {
2098
+ async seal(text) {
2099
+ const iv = crypto.getRandomValues(new Uint8Array(12));
2100
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await keyOf2(), new TextEncoder().encode(text)));
2101
+ const out = new Uint8Array(iv.length + sealed.length);
2102
+ out.set(iv);
2103
+ out.set(sealed, iv.length);
2104
+ return toBase64Url(out);
2105
+ },
2106
+ async unseal(token) {
2107
+ const bytes = fromBase64Url(token);
2108
+ if (!bytes || bytes.length < 12 + 16) return null;
2109
+ try {
2110
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.slice(0, 12) }, await keyOf2(), bytes.slice(12));
2111
+ return new TextDecoder().decode(plain);
2112
+ } catch {
2113
+ return null;
2114
+ }
2115
+ }
2116
+ };
2117
+ }
2118
+
2119
+ // ../core/src/login-theme.ts
2120
+ init_cjs_shims();
2121
+ var HEX = /^#[0-9a-f]{6}$/i;
2122
+ var KEYS = ["accent", "background", "surface", "text"];
2123
+ function parseLoginTheme(raw) {
2124
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
2125
+ const o = raw;
2126
+ if (Object.keys(o).length !== KEYS.length) return null;
2127
+ const out = {};
2128
+ for (const k of KEYS) {
2129
+ const v = o[k];
2130
+ if (typeof v !== "string" || !HEX.test(v)) return null;
2131
+ out[k] = v.toLowerCase();
2132
+ }
2133
+ return out;
2134
+ }
2135
+
2077
2136
  // ../core/src/hosted-resolver.ts
2078
2137
  var DEFAULT_TIMEOUT_MS = 5e3;
2079
2138
  var REPORT_TIMEOUT_MS = 3e3;
2139
+ var REPORT_REFRESH_MS = 6e4;
2080
2140
  var DEFAULT_MAX_CACHE_MS = 3e4;
2081
2141
  var EXPIRY_SKEW_MS = 6e4;
2082
2142
  var MAX_CACHE_ENTRIES = 500;
2143
+ function signInMethodsOf(body) {
2144
+ const methods = body?.signIn?.methods;
2145
+ if (!Array.isArray(methods)) return null;
2146
+ return methods.map((m) => {
2147
+ const { kind, label } = m && typeof m === "object" ? m : {};
2148
+ return {
2149
+ kind: kind === "app-login" || kind === "oauth" || kind === "session" ? kind : "other",
2150
+ label: typeof label === "string" && label.trim() ? label.trim().slice(0, 100) : "a sign-in method"
2151
+ };
2152
+ });
2153
+ }
2083
2154
  function createHostedAuthResolver(config) {
2084
2155
  const givenKey = config.key ?? config.licenseKey;
2085
2156
  const projectId = config.projectId ?? (config.key ? parseExposifyKey(config.key)?.projectId : void 0);
@@ -2173,9 +2244,13 @@ function createHostedAuthResolver(config) {
2173
2244
  }
2174
2245
  const sessionWrites = /* @__PURE__ */ new Map();
2175
2246
  const reported = /* @__PURE__ */ new Map();
2176
- const apiUrl = config.apiUrl ?? readEnv(API_URL_ENV) ?? DEFAULT_API_URL;
2247
+ let passwordDirect;
2248
+ const apiUrl = requireHttpsBaseUrl("apiUrl", config.apiUrl ?? readEnv(API_URL_ENV) ?? DEFAULT_API_URL);
2249
+ const stepSealer = createSealer(licenseKey, "exposify login-step v1");
2177
2250
  const info = {
2178
2251
  issuer,
2252
+ sealLoginStep: (text) => stepSealer.seal(text),
2253
+ unsealLoginStep: (token) => stepSealer.unseal(token),
2179
2254
  resource: config.resource,
2180
2255
  // Answers from the same cache, so the tool call right after a check doesn't ask the broker again.
2181
2256
  async check(authorization) {
@@ -2192,7 +2267,8 @@ function createHostedAuthResolver(config) {
2192
2267
  live: true,
2193
2268
  clientName: typeof res.body.clientName === "string" ? res.body.clientName : "An AI assistant",
2194
2269
  appName: typeof res.body.appName === "string" ? res.body.appName : null,
2195
- resource: typeof res.body.resource === "string" ? res.body.resource : null
2270
+ resource: typeof res.body.resource === "string" ? res.body.resource : null,
2271
+ theme: parseLoginTheme(res.body.theme)
2196
2272
  };
2197
2273
  },
2198
2274
  async completeLogin(handle, session) {
@@ -2220,23 +2296,39 @@ function createHostedAuthResolver(config) {
2220
2296
  cache.delete(authorization);
2221
2297
  await brokerPost("session-ended", {}, authorization);
2222
2298
  },
2299
+ supabasePasswordDirect: () => passwordDirect,
2223
2300
  reportLogin(report) {
2224
2301
  const key = JSON.stringify(report);
2225
- let pending = reported.get(key);
2226
- if (!pending) {
2227
- pending = (async () => {
2228
- const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/api/v1/app/login`, {
2229
- method: "PUT",
2230
- headers: { "content-type": "application/json", authorization: `Bearer ${licenseKey}` },
2231
- body: JSON.stringify(report),
2232
- signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
2233
- });
2234
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2235
- })();
2236
- reported.set(key, pending);
2302
+ const send2 = async () => {
2303
+ const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/api/v1/app/login`, {
2304
+ method: "PUT",
2305
+ headers: { "content-type": "application/json", authorization: `Bearer ${licenseKey}` },
2306
+ body: JSON.stringify(report),
2307
+ signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
2308
+ });
2309
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2310
+ const body = await res.json().catch(() => null);
2311
+ passwordDirect = typeof body?.supabasePasswordDirect === "boolean" ? body.supabasePasswordDirect : void 0;
2312
+ return signInMethodsOf(body);
2313
+ };
2314
+ const entry2 = reported.get(key);
2315
+ if (!entry2) {
2316
+ const pending = send2();
2317
+ reported.set(key, { answer: pending, at: Date.now() });
2237
2318
  pending.catch(() => reported.delete(key));
2319
+ return pending;
2320
+ }
2321
+ if (!entry2.refreshing && Date.now() - entry2.at >= REPORT_REFRESH_MS) {
2322
+ entry2.refreshing = true;
2323
+ send2().then(
2324
+ (methods) => reported.set(key, { answer: Promise.resolve(methods), at: Date.now() }),
2325
+ () => {
2326
+ entry2.at = Date.now();
2327
+ entry2.refreshing = false;
2328
+ }
2329
+ );
2238
2330
  }
2239
- return pending;
2331
+ return entry2.answer;
2240
2332
  }
2241
2333
  };
2242
2334
  Object.defineProperty(resolver, HOSTED_RESOLVER, { value: info });
@@ -6483,15 +6575,26 @@ function applyOpenApi(routes, doc) {
6483
6575
 
6484
6576
  // ../core/src/own-login.ts
6485
6577
  init_cjs_shims();
6578
+
6579
+ // ../core/src/startup.ts
6580
+ init_cjs_shims();
6581
+
6582
+ // ../core/src/own-login.ts
6486
6583
  var PASSWORD_NAME = /^(password|passwd|pass|pwd|pin|passcode|secret)$/i;
6487
6584
  var PASSWORD_PART = /(password|passwd|passcode)/i;
6488
6585
  var CONFIRM_NAME = /(confirm|repeat|again|verify|retype|new).*pass|pass.*(confirm|repeat|again|2)$/i;
6489
6586
  var IDENTITY_NAME = /^(email|e-mail|mail|username|user|login|name|phone|phonenumber|phone_number|mobile|account|identifier|handle)$/i;
6587
+ var CODE_NAME = /^(code|otp|totp|pin[-_]?code|(mfa|2fa|two[-_]?factor|verification|verify|sms|one[-_]?time|otp|totp|backup|recovery|auth|security|login|access)[-_]?code)$/i;
6490
6588
  var NOT_A_LOGIN = /(register|signup|sign[-_]?up|create[-_]?(account|user)|join|logout|log[-_]?out|signout|sign[-_]?out|reset|forgot|change[-_]?password|invite|verify)/i;
6491
6589
  var LOGIN_NAME = /(^|[^a-z])(login|log[-_]?in|signin|sign[-_]?in|authenticate|session|sessions|auth)([^a-z]|$)/i;
6590
+ var VERIFY_NAME = /(verify|verification|2fa|two[-_]?factor|mfa|otp|totp|challenge|code)/i;
6591
+ var NOT_A_VERIFY = /(register|signup|sign[-_]?up|verify[-_]?email|email[-_]?verif|confirm[-_]?email|reset|forgot|invite|coupon|promo|discount|voucher|gift|referral)/i;
6592
+ var START_NAME = /((send|request|email|text|sms)[-_]?(code|otp|token|link))|passwordless|magic|otp/i;
6593
+ var bareName = (name) => name.replace(/^.*[.[]/, "").replace(/]$/, "");
6492
6594
  function fieldKind(name) {
6493
- const bare = name.replace(/^.*[.[]/, "").replace(/]$/, "");
6595
+ const bare = bareName(name);
6494
6596
  if (PASSWORD_NAME.test(bare) || PASSWORD_PART.test(bare)) return "password";
6597
+ if (CODE_NAME.test(bare)) return "code";
6495
6598
  if (IDENTITY_NAME.test(bare) || /email/i.test(bare)) return "identity";
6496
6599
  return "other";
6497
6600
  }
@@ -6502,7 +6605,21 @@ function loginFields(names) {
6502
6605
  if (fields.length < 2) return null;
6503
6606
  return fields;
6504
6607
  }
6505
- var MAX_FORM_BYTES = 16 * 1024;
6608
+ function verifyFields(names, tokenIsCode = false) {
6609
+ const fields = names.map((name) => {
6610
+ const kind = fieldKind(name);
6611
+ return { name, kind: kind === "other" && tokenIsCode && /^token$/i.test(bareName(name)) ? "code" : kind };
6612
+ });
6613
+ if (fields.some((f) => f.kind === "password") || fields.filter((f) => f.kind === "code").length !== 1) return null;
6614
+ return fields;
6615
+ }
6616
+ function startFields(names) {
6617
+ const fields = names.map((name) => ({ name, kind: fieldKind(name) }));
6618
+ if (!fields.some((f) => f.kind === "identity") || fields.some((f) => f.kind === "password" || f.kind === "code")) return null;
6619
+ return fields;
6620
+ }
6621
+ var STEP_TTL_MS = 10 * 6e4;
6622
+ var MAX_FORM_BYTES = 32 * 1024;
6506
6623
 
6507
6624
  // src/introspect.ts
6508
6625
  init_cjs_shims();
@@ -6788,6 +6905,26 @@ function parsesJson(app) {
6788
6905
  if (names.includes("urlencodedParser")) return false;
6789
6906
  return null;
6790
6907
  }
6908
+ var LOGIN_RULE = { not: NOT_A_LOGIN, name: LOGIN_NAME, mustBeNamed: false, fields: (n) => loginFields(n), session: true };
6909
+ var VERIFY_RULE = { not: NOT_A_VERIFY, name: VERIFY_NAME, mustBeNamed: true, fields: (n, named) => verifyFields(n, named), session: true };
6910
+ var START_RULE = { not: NOT_A_LOGIN, name: new RegExp(`${LOGIN_NAME.source}|${START_NAME.source}`, "i"), mustBeNamed: true, fields: (n) => startFields(n), session: false };
6911
+ function candidates(app, routes, rule) {
6912
+ const out = [];
6913
+ for (const route of routes) {
6914
+ if (route.method !== "POST" || route.params.length > 0 || rule.not.test(route.path)) continue;
6915
+ const named = rule.name.test(route.path);
6916
+ if (rule.mustBeNamed && !named) continue;
6917
+ const handlers = routeHandlers.get(route) ?? [];
6918
+ const own = sourceOf(handlers.at(-1));
6919
+ const all = handlers.map(sourceOf).join("\n");
6920
+ const fields = rule.fields(bodyFieldsOf(own), named);
6921
+ if (!fields) continue;
6922
+ if (rule.session && !LOGIN_NAME.test(route.path) && !SESSION_CODE.test(all)) continue;
6923
+ out.push({ step: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
6924
+ }
6925
+ out.sort((a, b) => Number(b.named) - Number(a.named) || a.step.fields.length - b.step.fields.length);
6926
+ return out.map((c) => c.step);
6927
+ }
6791
6928
  function findExpressLogin(app, mounts) {
6792
6929
  let routes;
6793
6930
  try {
@@ -6795,21 +6932,21 @@ function findExpressLogin(app, mounts) {
6795
6932
  } catch {
6796
6933
  return null;
6797
6934
  }
6798
- const candidates = [];
6799
- for (const route of routes) {
6800
- if (route.method !== "POST" || route.params.length > 0 || NOT_A_LOGIN.test(route.path)) continue;
6801
- const handlers = routeHandlers.get(route) ?? [];
6802
- const own = sourceOf(handlers.at(-1));
6803
- const all = handlers.map(sourceOf).join("\n");
6804
- const fields = loginFields(bodyFieldsOf(own));
6805
- if (!fields) continue;
6806
- const named = LOGIN_NAME.test(route.path);
6807
- if (!named && !SESSION_CODE.test(all)) continue;
6808
- candidates.push({ login: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
6809
- }
6810
- if (candidates.length === 0) return null;
6811
- candidates.sort((a, b) => Number(b.named) - Number(a.named) || a.login.fields.length - b.login.fields.length);
6812
- return { login: candidates[0].login, others: candidates.slice(1).map((c) => `POST ${c.login.path}`) };
6935
+ const verifies = candidates(app, routes, VERIFY_RULE);
6936
+ const logins = candidates(app, routes, LOGIN_RULE);
6937
+ if (logins.length > 0) {
6938
+ const [first2, ...others2] = logins;
6939
+ return { login: { ...first2, ...verifies[0] ? { verify: verifies[0] } : {} }, others: others2.map((c) => `POST ${c.path}`) };
6940
+ }
6941
+ const pairs = [];
6942
+ for (const start of candidates(app, routes, START_RULE)) {
6943
+ const who = new Set(start.fields.filter((f) => f.kind === "identity").map((f) => f.name));
6944
+ const verify = verifies.find((v) => v.fields.some((f) => who.has(f.name))) ?? verifies.find((v) => !v.fields.some((f) => f.kind === "identity"));
6945
+ if (verify) pairs.push({ ...start, passwordless: true, verify });
6946
+ }
6947
+ if (pairs.length === 0) return null;
6948
+ const [first, ...others] = pairs;
6949
+ return { login: first, others: others.map((c) => `POST ${c.path}`) };
6813
6950
  }
6814
6951
 
6815
6952
  // bin/scan/env.ts
@@ -2078,12 +2078,83 @@ function requireHttpsBaseUrl(optionName, value) {
2078
2078
  return value.replace(/\/+$/, "");
2079
2079
  }
2080
2080
 
2081
+ // ../core/src/seal.ts
2082
+ init_esm_shims();
2083
+ function toBase64Url(bytes) {
2084
+ let s = "";
2085
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
2086
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2087
+ }
2088
+ function fromBase64Url(text) {
2089
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
2090
+ try {
2091
+ const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
2092
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
2093
+ } catch {
2094
+ return null;
2095
+ }
2096
+ }
2097
+ function createSealer(secret, label) {
2098
+ let key;
2099
+ const keyOf2 = () => key ??= crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${label}
2100
+ ${secret}`)).then((material) => crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]));
2101
+ return {
2102
+ async seal(text) {
2103
+ const iv = crypto.getRandomValues(new Uint8Array(12));
2104
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await keyOf2(), new TextEncoder().encode(text)));
2105
+ const out = new Uint8Array(iv.length + sealed.length);
2106
+ out.set(iv);
2107
+ out.set(sealed, iv.length);
2108
+ return toBase64Url(out);
2109
+ },
2110
+ async unseal(token) {
2111
+ const bytes = fromBase64Url(token);
2112
+ if (!bytes || bytes.length < 12 + 16) return null;
2113
+ try {
2114
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.slice(0, 12) }, await keyOf2(), bytes.slice(12));
2115
+ return new TextDecoder().decode(plain);
2116
+ } catch {
2117
+ return null;
2118
+ }
2119
+ }
2120
+ };
2121
+ }
2122
+
2123
+ // ../core/src/login-theme.ts
2124
+ init_esm_shims();
2125
+ var HEX = /^#[0-9a-f]{6}$/i;
2126
+ var KEYS = ["accent", "background", "surface", "text"];
2127
+ function parseLoginTheme(raw) {
2128
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
2129
+ const o = raw;
2130
+ if (Object.keys(o).length !== KEYS.length) return null;
2131
+ const out = {};
2132
+ for (const k of KEYS) {
2133
+ const v = o[k];
2134
+ if (typeof v !== "string" || !HEX.test(v)) return null;
2135
+ out[k] = v.toLowerCase();
2136
+ }
2137
+ return out;
2138
+ }
2139
+
2081
2140
  // ../core/src/hosted-resolver.ts
2082
2141
  var DEFAULT_TIMEOUT_MS = 5e3;
2083
2142
  var REPORT_TIMEOUT_MS = 3e3;
2143
+ var REPORT_REFRESH_MS = 6e4;
2084
2144
  var DEFAULT_MAX_CACHE_MS = 3e4;
2085
2145
  var EXPIRY_SKEW_MS = 6e4;
2086
2146
  var MAX_CACHE_ENTRIES = 500;
2147
+ function signInMethodsOf(body) {
2148
+ const methods = body?.signIn?.methods;
2149
+ if (!Array.isArray(methods)) return null;
2150
+ return methods.map((m) => {
2151
+ const { kind, label } = m && typeof m === "object" ? m : {};
2152
+ return {
2153
+ kind: kind === "app-login" || kind === "oauth" || kind === "session" ? kind : "other",
2154
+ label: typeof label === "string" && label.trim() ? label.trim().slice(0, 100) : "a sign-in method"
2155
+ };
2156
+ });
2157
+ }
2087
2158
  function createHostedAuthResolver(config) {
2088
2159
  const givenKey = config.key ?? config.licenseKey;
2089
2160
  const projectId = config.projectId ?? (config.key ? parseExposifyKey(config.key)?.projectId : void 0);
@@ -2177,9 +2248,13 @@ function createHostedAuthResolver(config) {
2177
2248
  }
2178
2249
  const sessionWrites = /* @__PURE__ */ new Map();
2179
2250
  const reported = /* @__PURE__ */ new Map();
2180
- const apiUrl = config.apiUrl ?? readEnv(API_URL_ENV) ?? DEFAULT_API_URL;
2251
+ let passwordDirect;
2252
+ const apiUrl = requireHttpsBaseUrl("apiUrl", config.apiUrl ?? readEnv(API_URL_ENV) ?? DEFAULT_API_URL);
2253
+ const stepSealer = createSealer(licenseKey, "exposify login-step v1");
2181
2254
  const info = {
2182
2255
  issuer,
2256
+ sealLoginStep: (text) => stepSealer.seal(text),
2257
+ unsealLoginStep: (token) => stepSealer.unseal(token),
2183
2258
  resource: config.resource,
2184
2259
  // Answers from the same cache, so the tool call right after a check doesn't ask the broker again.
2185
2260
  async check(authorization) {
@@ -2196,7 +2271,8 @@ function createHostedAuthResolver(config) {
2196
2271
  live: true,
2197
2272
  clientName: typeof res.body.clientName === "string" ? res.body.clientName : "An AI assistant",
2198
2273
  appName: typeof res.body.appName === "string" ? res.body.appName : null,
2199
- resource: typeof res.body.resource === "string" ? res.body.resource : null
2274
+ resource: typeof res.body.resource === "string" ? res.body.resource : null,
2275
+ theme: parseLoginTheme(res.body.theme)
2200
2276
  };
2201
2277
  },
2202
2278
  async completeLogin(handle, session) {
@@ -2224,23 +2300,39 @@ function createHostedAuthResolver(config) {
2224
2300
  cache.delete(authorization);
2225
2301
  await brokerPost("session-ended", {}, authorization);
2226
2302
  },
2303
+ supabasePasswordDirect: () => passwordDirect,
2227
2304
  reportLogin(report) {
2228
2305
  const key = JSON.stringify(report);
2229
- let pending = reported.get(key);
2230
- if (!pending) {
2231
- pending = (async () => {
2232
- const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/api/v1/app/login`, {
2233
- method: "PUT",
2234
- headers: { "content-type": "application/json", authorization: `Bearer ${licenseKey}` },
2235
- body: JSON.stringify(report),
2236
- signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
2237
- });
2238
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2239
- })();
2240
- reported.set(key, pending);
2306
+ const send2 = async () => {
2307
+ const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/api/v1/app/login`, {
2308
+ method: "PUT",
2309
+ headers: { "content-type": "application/json", authorization: `Bearer ${licenseKey}` },
2310
+ body: JSON.stringify(report),
2311
+ signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
2312
+ });
2313
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2314
+ const body = await res.json().catch(() => null);
2315
+ passwordDirect = typeof body?.supabasePasswordDirect === "boolean" ? body.supabasePasswordDirect : void 0;
2316
+ return signInMethodsOf(body);
2317
+ };
2318
+ const entry2 = reported.get(key);
2319
+ if (!entry2) {
2320
+ const pending = send2();
2321
+ reported.set(key, { answer: pending, at: Date.now() });
2241
2322
  pending.catch(() => reported.delete(key));
2323
+ return pending;
2324
+ }
2325
+ if (!entry2.refreshing && Date.now() - entry2.at >= REPORT_REFRESH_MS) {
2326
+ entry2.refreshing = true;
2327
+ send2().then(
2328
+ (methods) => reported.set(key, { answer: Promise.resolve(methods), at: Date.now() }),
2329
+ () => {
2330
+ entry2.at = Date.now();
2331
+ entry2.refreshing = false;
2332
+ }
2333
+ );
2242
2334
  }
2243
- return pending;
2335
+ return entry2.answer;
2244
2336
  }
2245
2337
  };
2246
2338
  Object.defineProperty(resolver, HOSTED_RESOLVER, { value: info });
@@ -6487,15 +6579,26 @@ function applyOpenApi(routes, doc) {
6487
6579
 
6488
6580
  // ../core/src/own-login.ts
6489
6581
  init_esm_shims();
6582
+
6583
+ // ../core/src/startup.ts
6584
+ init_esm_shims();
6585
+
6586
+ // ../core/src/own-login.ts
6490
6587
  var PASSWORD_NAME = /^(password|passwd|pass|pwd|pin|passcode|secret)$/i;
6491
6588
  var PASSWORD_PART = /(password|passwd|passcode)/i;
6492
6589
  var CONFIRM_NAME = /(confirm|repeat|again|verify|retype|new).*pass|pass.*(confirm|repeat|again|2)$/i;
6493
6590
  var IDENTITY_NAME = /^(email|e-mail|mail|username|user|login|name|phone|phonenumber|phone_number|mobile|account|identifier|handle)$/i;
6591
+ var CODE_NAME = /^(code|otp|totp|pin[-_]?code|(mfa|2fa|two[-_]?factor|verification|verify|sms|one[-_]?time|otp|totp|backup|recovery|auth|security|login|access)[-_]?code)$/i;
6494
6592
  var NOT_A_LOGIN = /(register|signup|sign[-_]?up|create[-_]?(account|user)|join|logout|log[-_]?out|signout|sign[-_]?out|reset|forgot|change[-_]?password|invite|verify)/i;
6495
6593
  var LOGIN_NAME = /(^|[^a-z])(login|log[-_]?in|signin|sign[-_]?in|authenticate|session|sessions|auth)([^a-z]|$)/i;
6594
+ var VERIFY_NAME = /(verify|verification|2fa|two[-_]?factor|mfa|otp|totp|challenge|code)/i;
6595
+ var NOT_A_VERIFY = /(register|signup|sign[-_]?up|verify[-_]?email|email[-_]?verif|confirm[-_]?email|reset|forgot|invite|coupon|promo|discount|voucher|gift|referral)/i;
6596
+ var START_NAME = /((send|request|email|text|sms)[-_]?(code|otp|token|link))|passwordless|magic|otp/i;
6597
+ var bareName = (name) => name.replace(/^.*[.[]/, "").replace(/]$/, "");
6496
6598
  function fieldKind(name) {
6497
- const bare = name.replace(/^.*[.[]/, "").replace(/]$/, "");
6599
+ const bare = bareName(name);
6498
6600
  if (PASSWORD_NAME.test(bare) || PASSWORD_PART.test(bare)) return "password";
6601
+ if (CODE_NAME.test(bare)) return "code";
6499
6602
  if (IDENTITY_NAME.test(bare) || /email/i.test(bare)) return "identity";
6500
6603
  return "other";
6501
6604
  }
@@ -6506,7 +6609,21 @@ function loginFields(names) {
6506
6609
  if (fields.length < 2) return null;
6507
6610
  return fields;
6508
6611
  }
6509
- var MAX_FORM_BYTES = 16 * 1024;
6612
+ function verifyFields(names, tokenIsCode = false) {
6613
+ const fields = names.map((name) => {
6614
+ const kind = fieldKind(name);
6615
+ return { name, kind: kind === "other" && tokenIsCode && /^token$/i.test(bareName(name)) ? "code" : kind };
6616
+ });
6617
+ if (fields.some((f) => f.kind === "password") || fields.filter((f) => f.kind === "code").length !== 1) return null;
6618
+ return fields;
6619
+ }
6620
+ function startFields(names) {
6621
+ const fields = names.map((name) => ({ name, kind: fieldKind(name) }));
6622
+ if (!fields.some((f) => f.kind === "identity") || fields.some((f) => f.kind === "password" || f.kind === "code")) return null;
6623
+ return fields;
6624
+ }
6625
+ var STEP_TTL_MS = 10 * 6e4;
6626
+ var MAX_FORM_BYTES = 32 * 1024;
6510
6627
 
6511
6628
  // src/introspect.ts
6512
6629
  init_esm_shims();
@@ -6792,6 +6909,26 @@ function parsesJson(app) {
6792
6909
  if (names.includes("urlencodedParser")) return false;
6793
6910
  return null;
6794
6911
  }
6912
+ var LOGIN_RULE = { not: NOT_A_LOGIN, name: LOGIN_NAME, mustBeNamed: false, fields: (n) => loginFields(n), session: true };
6913
+ var VERIFY_RULE = { not: NOT_A_VERIFY, name: VERIFY_NAME, mustBeNamed: true, fields: (n, named) => verifyFields(n, named), session: true };
6914
+ var START_RULE = { not: NOT_A_LOGIN, name: new RegExp(`${LOGIN_NAME.source}|${START_NAME.source}`, "i"), mustBeNamed: true, fields: (n) => startFields(n), session: false };
6915
+ function candidates(app, routes, rule) {
6916
+ const out = [];
6917
+ for (const route of routes) {
6918
+ if (route.method !== "POST" || route.params.length > 0 || rule.not.test(route.path)) continue;
6919
+ const named = rule.name.test(route.path);
6920
+ if (rule.mustBeNamed && !named) continue;
6921
+ const handlers = routeHandlers.get(route) ?? [];
6922
+ const own = sourceOf(handlers.at(-1));
6923
+ const all = handlers.map(sourceOf).join("\n");
6924
+ const fields = rule.fields(bodyFieldsOf(own), named);
6925
+ if (!fields) continue;
6926
+ if (rule.session && !LOGIN_NAME.test(route.path) && !SESSION_CODE.test(all)) continue;
6927
+ out.push({ step: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
6928
+ }
6929
+ out.sort((a, b) => Number(b.named) - Number(a.named) || a.step.fields.length - b.step.fields.length);
6930
+ return out.map((c) => c.step);
6931
+ }
6795
6932
  function findExpressLogin(app, mounts) {
6796
6933
  let routes;
6797
6934
  try {
@@ -6799,21 +6936,21 @@ function findExpressLogin(app, mounts) {
6799
6936
  } catch {
6800
6937
  return null;
6801
6938
  }
6802
- const candidates = [];
6803
- for (const route of routes) {
6804
- if (route.method !== "POST" || route.params.length > 0 || NOT_A_LOGIN.test(route.path)) continue;
6805
- const handlers = routeHandlers.get(route) ?? [];
6806
- const own = sourceOf(handlers.at(-1));
6807
- const all = handlers.map(sourceOf).join("\n");
6808
- const fields = loginFields(bodyFieldsOf(own));
6809
- if (!fields) continue;
6810
- const named = LOGIN_NAME.test(route.path);
6811
- if (!named && !SESSION_CODE.test(all)) continue;
6812
- candidates.push({ login: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
6813
- }
6814
- if (candidates.length === 0) return null;
6815
- candidates.sort((a, b) => Number(b.named) - Number(a.named) || a.login.fields.length - b.login.fields.length);
6816
- return { login: candidates[0].login, others: candidates.slice(1).map((c) => `POST ${c.login.path}`) };
6939
+ const verifies = candidates(app, routes, VERIFY_RULE);
6940
+ const logins = candidates(app, routes, LOGIN_RULE);
6941
+ if (logins.length > 0) {
6942
+ const [first2, ...others2] = logins;
6943
+ return { login: { ...first2, ...verifies[0] ? { verify: verifies[0] } : {} }, others: others2.map((c) => `POST ${c.path}`) };
6944
+ }
6945
+ const pairs = [];
6946
+ for (const start of candidates(app, routes, START_RULE)) {
6947
+ const who = new Set(start.fields.filter((f) => f.kind === "identity").map((f) => f.name));
6948
+ const verify = verifies.find((v) => v.fields.some((f) => who.has(f.name))) ?? verifies.find((v) => !v.fields.some((f) => f.kind === "identity"));
6949
+ if (verify) pairs.push({ ...start, passwordless: true, verify });
6950
+ }
6951
+ if (pairs.length === 0) return null;
6952
+ const [first, ...others] = pairs;
6953
+ return { login: first, others: others.map((c) => `POST ${c.path}`) };
6817
6954
  }
6818
6955
 
6819
6956
  // bin/scan/env.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/express",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -26,8 +26,8 @@
26
26
  "prepack": "yarn build"
27
27
  },
28
28
  "dependencies": {
29
- "@exposify/cli": "0.0.1",
30
- "@exposify/core": "0.3.13"
29
+ "@exposify/cli": "0.1.0",
30
+ "@exposify/core": "0.4.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "express": ">=4 <6"