create-ailk 0.17.0 → 0.19.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.
Files changed (193) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. package/templates/database/__tests__/build.test.ts +0 -131
@@ -231,10 +231,22 @@ describe("buildSinglePageAuditInput — sitewide identity (#6597 AC-7)", () => {
231
231
  expect(evidenceOf(report, "schema-coverage")?.pass).toBe(true);
232
232
  });
233
233
 
234
- it("the monorepo home finds the identity: Identity (organization) ✓", async () => {
234
+ // The content root's own _site.mdx names the site's identity kind: the monorepo's is
235
+ // Organization, a person-service scaffold's is Person. Read it, don't hard-code it (#8419).
236
+ const siteKind = (): "Organization" | "Person" => {
237
+ const site = readFileSync(join(CONTENT_ROOT, "_site.mdx"), "utf-8");
238
+ const kind = /^siteIdentity:\s*\n(?:[ \t]+.*\n)*?[ \t]+kind:\s*["']?(\w+)["']?\s*$/m.exec(site)?.[1];
239
+ if (kind !== "Organization" && kind !== "Person") {
240
+ throw new Error(`content/_site.mdx siteIdentity.kind is not Organization or Person: ${String(kind)}`);
241
+ }
242
+ return kind;
243
+ };
244
+
245
+ it("the content root's home finds the identity its _site.mdx names", async () => {
246
+ const kind = siteKind();
235
247
  const report = await auditOf({ slug: "home" }, CONTENT_ROOT);
236
248
  expect(evidenceOf(report, "entity-markup")?.evidence).toContain(
237
- "Identity (organization) ✓",
249
+ `Identity (${kind.toLowerCase()}) ✓`,
238
250
  );
239
251
  });
240
252
 
@@ -243,7 +255,7 @@ describe("buildSinglePageAuditInput — sitewide identity (#6597 AC-7)", () => {
243
255
  const nodes = input.jsonLdByPage["AboutPage/about"] as Array<Record<string, unknown>>;
244
256
  expect(nodes[0]!["@type"]).toBe("AboutPage");
245
257
  expect(nodes.map((n) => n["@type"])).toEqual(
246
- expect.arrayContaining(["Organization", "WebSite"]),
258
+ expect.arrayContaining([siteKind(), "WebSite"]),
247
259
  );
248
260
  });
249
261
 
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The shared boot module (#8070 D1, D2): `readStatelessEnv` and
3
+ * `httpTransportConfigFromEnv`, which both entrypoints read the environment
4
+ * through.
5
+ *
6
+ * Acceptance criteria verified here (spec docs/specs/8070-spoke-mcp-vercel-backend-spec.md):
7
+ * - AC-1: MCP_HTTP_STATELESS=true selects the stateless transport — a
8
+ * session-less tools/list answers 200 and lists list_pages.
9
+ * - AC-2: each entry's default, and the accepted spellings.
10
+ * - AC-3: a bad value throws naming the variable; a blank per-minute rate
11
+ * falls back to its default rather than disabling the limit.
12
+ *
13
+ * And from spec docs/specs/8071-mcp-write-guard-spec.md:
14
+ * - AC-5: a blank or non-numeric MCP_ABUSE_CREATE_BOOKING_DAILY_CAP /
15
+ * MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP yields the default cap through the
16
+ * env reader both entries share, never an unlimited one.
17
+ *
18
+ * Real sockets on port 0, the pattern of `http-transport.test.ts`.
19
+ */
20
+
21
+ import type { AddressInfo } from "node:net";
22
+
23
+ import type { FastifyInstance, FastifyRequest } from "fastify";
24
+
25
+ import {
26
+ AbuseGuard,
27
+ DEFAULT_CREATE_BOOKING_DAILY_CAP,
28
+ DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP,
29
+ } from "../src/abuse/index.js";
30
+ import { httpTransportConfigFromEnv, readStatelessEnv } from "../src/boot.js";
31
+ import { buildHttpTransportApp } from "../src/http-transport.js";
32
+
33
+ const VARS = [
34
+ "MCP_HTTP_STATELESS",
35
+ "MCP_HTTP_PORT",
36
+ "MCP_HTTP_PATH",
37
+ "MCP_RATE_LIMIT_READ_PER_MIN",
38
+ "MCP_RATE_LIMIT_WRITE_PER_MIN",
39
+ "MCP_CORS_WRITE_ALLOWED_ORIGINS",
40
+ "MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
41
+ "MCP_ABUSE_START_PURCHASE_DAILY_CAP",
42
+ "MCP_ABUSE_CREATE_BOOKING_DAILY_CAP",
43
+ "MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP",
44
+ "MCP_ABUSE_BLOCKED_USER_AGENTS",
45
+ ] as const;
46
+
47
+ const saved: Partial<Record<(typeof VARS)[number], string | undefined>> = {};
48
+
49
+ beforeEach(() => {
50
+ for (const name of VARS) {
51
+ saved[name] = process.env[name];
52
+ delete process.env[name];
53
+ }
54
+ });
55
+
56
+ afterEach(() => {
57
+ for (const name of VARS) {
58
+ const value = saved[name];
59
+ if (value === undefined) delete process.env[name];
60
+ else process.env[name] = value;
61
+ }
62
+ });
63
+
64
+ /** A stub carrying only the two members the guard reads. */
65
+ function requestFrom(ip: string): FastifyRequest {
66
+ // @ts-expect-error: a full FastifyRequest cannot be built without a server.
67
+ return {
68
+ headers: { "user-agent": "Mozilla/5.0 (compatible; TestAgent/1.0)" },
69
+ ip,
70
+ };
71
+ }
72
+
73
+ const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} };
74
+
75
+ async function listen(app: FastifyInstance): Promise<number> {
76
+ await app.listen({ host: "127.0.0.1", port: 0 });
77
+ return (app.server.address() as AddressInfo).port;
78
+ }
79
+
80
+ async function postToolsList(port: number): Promise<Response> {
81
+ return fetch(`http://127.0.0.1:${port}/mcp`, {
82
+ method: "POST",
83
+ body: JSON.stringify(TOOLS_LIST),
84
+ headers: {
85
+ "content-type": "application/json",
86
+ accept: "application/json, text/event-stream",
87
+ },
88
+ });
89
+ }
90
+
91
+ /** The app the long-running entry builds, from the environment as it stands. */
92
+ async function appFromEnv(defaultStateless: boolean): Promise<FastifyInstance> {
93
+ const { port: _port, ...config } = httpTransportConfigFromEnv();
94
+ return buildHttpTransportApp({
95
+ ...config,
96
+ stateless: readStatelessEnv(defaultStateless),
97
+ });
98
+ }
99
+
100
+ describe("AC-1 — MCP_HTTP_STATELESS=true selects the stateless transport", () => {
101
+ let app: FastifyInstance | undefined;
102
+
103
+ afterEach(async () => {
104
+ await app?.close();
105
+ app = undefined;
106
+ });
107
+
108
+ it("answers a tools/list with no Mcp-Session-Id with 200, listing list_pages", async () => {
109
+ process.env["MCP_HTTP_STATELESS"] = "true";
110
+ app = await appFromEnv(false);
111
+ const port = await listen(app);
112
+
113
+ const res = await postToolsList(port);
114
+
115
+ expect(res.status).toBe(200);
116
+ const body = (await res.json()) as {
117
+ result?: { tools?: Array<{ name: string }> };
118
+ };
119
+ const names = (body.result?.tools ?? []).map((tool) => tool.name);
120
+ expect(names).toContain("list_pages");
121
+ });
122
+ });
123
+
124
+ describe("AC-2 — each entry's default, and the accepted spellings", () => {
125
+ let app: FastifyInstance | undefined;
126
+
127
+ afterEach(async () => {
128
+ await app?.close();
129
+ app = undefined;
130
+ });
131
+
132
+ it("unset, the long-running default is false — and a session-less tools/list is 400 (today's stateful behaviour)", async () => {
133
+ expect(readStatelessEnv(false)).toBe(false);
134
+
135
+ app = await appFromEnv(false);
136
+ const port = await listen(app);
137
+
138
+ const res = await postToolsList(port);
139
+ expect(res.status).toBe(400);
140
+ });
141
+
142
+ it("unset, the serverless default is true", () => {
143
+ expect(readStatelessEnv(true)).toBe(true);
144
+ });
145
+
146
+ it("an empty value takes the default", () => {
147
+ process.env["MCP_HTTP_STATELESS"] = "";
148
+ expect(readStatelessEnv(true)).toBe(true);
149
+ expect(readStatelessEnv(false)).toBe(false);
150
+ });
151
+
152
+ it.each(["false", "0", "FALSE"])("%s reads as false", (value) => {
153
+ process.env["MCP_HTTP_STATELESS"] = value;
154
+ expect(readStatelessEnv(true)).toBe(false);
155
+ });
156
+
157
+ it.each(["1", "True"])("%s reads as true", (value) => {
158
+ process.env["MCP_HTTP_STATELESS"] = value;
159
+ expect(readStatelessEnv(false)).toBe(true);
160
+ });
161
+
162
+ it("surrounding whitespace is trimmed", () => {
163
+ process.env["MCP_HTTP_STATELESS"] = " true ";
164
+ expect(readStatelessEnv(false)).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe("AC-3 — a bad value fails the boot; a blank rate does not disable the limit", () => {
169
+ it("a value outside the four spellings throws an Error naming MCP_HTTP_STATELESS", () => {
170
+ process.env["MCP_HTTP_STATELESS"] = "yes";
171
+ expect(() => readStatelessEnv(true)).toThrow(Error);
172
+ expect(() => readStatelessEnv(true)).toThrow(/MCP_HTTP_STATELESS/);
173
+ });
174
+
175
+ it("MCP_RATE_LIMIT_READ_PER_MIN='' yields the default 60, never NaN", () => {
176
+ process.env["MCP_RATE_LIMIT_READ_PER_MIN"] = "";
177
+ expect(httpTransportConfigFromEnv().rateLimits.readPerMin).toBe(60);
178
+ });
179
+
180
+ it("MCP_RATE_LIMIT_WRITE_PER_MIN='' yields the default 10, never NaN", () => {
181
+ process.env["MCP_RATE_LIMIT_WRITE_PER_MIN"] = "";
182
+ expect(httpTransportConfigFromEnv().rateLimits.writePerMin).toBe(10);
183
+ });
184
+ });
185
+
186
+ describe("httpTransportConfigFromEnv — today's reading of every other variable", () => {
187
+ it("unset, it returns today's defaults", () => {
188
+ expect(httpTransportConfigFromEnv()).toEqual({
189
+ port: 8080,
190
+ path: "/mcp",
191
+ rateLimits: { readPerMin: 60, writePerMin: 10 },
192
+ cors: { writeAllowedOrigins: [] },
193
+ abuse: {
194
+ captureLeadDailyCapPerIp: 50,
195
+ startPurchaseDailyCapPerIp: 20,
196
+ createBookingDailyCapPerIp: 20,
197
+ submitFlowStepDailyCapPerIp: 50,
198
+ blockedUserAgents: "",
199
+ },
200
+ });
201
+ });
202
+
203
+ it("set, it reads each variable as src/index.ts did", () => {
204
+ process.env["MCP_HTTP_PORT"] = "9090";
205
+ process.env["MCP_HTTP_PATH"] = "/agents";
206
+ process.env["MCP_RATE_LIMIT_READ_PER_MIN"] = "120";
207
+ process.env["MCP_RATE_LIMIT_WRITE_PER_MIN"] = "5";
208
+ process.env["MCP_CORS_WRITE_ALLOWED_ORIGINS"] =
209
+ "https://a.example, https://b.example";
210
+ process.env["MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP"] = "3";
211
+ process.env["MCP_ABUSE_START_PURCHASE_DAILY_CAP"] = "2";
212
+ process.env["MCP_ABUSE_CREATE_BOOKING_DAILY_CAP"] = "4";
213
+ process.env["MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP"] = "5";
214
+ process.env["MCP_ABUSE_BLOCKED_USER_AGENTS"] = "BadBot";
215
+
216
+ expect(httpTransportConfigFromEnv()).toEqual({
217
+ port: 9090,
218
+ path: "/agents",
219
+ rateLimits: { readPerMin: 120, writePerMin: 5 },
220
+ cors: { writeAllowedOrigins: ["https://a.example", "https://b.example"] },
221
+ abuse: {
222
+ captureLeadDailyCapPerIp: 3,
223
+ startPurchaseDailyCapPerIp: 2,
224
+ createBookingDailyCapPerIp: 4,
225
+ submitFlowStepDailyCapPerIp: 5,
226
+ blockedUserAgents: "BadBot",
227
+ },
228
+ });
229
+ });
230
+ });
231
+
232
+ // #8071 AC-5, through the wiring both entries use: the env value reaches the
233
+ // AbuseGuard the HTTP transport builds from `config.abuse`
234
+ // (http-transport.ts). A guard built straight from AbuseConfig cannot show
235
+ // this — it defaults an absent cap on its own — so each case also pins the
236
+ // value httpTransportConfigFromEnv() returns.
237
+ describe("#8071 AC-5 — the booking and flow-step caps reach the guard both entries build", () => {
238
+ const CASES = [
239
+ {
240
+ toolName: "create_booking",
241
+ envVar: "MCP_ABUSE_CREATE_BOOKING_DAILY_CAP",
242
+ field: "createBookingDailyCapPerIp",
243
+ defaultCap: DEFAULT_CREATE_BOOKING_DAILY_CAP,
244
+ },
245
+ {
246
+ toolName: "submit_flow_step",
247
+ envVar: "MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP",
248
+ field: "submitFlowStepDailyCapPerIp",
249
+ defaultCap: DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP,
250
+ },
251
+ ] as const;
252
+
253
+ /** Calls `toolName` until refused; returns how many calls were allowed. */
254
+ function allowedCalls(toolName: string, limit: number): number {
255
+ const guard = new AbuseGuard(httpTransportConfigFromEnv().abuse);
256
+ const request = requestFrom("198.51.100.7");
257
+ let allowed = 0;
258
+ while (allowed <= limit && guard.validate(toolName, {}, request).allowed) {
259
+ allowed += 1;
260
+ }
261
+ return allowed;
262
+ }
263
+
264
+ describe.each(CASES)("$envVar", ({ toolName, envVar, field, defaultCap }) => {
265
+ it.each(["", "abc"])(
266
+ "set to %p yields the default cap, refusing the call at default + 1",
267
+ (raw) => {
268
+ process.env[envVar] = raw;
269
+ expect(httpTransportConfigFromEnv().abuse[field]).toBe(defaultCap);
270
+ expect(allowedCalls(toolName, defaultCap)).toBe(defaultCap);
271
+ },
272
+ );
273
+
274
+ it("set to a positive integer, the guard refuses the call past that cap", () => {
275
+ process.env[envVar] = "3";
276
+ expect(httpTransportConfigFromEnv().abuse[field]).toBe(3);
277
+ expect(allowedCalls(toolName, defaultCap)).toBe(3);
278
+ });
279
+ });
280
+ });
@@ -20,12 +20,20 @@
20
20
  * - Blocked UA returns 403
21
21
  * - #5971: a start_purchase burst past the per-IP daily cap is refused with
22
22
  * the rate-limit error, and no Checkout Session is created
23
+ * - #8071: create_booking and submit_flow_step take the same write branch —
24
+ * the origin allow-list, each tool's own daily cap (no hub call for a
25
+ * refused one), one cap unit per batched call, and for create_booking an
26
+ * attendee.email format check
27
+ * - #8073: a capture_lead call forwards the socket address the transport
28
+ * trusted (never a caller-forged X-Forwarded-For) plus
29
+ * AILK_VISITOR_FORWARD_KEY as the two visitor-forwarding headers, and sends
30
+ * neither when the key is unset
23
31
  */
24
32
 
25
33
  import type { AddressInfo } from "node:net";
26
34
 
27
35
  import type { FastifyInstance, FastifyRequest } from "fastify";
28
- import { checkout } from "@working-theory/api-client";
36
+ import { apiClient, checkout } from "@working-theory/api-client";
29
37
 
30
38
  import {
31
39
  __setMcpCommerceClaimForTest,
@@ -41,7 +49,17 @@ import { listRegisteredTools } from "../src/server.js";
41
49
 
42
50
  jest.mock("@working-theory/api-client", () => {
43
51
  const actual = jest.requireActual("@working-theory/api-client");
44
- return { ...actual, checkout: { createSession: jest.fn() } };
52
+ // The hub stubs for the two #8071 write tools: every other surface stays
53
+ // real, so a tool these tests do not name behaves exactly as before.
54
+ return {
55
+ ...actual,
56
+ checkout: { createSession: jest.fn() },
57
+ apiClient: {
58
+ ...actual.apiClient,
59
+ schedule: { ...actual.apiClient.schedule, book: jest.fn() },
60
+ flows: { ...actual.apiClient.flows, submitStep: jest.fn() },
61
+ },
62
+ };
45
63
  });
46
64
 
47
65
  /** Permissive JSON-RPC 2.0 envelope type for response body assertions. */
@@ -340,6 +358,70 @@ describe("Abuse rules", () => {
340
358
  });
341
359
  });
342
360
 
361
+ /**
362
+ * #8073 — the lead-write tools forward the address the transport TRUSTED
363
+ * (`request.ip`), not a caller-supplied `X-Forwarded-For` value. The
364
+ * long-running entry (`buildHttpTransportApp`, used throughout this file)
365
+ * sets no `trustProxy`, so `request.ip` is always the real socket address —
366
+ * 127.0.0.1 here — exactly the D5 guarantee a forged header cannot move.
367
+ */
368
+ describe("capture_lead forwards the trusted caller address (#8073)", () => {
369
+ let app: FastifyInstance;
370
+ let port: number;
371
+ let submitSpy: jest.SpiedFunction<typeof apiClient.leads.submit>;
372
+
373
+ beforeEach(async () => {
374
+ submitSpy = jest
375
+ .spyOn(apiClient.leads, "submit")
376
+ .mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
377
+ app = await buildHttpTransportApp(DEFAULT_CONFIG);
378
+ await app.listen({ host: "127.0.0.1", port: 0 });
379
+ port = (app.server.address() as AddressInfo).port;
380
+ });
381
+
382
+ afterEach(async () => {
383
+ await app.close();
384
+ submitSpy.mockRestore();
385
+ delete process.env.AILK_VISITOR_FORWARD_KEY;
386
+ });
387
+
388
+ function capturePayload(id: number, email: string) {
389
+ return {
390
+ jsonrpc: "2.0",
391
+ id,
392
+ method: "tools/call",
393
+ params: { name: "capture_lead", arguments: { name: "Jane", email } },
394
+ };
395
+ }
396
+
397
+ it("sends the socket address and the forward key as the two visitor headers", async () => {
398
+ process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
399
+
400
+ const res = await mcpPost(port, capturePayload(40, "jane@example.com"), {
401
+ // A caller-forged hop: D5 says this must not move the key.
402
+ "x-forwarded-for": "203.0.113.7",
403
+ });
404
+
405
+ expect(res.status).toBe(200);
406
+ expect(submitSpy).toHaveBeenCalledTimes(1);
407
+ const init = submitSpy.mock.calls[0]?.[1];
408
+ expect(init?.headers).toEqual({
409
+ "x-ailk-visitor-ip": "127.0.0.1",
410
+ "x-ailk-visitor-forward-key": "spoke-key",
411
+ });
412
+ });
413
+
414
+ it("sends no visitor headers (and no second argument) when the key is unset", async () => {
415
+ delete process.env.AILK_VISITOR_FORWARD_KEY;
416
+
417
+ const res = await mcpPost(port, capturePayload(41, "jane2@example.com"));
418
+
419
+ expect(res.status).toBe(200);
420
+ expect(submitSpy).toHaveBeenCalledTimes(1);
421
+ expect(submitSpy.mock.calls[0]).toHaveLength(1);
422
+ });
423
+ });
424
+
343
425
  /**
344
426
  * AC-3 — a `start_purchase` burst beyond the AbuseGuard per-IP cap is refused
345
427
  * with the rate-limit error and creates no Checkout Session (#5971 D6).
@@ -860,3 +942,210 @@ describe("abuse refusal status is keyed on the typed reason code (#5994)", () =>
860
942
  expect(ABUSE_REFUSAL_STATUS[AbuseReasonCode.INVALID_EMAIL]).toBe(422);
861
943
  });
862
944
  });
945
+
946
+ /**
947
+ * #8071 — `create_booking` and `submit_flow_step` send caller-chosen data to
948
+ * the hub, so they take the same write branch as `capture_lead`: the
949
+ * write-origin allow-list, then the AbuseGuard once per call — each on its OWN
950
+ * daily-cap tracker.
951
+ *
952
+ * Every assertion here is a refusal the preHandler makes, so the suite holds
953
+ * in a scaffold that does not register the tool (no `scheduling` or
954
+ * `signal-collectors` module): the hub stub is checked for "no request for the
955
+ * refused call", never for a count the handler would have to produce.
956
+ */
957
+ const HUB_WRITE_TOOLS = [
958
+ {
959
+ toolName: "create_booking",
960
+ capField: "createBookingDailyCapPerIp",
961
+ hubStub: () => apiClient.schedule.book as jest.Mock,
962
+ args: () => ({
963
+ calLink: "acme/intro",
964
+ start: "2030-01-07T15:00:00.000Z",
965
+ attendee: { name: "Ada", email: "ada@example.com" },
966
+ }),
967
+ },
968
+ {
969
+ toolName: "submit_flow_step",
970
+ capField: "submitFlowStepDailyCapPerIp",
971
+ hubStub: () => apiClient.flows.submitStep as jest.Mock,
972
+ args: () => ({
973
+ slug: "intake",
974
+ stepKey: "contact",
975
+ values: { company: "Acme" },
976
+ }),
977
+ },
978
+ ] as const;
979
+
980
+ describe.each(HUB_WRITE_TOOLS)(
981
+ "$toolName takes the write branch of the HTTP guard (#8071)",
982
+ ({ toolName, capField, hubStub, args }) => {
983
+ const CAP = 2;
984
+ const config = {
985
+ ...DEFAULT_CONFIG,
986
+ cors: { writeAllowedOrigins: ["https://allowed.example"] as string[] },
987
+ abuse: {
988
+ captureLeadDailyCapPerIp: 50,
989
+ startPurchaseDailyCapPerIp: 20,
990
+ [capField]: CAP,
991
+ blockedUserAgents: "",
992
+ },
993
+ };
994
+
995
+ let app: FastifyInstance;
996
+ let port: number;
997
+
998
+ function call(id: number, callArgs: object = args()) {
999
+ return {
1000
+ jsonrpc: "2.0",
1001
+ id,
1002
+ method: "tools/call",
1003
+ params: { name: toolName, arguments: callArgs },
1004
+ };
1005
+ }
1006
+
1007
+ beforeEach(async () => {
1008
+ hubStub().mockResolvedValue({ ok: false, status: 503, body: {} });
1009
+ app = await buildHttpTransportApp(config);
1010
+ await app.listen({ host: "127.0.0.1", port: 0 });
1011
+ port = (app.server.address() as AddressInfo).port;
1012
+ });
1013
+
1014
+ afterEach(async () => {
1015
+ await app.close();
1016
+ jest.clearAllMocks();
1017
+ });
1018
+
1019
+ it("is a member of WRITE_TOOLS", () => {
1020
+ expect(WRITE_TOOLS.has(toolName)).toBe(true);
1021
+ });
1022
+
1023
+ it("refuses a call from an origin not on writeAllowedOrigins with 403", async () => {
1024
+ const res = await mcpPost(port, call(80), {
1025
+ origin: "https://evil.example",
1026
+ });
1027
+
1028
+ expect(res.status).toBe(403);
1029
+ const body = (await res.json()) as { error?: string };
1030
+ expect(body.error).toContain("CORS");
1031
+ expect(hubStub()).not.toHaveBeenCalled();
1032
+ });
1033
+
1034
+ it("refuses the call past its daily cap with 403, and the hub gets no request for it", async () => {
1035
+ for (let i = 0; i < CAP; i += 1) {
1036
+ const res = await mcpPost(port, call(81 + i));
1037
+ expect(res.status).toBe(200);
1038
+ }
1039
+ const hubCallsBefore = hubStub().mock.calls.length;
1040
+
1041
+ const capped = await mcpPost(port, call(90));
1042
+
1043
+ expect(capped.status).toBe(403);
1044
+ const body = (await capped.json()) as { error?: string };
1045
+ expect(body.error).toContain(toolName);
1046
+ expect(hubStub().mock.calls.length).toBe(hubCallsBefore);
1047
+ });
1048
+
1049
+ it("a batch of N calls charges N cap units", async () => {
1050
+ const res = await mcpPost(port, [call(91), call(92), call(93)]);
1051
+
1052
+ expect(res.status).toBe(403);
1053
+ expect(hubStub()).not.toHaveBeenCalled();
1054
+ });
1055
+
1056
+ it("its cap is its own — spending it leaves capture_lead callable", async () => {
1057
+ for (let i = 0; i < CAP; i += 1) await mcpPost(port, call(94 + i));
1058
+ expect((await mcpPost(port, call(97))).status).toBe(403);
1059
+
1060
+ const lead = await mcpPost(port, {
1061
+ jsonrpc: "2.0",
1062
+ id: 98,
1063
+ method: "tools/call",
1064
+ params: {
1065
+ name: "capture_lead",
1066
+ arguments: { name: "Ada", email: "ada@example.com" },
1067
+ },
1068
+ });
1069
+ expect(lead.status).not.toBe(403);
1070
+ });
1071
+
1072
+ it("a refusal maps through the typed code: past the cap is DAILY_CAP_REACHED", () => {
1073
+ const guard = new AbuseGuard({
1074
+ captureLeadDailyCapPerIp: 50,
1075
+ [capField]: 1,
1076
+ blockedUserAgents: "",
1077
+ });
1078
+ // @ts-expect-error: a stub carrying only the two members the guard reads.
1079
+ const request: FastifyRequest = {
1080
+ headers: { "user-agent": "Mozilla/5.0 (compatible; TestAgent/1.0)" },
1081
+ ip: "203.0.113.9",
1082
+ };
1083
+
1084
+ expect(guard.validate(toolName, args(), request).allowed).toBe(true);
1085
+ const refused = guard.validate(toolName, args(), request);
1086
+
1087
+ expect(refused.allowed).toBe(false);
1088
+ if (refused.allowed) return;
1089
+ expect(refused.code).toBe(AbuseReasonCode.DAILY_CAP_REACHED);
1090
+ expect(ABUSE_REFUSAL_STATUS[refused.code]).toBe(403);
1091
+ });
1092
+ },
1093
+ );
1094
+
1095
+ describe("create_booking — attendee.email is format-checked before the hub (#8071)", () => {
1096
+ let app: FastifyInstance;
1097
+ let port: number;
1098
+
1099
+ const book = () => apiClient.schedule.book as jest.Mock;
1100
+
1101
+ function booking(id: number, attendee: unknown) {
1102
+ return {
1103
+ jsonrpc: "2.0",
1104
+ id,
1105
+ method: "tools/call",
1106
+ params: {
1107
+ name: "create_booking",
1108
+ arguments: {
1109
+ calLink: "acme/intro",
1110
+ start: "2030-01-07T15:00:00.000Z",
1111
+ attendee,
1112
+ },
1113
+ },
1114
+ };
1115
+ }
1116
+
1117
+ beforeEach(async () => {
1118
+ book().mockResolvedValue({ ok: false, status: 503, body: {} });
1119
+ app = await buildHttpTransportApp(DEFAULT_CONFIG);
1120
+ await app.listen({ host: "127.0.0.1", port: 0 });
1121
+ port = (app.server.address() as AddressInfo).port;
1122
+ });
1123
+
1124
+ afterEach(async () => {
1125
+ await app.close();
1126
+ jest.clearAllMocks();
1127
+ });
1128
+
1129
+ it("refuses a malformed attendee.email with 422 and leaves the hub stub untouched", async () => {
1130
+ const res = await mcpPost(
1131
+ port,
1132
+ booking(100, { name: "Spammer", email: "not-an-email" }),
1133
+ );
1134
+
1135
+ expect(res.status).toBe(422);
1136
+ const body = (await res.json()) as { error?: string };
1137
+ expect(body.error).toContain("email");
1138
+ expect(book()).not.toHaveBeenCalled();
1139
+ });
1140
+
1141
+ it.each([null, "ada@example.com", ["ada@example.com"], 42])(
1142
+ "a non-object attendee (%p) is not a guard refusal and never a 500",
1143
+ async (attendee) => {
1144
+ const res = await mcpPost(port, booking(101, attendee));
1145
+
1146
+ // The handler's own Zod schema is the authority on the shape.
1147
+ expect(res.status).toBe(200);
1148
+ expect(book()).not.toHaveBeenCalled();
1149
+ },
1150
+ );
1151
+ });