create-ailk 0.18.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 (167) 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/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -2,16 +2,18 @@
2
2
  * Abuse rule orchestrator for MCP write tools.
3
3
  *
4
4
  * Runs all in-process heuristics for the anonymous write tools:
5
- * capture_lead, subscribe_newsletter, request_callback, and — since #5971 —
6
- * start_purchase.
5
+ * capture_lead, subscribe_newsletter, request_callback, start_purchase
6
+ * (#5971), and — since #8071 — create_booking and submit_flow_step.
7
7
  *
8
8
  * Rules applied (in order):
9
9
  * 1. User-Agent deny list (applies to all write tools)
10
10
  * 2. Honeypot field check (applies to all write tools)
11
- * 3. Email format validation (capture_lead, subscribe_newsletter)
12
- * 4. Per-IP daily cap (capture_lead and start_purchase, each on its OWN
13
- * tracker — sharing one would let purchase attempts consume the lead
14
- * budget and vice versa, a silent cross-tool denial of service)
11
+ * 3. Email format validation (capture_lead, subscribe_newsletter, and
12
+ * create_booking's attendee.email)
13
+ * 4. Per-IP daily cap (capture_lead, start_purchase, create_booking and
14
+ * submit_flow_step, each on its OWN tracker — sharing one would let one
15
+ * tool's calls consume another's budget, a silent cross-tool denial of
16
+ * service)
15
17
  *
16
18
  * The paid layer (lead-routing.ts) extends with real abuse-protection
17
19
  * services. OSS gets basic heuristics that stop obvious bots.
@@ -24,11 +26,40 @@ import { isValidEmail } from "./email-validator.js";
24
26
  import { isHoneypotTripped } from "./honeypot.js";
25
27
  import { buildDenyList, isUserAgentBlocked } from "./user-agent-deny.js";
26
28
 
27
- /** Tools that require email format validation on their input. */
28
- const EMAIL_VALIDATED_TOOLS = new Set(["capture_lead", "subscribe_newsletter"]);
29
+ /**
30
+ * Tools that require email format validation on their input, each mapped to
31
+ * where the email sits in it: top level for the lead tools, `attendee.email`
32
+ * for create_booking (#8071 D3). submit_flow_step has no fixed email field —
33
+ * its `values` is a free record the hub's flow engine validates.
34
+ */
35
+ const EMAIL_VALIDATED_TOOLS = new Map<string, readonly string[]>([
36
+ ["capture_lead", ["email"]],
37
+ ["subscribe_newsletter", ["email"]],
38
+ ["create_booking", ["attendee", "email"]],
39
+ ]);
40
+
41
+ /**
42
+ * The value at `path` in `input`, or undefined when any step is not a plain
43
+ * object — a `null`, string or array `attendee` reads as a missing email, so a
44
+ * malformed body can never throw out of the guard (#8071 SEC-13).
45
+ */
46
+ function valueAt(input: unknown, path: readonly string[]): unknown {
47
+ let value = input;
48
+ for (const key of path) {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
50
+ return undefined;
51
+ }
52
+ value = (value as Record<string, unknown>)[key];
53
+ }
54
+ return value;
55
+ }
29
56
 
30
57
  /** The per-tool daily-cap budgets, keyed by MCP tool name. */
31
- type DailyCapKey = "captureLead" | "startPurchase";
58
+ type DailyCapKey =
59
+ | "captureLead"
60
+ | "startPurchase"
61
+ | "createBooking"
62
+ | "submitFlowStep";
32
63
 
33
64
  /**
34
65
  * The typed reason codes an {@link AbuseResult} refusal can carry.
@@ -56,8 +87,9 @@ export type AbuseReasonCode =
56
87
  /**
57
88
  * Tools that apply a per-IP daily cap, each mapped to the tracker that budgets
58
89
  * it and the refusal reason it answers with. One tracker per entry — never
59
- * shared across tools, since a shared budget would let purchase attempts
60
- * consume the lead allowance and vice versa.
90
+ * shared across tools, since a shared budget would let one tool's calls
91
+ * consume another's allowance (purchase attempts the lead budget, flow steps
92
+ * the lead budget they could otherwise mint Leads past).
61
93
  *
62
94
  * The reason strings here are prose for the caller and nothing more: every
63
95
  * entry refuses with {@link AbuseReasonCode.DAILY_CAP_REACHED}, and that code
@@ -81,6 +113,20 @@ const DAILY_CAPPED_TOOLS = new Map<
81
113
  reason: "daily cap reached for start_purchase from this IP",
82
114
  },
83
115
  ],
116
+ [
117
+ "create_booking",
118
+ {
119
+ tracker: "createBooking",
120
+ reason: "daily cap reached for create_booking from this IP",
121
+ },
122
+ ],
123
+ [
124
+ "submit_flow_step",
125
+ {
126
+ tracker: "submitFlowStep",
127
+ reason: "daily cap reached for submit_flow_step from this IP",
128
+ },
129
+ ],
84
130
  ]);
85
131
 
86
132
  export interface AbuseConfig {
@@ -93,6 +139,18 @@ export interface AbuseConfig {
93
139
  * keep compiling; omitted falls back to the default.
94
140
  */
95
141
  startPurchaseDailyCapPerIp?: number;
142
+ /**
143
+ * Per-IP daily cap for create_booking (#8071). Default 20, as for
144
+ * start_purchase: each call creates a real calendar event and emails the
145
+ * attendee an invite. Optional; omitted falls back to the default.
146
+ */
147
+ createBookingDailyCapPerIp?: number;
148
+ /**
149
+ * Per-IP daily cap for submit_flow_step (#8071). Default 50, equal to
150
+ * capture_lead's and no higher: a one-step flow creates a Lead in one call.
151
+ * Optional; omitted falls back to the default.
152
+ */
153
+ submitFlowStepDailyCapPerIp?: number;
96
154
  /** Comma-separated extra UA substrings to block. */
97
155
  blockedUserAgents: string;
98
156
  }
@@ -103,6 +161,12 @@ export const DEFAULT_START_PURCHASE_DAILY_CAP = 20;
103
161
  /** Default per-IP daily cap for capture_lead when none is configured. */
104
162
  export const DEFAULT_CAPTURE_LEAD_DAILY_CAP = 50;
105
163
 
164
+ /** Default per-IP daily cap for create_booking when none is configured. */
165
+ export const DEFAULT_CREATE_BOOKING_DAILY_CAP = 20;
166
+
167
+ /** Default per-IP daily cap for submit_flow_step when none is configured. */
168
+ export const DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP = 50;
169
+
106
170
  /**
107
171
  * Reads a positive-integer env var, falling back to `fallback` for anything
108
172
  * that is not one.
@@ -162,6 +226,18 @@ export class AbuseGuard {
162
226
  DEFAULT_START_PURCHASE_DAILY_CAP,
163
227
  ),
164
228
  ),
229
+ createBooking: new DailyCapTracker(
230
+ usableCap(
231
+ config.createBookingDailyCapPerIp,
232
+ DEFAULT_CREATE_BOOKING_DAILY_CAP,
233
+ ),
234
+ ),
235
+ submitFlowStep: new DailyCapTracker(
236
+ usableCap(
237
+ config.submitFlowStepDailyCapPerIp,
238
+ DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP,
239
+ ),
240
+ ),
165
241
  };
166
242
  }
167
243
 
@@ -198,10 +274,12 @@ export class AbuseGuard {
198
274
  };
199
275
  }
200
276
 
201
- // Rule 3: Email format validation
202
- if (EMAIL_VALIDATED_TOOLS.has(toolName)) {
203
- const rec = input as Record<string, unknown>;
204
- const email = rec["email"];
277
+ // Rule 3: Email format validation, at the tool's own email path. A
278
+ // missing or non-string email passes — the handler's Zod schema is the
279
+ // authority on required fields.
280
+ const emailPath = EMAIL_VALIDATED_TOOLS.get(toolName);
281
+ if (emailPath !== undefined) {
282
+ const email = valueAt(input, emailPath);
205
283
  if (typeof email === "string" && !isValidEmail(email)) {
206
284
  return {
207
285
  allowed: false,
@@ -227,12 +305,16 @@ export class AbuseGuard {
227
305
  }
228
306
 
229
307
  /**
230
- * Convenience function for constructing an AbuseGuard from env vars.
231
- * Reads MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP,
232
- * MCP_ABUSE_START_PURCHASE_DAILY_CAP and MCP_ABUSE_BLOCKED_USER_AGENTS.
308
+ * The abuse configuration, read from the environment. The one reader of the
309
+ * MCP_ABUSE_* variables: both entries reach it through
310
+ * `httpTransportConfigFromEnv()` (`src/boot.ts`), and
311
+ * {@link createAbuseGuardFromEnv} wraps it, so a new cap is added here once.
312
+ *
313
+ * Every cap goes through {@link readPositiveIntEnv}: a blank, zero or
314
+ * non-numeric value yields its default, never an unlimited cap.
233
315
  */
234
- export function createAbuseGuardFromEnv(): AbuseGuard {
235
- return new AbuseGuard({
316
+ export function abuseConfigFromEnv(): Required<AbuseConfig> {
317
+ return {
236
318
  captureLeadDailyCapPerIp: readPositiveIntEnv(
237
319
  "MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
238
320
  DEFAULT_CAPTURE_LEAD_DAILY_CAP,
@@ -241,6 +323,20 @@ export function createAbuseGuardFromEnv(): AbuseGuard {
241
323
  "MCP_ABUSE_START_PURCHASE_DAILY_CAP",
242
324
  DEFAULT_START_PURCHASE_DAILY_CAP,
243
325
  ),
326
+ createBookingDailyCapPerIp: readPositiveIntEnv(
327
+ "MCP_ABUSE_CREATE_BOOKING_DAILY_CAP",
328
+ DEFAULT_CREATE_BOOKING_DAILY_CAP,
329
+ ),
330
+ submitFlowStepDailyCapPerIp: readPositiveIntEnv(
331
+ "MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP",
332
+ DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP,
333
+ ),
334
+ // A literal read, so create-ailk's declared-env scan finds it (#7874).
244
335
  blockedUserAgents: process.env["MCP_ABUSE_BLOCKED_USER_AGENTS"] ?? "",
245
- });
336
+ };
337
+ }
338
+
339
+ /** An AbuseGuard built from {@link abuseConfigFromEnv}. */
340
+ export function createAbuseGuardFromEnv(): AbuseGuard {
341
+ return new AbuseGuard(abuseConfigFromEnv());
246
342
  }
@@ -11,11 +11,7 @@
11
11
  * a type only, so the stdio entry still never loads the HTTP stack.
12
12
  */
13
13
 
14
- import {
15
- DEFAULT_CAPTURE_LEAD_DAILY_CAP,
16
- DEFAULT_START_PURCHASE_DAILY_CAP,
17
- readPositiveIntEnv,
18
- } from "./abuse/index.js";
14
+ import { abuseConfigFromEnv, readPositiveIntEnv } from "./abuse/index.js";
19
15
  import type { HttpTransportConfig } from "./http-transport.js";
20
16
  import {
21
17
  initMcpCommerceClaim,
@@ -158,28 +154,14 @@ export function httpTransportConfigFromEnv(): HttpTransportConfig & {
158
154
  const writeAllowedOrigins = writeOriginsRaw
159
155
  ? writeOriginsRaw.split(",").map((o) => o.trim())
160
156
  : [];
161
- // Both caps go through readPositiveIntEnv rather than a bare parseInt: a
162
- // BLANK env var would otherwise yield NaN and silently disable the cap
163
- // (#5971 security review round 2).
164
- const captureLeadDailyCapPerIp = readPositiveIntEnv(
165
- "MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
166
- DEFAULT_CAPTURE_LEAD_DAILY_CAP,
167
- );
168
- const startPurchaseDailyCapPerIp = readPositiveIntEnv(
169
- "MCP_ABUSE_START_PURCHASE_DAILY_CAP",
170
- DEFAULT_START_PURCHASE_DAILY_CAP,
171
- );
172
- const blockedUserAgents = process.env["MCP_ABUSE_BLOCKED_USER_AGENTS"] ?? "";
173
-
174
157
  return {
175
158
  port,
176
159
  path,
177
160
  rateLimits: { readPerMin, writePerMin },
178
161
  cors: { writeAllowedOrigins },
179
- abuse: {
180
- captureLeadDailyCapPerIp,
181
- startPurchaseDailyCapPerIp,
182
- blockedUserAgents,
183
- },
162
+ // The MCP_ABUSE_* variables have one reader, shared with
163
+ // createAbuseGuardFromEnv (#8071); every cap falls back to its default
164
+ // on a blank or non-numeric value, never to unlimited (#5971).
165
+ abuse: abuseConfigFromEnv(),
184
166
  };
185
167
  }
@@ -51,6 +51,7 @@ import {
51
51
  AbuseReasonCode,
52
52
  type AbuseConfig,
53
53
  } from "./abuse/index.js";
54
+ import { trustedCallerAddress } from "./request-context.js";
54
55
  import { createMcpServer } from "./server.js";
55
56
  import { createWorkspaceMcpServer } from "./workspace-server.js";
56
57
 
@@ -85,6 +86,13 @@ export const ABUSE_REFUSAL_STATUS: Record<AbuseReasonCode, 403 | 422> = {
85
86
  * worth money to abuse, so it takes the write branch — the origin allow-list,
86
87
  * the write CORS posture, and `abuseGuard.validate()` — rather than the read
87
88
  * branch's UA check alone.
89
+ *
90
+ * `create_booking` and `submit_flow_step` joined in #8071: each forwards
91
+ * caller-chosen data to the hub — a real booking on the seller's calendar
92
+ * that emails the attendee an invite, and a flow step whose completing call
93
+ * can create a Lead — so they take the same branch, each on its own daily
94
+ * cap. `__tests__/write-tool-guard.test.ts` fails when a catalog tool that
95
+ * sends a non-GET request to the hub is missing from this set.
88
96
  */
89
97
  // Any name added here MUST be enforced through the batch-aware path in
90
98
  // writeToolCalls() below — a request body can be a JSON-RPC ARRAY, and a
@@ -96,6 +104,8 @@ export const WRITE_TOOLS = new Set([
96
104
  "subscribe_newsletter",
97
105
  "request_callback",
98
106
  "start_purchase",
107
+ "create_booking",
108
+ "submit_flow_step",
99
109
  ]);
100
110
 
101
111
  export interface HttpTransportCorsConfig {
@@ -382,83 +392,93 @@ export async function registerHttpTransport(
382
392
  }
383
393
  }
384
394
  },
385
- handler: async (request, reply) => {
386
- const body = request.body as unknown;
387
- const sessionId = request.headers["mcp-session-id"] as string | undefined;
388
-
389
- try {
390
- let transport: StreamableHTTPServerTransport;
391
-
392
- if (config.stateless) {
393
- // Stateless mode: each request gets a fresh transport + server.
394
- // enableJsonResponse: true ensures plain JSON responses (no SSE).
395
- // sessionIdGenerator is intentionally omitted (stateless = no sessions).
396
- transport = new StreamableHTTPServerTransport({
397
- enableJsonResponse: true,
398
- });
399
- const server = createMcpServer();
400
- // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
401
- // as `(() => void) | undefined`, which conflicts with Transport.onclose
402
- // under exactOptionalPropertyTypes: true. The runtime behaviour is
403
- // identical; the cast is safe.
404
- await server.connect(transport as unknown as Transport);
405
- try {
406
- await transport.handleRequest(request.raw, reply.raw, body);
407
- } finally {
408
- // Close the per-request transport + server to release internal
409
- // handles. Without this, the MCP SDK's transport leaves open
410
- // sockets that surface as `socket.destroySoon is not a function`
411
- // during Jest teardown on Node 20.x (@hono/node-server compat).
412
- await transport.close().catch(() => undefined);
413
- await server.close().catch(() => undefined);
395
+ handler: async (request, reply) =>
396
+ // The lead-write tools read this address (#8073) through
397
+ // `visitorForwardHeadersForThisCall()` while the SDK dispatches their
398
+ // call, somewhere inside `transport.handleRequest()` below — the whole
399
+ // handler body runs inside the store so every path reaches it.
400
+ trustedCallerAddress.run(request.ip, async () => {
401
+ const body = request.body as unknown;
402
+ const sessionId = request.headers["mcp-session-id"] as
403
+ | string
404
+ | undefined;
405
+
406
+ try {
407
+ let transport: StreamableHTTPServerTransport;
408
+
409
+ if (config.stateless) {
410
+ // Stateless mode: each request gets a fresh transport + server.
411
+ // enableJsonResponse: true ensures plain JSON responses (no SSE).
412
+ // sessionIdGenerator is intentionally omitted (stateless = no sessions).
413
+ transport = new StreamableHTTPServerTransport({
414
+ enableJsonResponse: true,
415
+ });
416
+ const server = createMcpServer();
417
+ // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
418
+ // as `(() => void) | undefined`, which conflicts with Transport.onclose
419
+ // under exactOptionalPropertyTypes: true. The runtime behaviour is
420
+ // identical; the cast is safe.
421
+ await server.connect(transport as unknown as Transport);
422
+ try {
423
+ await transport.handleRequest(request.raw, reply.raw, body);
424
+ } finally {
425
+ // Close the per-request transport + server to release internal
426
+ // handles. Without this, the MCP SDK's transport leaves open
427
+ // sockets that surface as `socket.destroySoon is not a function`
428
+ // during Jest teardown on Node 20.x (@hono/node-server compat).
429
+ await transport.close().catch(() => undefined);
430
+ await server.close().catch(() => undefined);
431
+ }
432
+ return;
414
433
  }
415
- return;
416
- }
417
-
418
- if (sessionId && transports.has(sessionId)) {
419
- // Existing session — reuse transport
420
- transport = transports.get(sessionId)!;
421
- } else if (!sessionId && isInitializeRequest(body)) {
422
- // New initialization — create a stateful transport + fresh server
423
- transport = new StreamableHTTPServerTransport({
424
- sessionIdGenerator: () => randomUUID(),
425
- onsessioninitialized: (sid) => {
426
- transports.set(sid, transport);
427
- },
428
- });
429
434
 
430
- transport.onclose = () => {
431
- const sid = transport.sessionId;
432
- if (sid) transports.delete(sid);
433
- };
434
-
435
- const server = createMcpServer();
436
- // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
437
- // as `(() => void) | undefined`, which conflicts with Transport.onclose
438
- // under exactOptionalPropertyTypes: true. The runtime behaviour is
439
- // identical; the cast is safe.
440
- await server.connect(transport as unknown as Transport);
441
- } else {
442
- reply.code(400).send({
443
- jsonrpc: "2.0",
444
- error: { code: -32000, message: "Bad Request: no valid session" },
445
- id: null,
446
- });
447
- return;
448
- }
435
+ if (sessionId && transports.has(sessionId)) {
436
+ // Existing session — reuse transport
437
+ transport = transports.get(sessionId)!;
438
+ } else if (!sessionId && isInitializeRequest(body)) {
439
+ // New initialization — create a stateful transport + fresh server
440
+ transport = new StreamableHTTPServerTransport({
441
+ sessionIdGenerator: () => randomUUID(),
442
+ onsessioninitialized: (sid) => {
443
+ transports.set(sid, transport);
444
+ },
445
+ });
446
+
447
+ transport.onclose = () => {
448
+ const sid = transport.sessionId;
449
+ if (sid) transports.delete(sid);
450
+ };
451
+
452
+ const server = createMcpServer();
453
+ // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
454
+ // as `(() => void) | undefined`, which conflicts with Transport.onclose
455
+ // under exactOptionalPropertyTypes: true. The runtime behaviour is
456
+ // identical; the cast is safe.
457
+ await server.connect(transport as unknown as Transport);
458
+ } else {
459
+ reply.code(400).send({
460
+ jsonrpc: "2.0",
461
+ error: {
462
+ code: -32000,
463
+ message: "Bad Request: no valid session",
464
+ },
465
+ id: null,
466
+ });
467
+ return;
468
+ }
449
469
 
450
- await transport.handleRequest(request.raw, reply.raw, body);
451
- } catch (err) {
452
- if (!reply.sent) {
453
- reply.code(500).send({
454
- jsonrpc: "2.0",
455
- error: { code: -32603, message: "Internal server error" },
456
- id: null,
457
- });
470
+ await transport.handleRequest(request.raw, reply.raw, body);
471
+ } catch (err) {
472
+ if (!reply.sent) {
473
+ reply.code(500).send({
474
+ jsonrpc: "2.0",
475
+ error: { code: -32603, message: "Internal server error" },
476
+ id: null,
477
+ });
478
+ }
479
+ throw err;
458
480
  }
459
- throw err;
460
- }
461
- },
481
+ }),
462
482
  });
463
483
 
464
484
  // DELETE — session termination
@@ -15,6 +15,8 @@
15
15
  * MCP_CORS_WRITE_ALLOWED_ORIGINS= (empty = no cross-origin writes)
16
16
  * MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP=50
17
17
  * MCP_ABUSE_START_PURCHASE_DAILY_CAP=20
18
+ * MCP_ABUSE_CREATE_BOOKING_DAILY_CAP=20
19
+ * MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP=50
18
20
  * MCP_ABUSE_BLOCKED_USER_AGENTS= (comma-separated substrings)
19
21
  *
20
22
  * Content adapter (all optional; `CONTENT_ADAPTER` default: mdx):
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-request context for @working-theory/mcp, shared by the HTTP transport
3
+ * and the MCP lead-write tools (#8073).
4
+ *
5
+ * Deliberately dependency-free beyond `@working-theory/api-client` (already
6
+ * loaded by every tool): `http-transport.ts` imports Fastify and the full
7
+ * HTTP stack, and `src/index.ts`'s stdio branch dynamic-imports `server.js`
8
+ * specifically to avoid loading that stack for a local stdio caller. A tool
9
+ * importing `trustedCallerAddress` straight from `http-transport.ts` would
10
+ * pull Fastify into every stdio boot and, worse, close an import cycle
11
+ * (`http-transport.ts` → `server.ts` → `tools/*.ts` → `http-transport.ts`).
12
+ * This module sits below both: `http-transport.ts` populates the store,
13
+ * the tools read it, neither imports the other.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+
18
+ import { visitorForwardHeaders } from "@working-theory/api-client";
19
+
20
+ /**
21
+ * The caller address the transport trusted for the POST request currently
22
+ * being handled — `request.ip`, Fastify's own `trustProxy`-resolved value
23
+ * (the Vercel entry trusts exactly one hop, #8070 D5), never the raw,
24
+ * caller-forgeable leftmost `X-Forwarded-For` entry. Populated by
25
+ * `http-transport.ts` for the duration of one `transport.handleRequest()`
26
+ * call and read by the three MCP lead-write tools (#8073) so they can
27
+ * forward it to the hub without the tool gaining an identity argument.
28
+ * `undefined` outside an HTTP request — stdio has no client address to
29
+ * forward.
30
+ */
31
+ export const trustedCallerAddress = new AsyncLocalStorage<string>();
32
+
33
+ /**
34
+ * The two visitor-forwarding headers (`x-ailk-visitor-ip`,
35
+ * `x-ailk-visitor-forward-key`) for the request this call is running inside,
36
+ * or `undefined` when there is no trusted address for this call (not an HTTP
37
+ * request), no `AILK_VISITOR_FORWARD_KEY`, or `visitorForwardHeaders` itself
38
+ * declines (an unsafe transport, an unsafe key value). Read by the lead-write
39
+ * tools right before they call the hub (#8073).
40
+ */
41
+ export function visitorForwardHeadersForThisCall():
42
+ | Record<string, string>
43
+ | undefined {
44
+ const address = trustedCallerAddress.getStore();
45
+ if (!address) return undefined;
46
+ return visitorForwardHeaders(
47
+ {
48
+ get: (name: string) => (name === "x-forwarded-for" ? address : null),
49
+ },
50
+ process.env.AILK_VISITOR_FORWARD_KEY,
51
+ );
52
+ }
@@ -17,6 +17,8 @@ import {
17
17
  type SubmitLeadOutput,
18
18
  } from "@working-theory/validation";
19
19
 
20
+ import { visitorForwardHeadersForThisCall } from "../request-context.js";
21
+
20
22
  export { submitLeadInputSchema, submitLeadOutputSchema };
21
23
  export type { SubmitLeadInput, SubmitLeadOutput };
22
24
 
@@ -47,7 +49,13 @@ export async function ailkCaptureLead(
47
49
  },
48
50
  };
49
51
 
50
- const res = await apiClient.leads.submit(body);
52
+ // #8073 — forward the address the transport trusted (never an identity
53
+ // argument on this tool's own schema) so the hub counts the write in that
54
+ // visitor's own rate-limit bucket rather than this MCP host's.
55
+ const headers = visitorForwardHeadersForThisCall();
56
+ const res = headers
57
+ ? await apiClient.leads.submit(body, { headers })
58
+ : await apiClient.leads.submit(body);
51
59
  if (!res.ok) {
52
60
  return { ok: false, error: { kind: "api_error", status: res.status } };
53
61
  }
@@ -18,6 +18,8 @@ import {
18
18
  type RequestCallbackOutput,
19
19
  } from "@working-theory/validation";
20
20
 
21
+ import { visitorForwardHeadersForThisCall } from "../request-context.js";
22
+
21
23
  export { requestCallbackInputSchema, requestCallbackOutputSchema };
22
24
  export type { RequestCallbackInput, RequestCallbackOutput };
23
25
 
@@ -42,7 +44,13 @@ export async function ailkRequestCallback(
42
44
  payload: {},
43
45
  };
44
46
 
45
- const res = await apiClient.leads.callback(body);
47
+ // #8073 — forward the address the transport trusted (never an identity
48
+ // argument on this tool's own schema) so the hub counts the write in that
49
+ // visitor's own rate-limit bucket rather than this MCP host's.
50
+ const headers = visitorForwardHeadersForThisCall();
51
+ const res = headers
52
+ ? await apiClient.leads.callback(body, { headers })
53
+ : await apiClient.leads.callback(body);
46
54
  if (!res.ok) {
47
55
  return { ok: false, error: { kind: "api_error", status: res.status } };
48
56
  }
@@ -18,6 +18,8 @@ import {
18
18
  type SubscribeNewsletterOutput,
19
19
  } from "@working-theory/validation";
20
20
 
21
+ import { visitorForwardHeadersForThisCall } from "../request-context.js";
22
+
21
23
  export { subscribeNewsletterInputSchema, subscribeNewsletterOutputSchema };
22
24
  export type { SubscribeNewsletterInput, SubscribeNewsletterOutput };
23
25
 
@@ -40,7 +42,13 @@ export async function ailkSubscribeNewsletter(
40
42
  const siteId = process.env.AILK_SITE_ID ?? "default";
41
43
  const body = { siteId, email, source: "mcp" };
42
44
 
43
- const res = await apiClient.leads.subscribe(body);
45
+ // #8073 — forward the address the transport trusted (never an identity
46
+ // argument on this tool's own schema) so the hub counts the write in that
47
+ // visitor's own rate-limit bucket rather than this MCP host's.
48
+ const headers = visitorForwardHeadersForThisCall();
49
+ const res = headers
50
+ ? await apiClient.leads.subscribe(body, { headers })
51
+ : await apiClient.leads.subscribe(body);
44
52
  if (!res.ok) {
45
53
  return { ok: false, error: { kind: "api_error", status: res.status } };
46
54
  }
@@ -1,3 +1,4 @@
1
+
1
2
  Apache License
2
3
  Version 2.0, January 2004
3
4
  http://www.apache.org/licenses/
@@ -137,8 +138,8 @@
137
138
 
138
139
  6. Trademarks. This License does not grant permission to use the trade
139
140
  names, trademarks, service marks, or product names of the Licensor,
140
- except as required for describing the origin of the Work and
141
- reproducing the content of the NOTICE file.
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
142
143
 
143
144
  7. Disclaimer of Warranty. Unless required by applicable law or
144
145
  agreed to in writing, Licensor provides the Work (and each
@@ -162,7 +163,7 @@
162
163
  other commercial damages or losses), even if such Contributor
163
164
  has been advised of the possibility of such damages.
164
165
 
165
- 9. Accepting Warranty or Support. While redistributing
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
167
  the Work or Derivative Works thereof, You may choose to offer,
167
168
  and charge a fee for, acceptance of support, warranty, indemnity,
168
169
  or other liability obligations and/or rights consistent with this
@@ -171,7 +172,7 @@
171
172
  of any other Contributor, and only if You agree to indemnify,
172
173
  defend, and hold each Contributor harmless for any liability
173
174
  incurred by, or claims asserted against, such Contributor by reason
174
- of your accepting any such warranty or support.
175
+ of your accepting any such warranty or additional liability.
175
176
 
176
177
  END OF TERMS AND CONDITIONS
177
178
 
@@ -186,7 +187,7 @@
186
187
  same "printed page" as the copyright notice for easier
187
188
  identification within third-party archives.
188
189
 
189
- Copyright 2026 Tailwind AI
190
+ Copyright [yyyy] [name of copyright owner]
190
191
 
191
192
  Licensed under the Apache License, Version 2.0 (the "License");
192
193
  you may not use this file except in compliance with the License.
@@ -0,0 +1,2 @@
1
+ AI Launch Kit
2
+ Copyright 2026 SalesSmyth LLC
@@ -28,7 +28,7 @@
28
28
  * See `docs/security/agent-manifest.md` for the full consumer guidance.
29
29
  */
30
30
 
31
- import { SITE_NAME } from "../../../lib/site-brand";
31
+ import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
32
32
  import { resolveBaseUrl } from "../../../src/lib/routes";
33
33
 
34
34
  /** Soft cap to detect runaway `description_for_model` growth. */
@@ -66,8 +66,8 @@ export function GET(): Response {
66
66
  schema_version: "v1",
67
67
  name_for_human: SITE_NAME,
68
68
  name_for_model: "ai_launch_kit",
69
- description_for_human:
70
- "AX-first OSS template — ship software with API + MCP as canonical agent surfaces.",
69
+ // #8085 — the site's own description, as /.well-known/mcp.json (#8057).
70
+ description_for_human: SITE_DESCRIPTION,
71
71
  description_for_model: MANIFEST_DESCRIPTION_FOR_MODEL,
72
72
  auth: { type: "none" },
73
73
  api: {