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
@@ -8,6 +8,51 @@ Inherits root `CLAUDE.md`. This file adds MCP-specific rules.
8
8
  - Transport: stdio at launch
9
9
  - Discovery: `/.well-known/mcp.json` published from `apps/web`
10
10
 
11
+ ## Two entrypoints, one boot (#8070)
12
+
13
+ - **`src/index.ts`** — run locally: stdio (the default), or HTTP on `MCP_HTTP_PORT` with
14
+ `MCP_TRANSPORT=http`.
15
+ - **`src/app.ts`** — the Vercel entry. A Vercel project whose root directory is `apps/mcp`
16
+ detects it (it imports Fastify) and calls its default export, `serverlessHandler(app)`: a
17
+ `(req, res)` function, the shape Vercel's Node runtime runs — a bare Fastify instance is not
18
+ one and answers every request with a 500. It never listens. There is **no `vercel.json`**: a
19
+ `functions` pattern for `src/app.ts` is rejected for the `fastify` builder, so the site's
20
+ `content/` reaches the function by the builder's file tracing, and the 30-second request cap
21
+ is `export const config = { maxDuration: 30 }` in `app.ts` (a literal object — the builder
22
+ reads it by static analysis). `tsconfig.json` names `module` and `moduleResolution` itself:
23
+ Vercel's type-check edits the entry's nearest `tsconfig.json` as raw JSON before `extends`,
24
+ and without its own `module` it switches `strict` off, which fails 11 narrowing sites.
25
+ - Both call `bootMcp()` in `src/boot.ts` before building a transport, and read the HTTP
26
+ configuration through `httpTransportConfigFromEnv()` there. Add a boot step to `bootMcp()`,
27
+ never to one entry. `src/app.ts` stays thin; what a test needs is in `src/serverless-app.ts`.
28
+ - **`MCP_HTTP_STATELESS`** picks the transport mode: `true`/`1` stateless (no sessions; any
29
+ instance answers), `false`/`0` stateful (sessions in one process's memory). Unset, the
30
+ Vercel entry is stateless and `src/index.ts` is stateful. Any other value fails the boot.
31
+ The Vercel entry needs stateless: a session kept in one instance is lost on the next.
32
+ - **Content root.** Every content tool and `audit` take their root from `mcpContentRoot()`
33
+ (`lib/content-adapter.ts`): `AILK_CONTENT_ROOT`, else `<cwd>/content`, else
34
+ `<cwd>/../../content`. Never build a content path from `process.cwd()` in a tool.
35
+ - **`create_project`** is not served on a spoke: the generated seam's `UNSERVED_MCP_TOOLS`
36
+ keeps it out of registration, and the web manifest drops the same list.
37
+
38
+ ### What a spoke's MCP host on Vercel enforces
39
+
40
+ - **The address is the caller's, behind exactly one proxy.** The Vercel entry trusts one hop
41
+ (`trustOneHop`, `src/serverless-app.ts`), so the limits key on the address Vercel's edge
42
+ saw and a caller's own `X-Forwarded-For` cannot move the key. This holds when the MCP host
43
+ name points straight at the Vercel project; a second proxy in front keys every caller on
44
+ that proxy. It is a function, not `trustProxy: 1`, because Fastify 5.12 treats a numeric
45
+ hop count as trusting nothing. The long-running entry trusts no proxy.
46
+ - **Per address, per instance.** 60 requests a minute across reads and writes (the write rate
47
+ is parsed but not enforced); `capture_lead` 50 a day, `start_purchase` 20 a day,
48
+ `create_booking` 20 a day and `submit_flow_step` 50 a day, each on its own budget.
49
+ `subscribe_newsletter` and `request_callback` have no daily cap. Each warm instance counts
50
+ on its own and a cold start resets the counts, so a caller reaching N instances gets up to
51
+ N times each limit.
52
+ - **At the hub, one shared bucket.** The MCP lead tools forward no visitor address, so the
53
+ hub's lead limiter counts every MCP lead write from a spoke against the MCP host's egress
54
+ address (#8073).
55
+
11
56
  ## Identity-blind by design (hard rule)
12
57
 
13
58
  This rule governs the anonymous surface, `/mcp` (and stdio) — every tool the catalog returns with
@@ -26,11 +71,13 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
26
71
 
27
72
  ## The free anonymous tools (enabled at launch)
28
73
 
74
+ **The HTTP write branch (6).** `WRITE_TOOLS` in `src/http-transport.ts` — `capture_lead`, `subscribe_newsletter`, `request_callback`, `start_purchase`, `create_booking`, `submit_flow_step` — takes the write-origin allow-list and the AbuseGuard (UA, honeypot, email format, per-IP daily cap); every other tool takes the read branch (UA only). A tool that sends caller-chosen data to the hub belongs in it: `__tests__/write-tool-guard.test.ts` fails when a catalog tool that sends a non-GET request through `@working-theory/api-client` is missing, and makes every new catalog tool pick a branch (#8071).
75
+
29
76
  **Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
30
77
  **Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
31
78
  **Maintenance (2):** `audit`, `create_project`
32
- **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has
33
- **Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`
79
+ **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has. `submit_flow_step` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP`, default 50 — #8071)
80
+ **Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`. `create_booking` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_CREATE_BOOKING_DAILY_CAP`, default 20, and an `attendee.email` format check — #8071)
34
81
 
35
82
  ### Why the parity test counts 20 and a keyless server registers 19
36
83
 
@@ -56,14 +103,14 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
56
103
  server-wide gate and its own Gate 2 in `tools/purchase.ts`.
57
104
  `MCP_FEATURE_GATE_MODE` (`packages/metadata`) decides what an unheld
58
105
  feature does. In `"warn"` mode (this release) the five register on module
59
- presence alone, and `index.ts` logs one boot warning (stderr) per tool
106
+ presence alone, and the boot (`bootMcp()`) logs one warning (stderr) per tool
60
107
  served without its feature. In `"refuse"` mode (the next release) they
61
108
  also need the deployment's licence to hold the feature, and
62
109
  `/.well-known/mcp.json` drops them in step. The claim is resolved at boot
63
110
  and re-read every `MCP_CLAIM_REFRESH_MS` through `checkLicense()`
64
111
  (`lib/commerce-claim.ts`) and fails closed.
65
- `index.ts` is the transport entry point and exports no tools — registration
66
- is entirely via `createMcpServer`.
112
+ `index.ts` and `app.ts` are the transport entry points and export no tools —
113
+ registration is entirely via `createMcpServer`.
67
114
 
68
115
  ## The signed-in workspace surface (`/mcp/workspace`, #3591)
69
116
 
@@ -118,7 +165,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
118
165
  server's tool list, and calling it by name there returns the module-disabled
119
166
  `not_found` body. The claim is resolved at boot and re-read every
120
167
  `MCP_CLAIM_REFRESH_MS`
121
- (`src/lib/commerce-claim.ts`, awaited in `src/index.ts`) and read
168
+ (`src/lib/commerce-claim.ts`, awaited in `bootMcp()`, `src/boot.ts`) and read
122
169
  synchronously by both the registration filter and the handler's own
123
170
  re-check. Calls are rate-limited per IP by the AbuseGuard
124
171
  (`MCP_ABUSE_START_PURCHASE_DAILY_CAP`, default 20, on its own tracker).
@@ -164,7 +211,11 @@ Example (lead routing):
164
211
  - Don't add a tool without the matching `apps/api` endpoint.
165
212
  - Don't put business logic in tool handlers — they're adapters.
166
213
 
167
- Last updated: 2026-10-04 — #3591 PR-E (#7684): the identity-blind rule is scoped to the anonymous `/mcp`, and the new § The signed-in workspace surface records `/mcp/workspace` — POST-only and stateless, an agent key per request forwarded to each tool's apps/api twin, eleven `surface: "workspace"` catalog rows that are parity `twin`s and never in the discovery manifest — verified against `apps/mcp/src/http-transport.ts`, `apps/mcp/src/workspace-server.ts` and `packages/metadata/src/mcp-tool-catalog.ts`.
214
+ Last updated: 2026-10-10 — #8071: `create_booking` and `submit_flow_step` join the HTTP write branch — § The free anonymous tools names its six members, the Booking and Flow rows record it, and § What a spoke's MCP host on Vercel enforces lists the two new daily caps — verified against `apps/mcp/src/http-transport.ts` and `apps/mcp/src/abuse/index.ts`.
215
+
216
+ Prior: 2026-10-10 — #8070 (amendment 2): the Vercel entry exports a handler, no `vercel.json`; § Two entrypoints, one boot — the Vercel entry `src/app.ts`, the shared `src/boot.ts`, `MCP_HTTP_STATELESS`, `mcpContentRoot()`, `create_project` withheld on a spoke, and what a spoke's MCP host enforces — verified against `apps/mcp/src/{app,boot,serverless-app,index,http-transport,server}.ts` and `lib/content-adapter.ts`.
217
+
218
+ Prior: 2026-10-04 — #3591 PR-E (#7684): the identity-blind rule is scoped to the anonymous `/mcp`, and the new § The signed-in workspace surface records `/mcp/workspace` — POST-only and stateless, an agent key per request forwarded to each tool's apps/api twin, eleven `surface: "workspace"` catalog rows that are parity `twin`s and never in the discovery manifest — verified against `apps/mcp/src/http-transport.ts`, `apps/mcp/src/workspace-server.ts` and `packages/metadata/src/mcp-tool-catalog.ts`.
168
219
 
169
220
  Prior: 2026-09-29 — #7136: the five `tier: "pro"` tools name their licence `feature:` (`scheduling`, `flows-deliverables`) and gate on the deployment's own claim under `MCP_FEATURE_GATE_MODE` — `"warn"` this release (served, one boot warning each), `"refuse"` the next. The unused call-time paid-tier guard is deleted.
170
221
 
@@ -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
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Guard-level tests for abuse/index.ts — the AbuseGuard as a whole, where
3
+ * daily-cap.test.ts and its siblings test one rule each (#8071).
4
+ *
5
+ * Pins:
6
+ * - AC-4: the four daily-capped tools (capture_lead, start_purchase,
7
+ * create_booking, submit_flow_step) each spend their OWN tracker — spending
8
+ * one tool's budget from an IP leaves the other three callable from it.
9
+ * - AC-5: a blank or non-numeric MCP_ABUSE_CREATE_BOOKING_DAILY_CAP /
10
+ * MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP yields the default cap (20 / 50),
11
+ * never an unlimited one: the call at default + 1 is refused.
12
+ * - D3: create_booking's attendee.email is format-checked, and a non-object
13
+ * attendee is treated as a missing email rather than throwing.
14
+ */
15
+
16
+ import type { FastifyRequest } from "fastify";
17
+
18
+ import {
19
+ AbuseGuard,
20
+ AbuseReasonCode,
21
+ createAbuseGuardFromEnv,
22
+ DEFAULT_CREATE_BOOKING_DAILY_CAP,
23
+ DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP,
24
+ } from "../../src/abuse/index.js";
25
+
26
+ /** A stub carrying only the two members the guard reads. */
27
+ function requestFrom(ip: string): FastifyRequest {
28
+ // @ts-expect-error: a full FastifyRequest cannot be built without a server.
29
+ return {
30
+ headers: { "user-agent": "Mozilla/5.0 (compatible; TestAgent/1.0)" },
31
+ ip,
32
+ };
33
+ }
34
+
35
+ const CAPPED_TOOLS = [
36
+ "capture_lead",
37
+ "start_purchase",
38
+ "create_booking",
39
+ "submit_flow_step",
40
+ ] as const;
41
+
42
+ describe("AbuseGuard — each daily-capped tool spends its own tracker (AC-4)", () => {
43
+ it.each(CAPPED_TOOLS)(
44
+ "exhausting %s leaves the other three callable from the same IP",
45
+ (spent) => {
46
+ const guard = new AbuseGuard({
47
+ captureLeadDailyCapPerIp: 1,
48
+ startPurchaseDailyCapPerIp: 1,
49
+ createBookingDailyCapPerIp: 1,
50
+ submitFlowStepDailyCapPerIp: 1,
51
+ blockedUserAgents: "",
52
+ });
53
+ const request = requestFrom("198.51.100.4");
54
+
55
+ expect(guard.validate(spent, {}, request).allowed).toBe(true);
56
+ const refused = guard.validate(spent, {}, request);
57
+ expect(refused).toMatchObject({
58
+ allowed: false,
59
+ code: AbuseReasonCode.DAILY_CAP_REACHED,
60
+ });
61
+
62
+ for (const other of CAPPED_TOOLS.filter((t) => t !== spent)) {
63
+ expect(guard.validate(other, {}, request).allowed).toBe(true);
64
+ }
65
+ },
66
+ );
67
+ });
68
+
69
+ describe("createAbuseGuardFromEnv — a blank or non-numeric cap falls back to the default (AC-5)", () => {
70
+ const CASES = [
71
+ {
72
+ toolName: "create_booking",
73
+ envVar: "MCP_ABUSE_CREATE_BOOKING_DAILY_CAP",
74
+ defaultCap: DEFAULT_CREATE_BOOKING_DAILY_CAP,
75
+ },
76
+ {
77
+ toolName: "submit_flow_step",
78
+ envVar: "MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP",
79
+ defaultCap: DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP,
80
+ },
81
+ ] as const;
82
+
83
+ afterEach(() => {
84
+ for (const { envVar } of CASES) delete process.env[envVar];
85
+ });
86
+
87
+ it("the defaults are 20 bookings and 50 flow steps per IP per day", () => {
88
+ expect(DEFAULT_CREATE_BOOKING_DAILY_CAP).toBe(20);
89
+ expect(DEFAULT_SUBMIT_FLOW_STEP_DAILY_CAP).toBe(50);
90
+ });
91
+
92
+ describe.each(CASES)("$envVar", ({ toolName, envVar, defaultCap }) => {
93
+ it.each(["", "abc"])("set to %p refuses the call at default + 1", (raw) => {
94
+ process.env[envVar] = raw;
95
+ const guard = createAbuseGuardFromEnv();
96
+ const request = requestFrom("198.51.100.5");
97
+
98
+ for (let i = 0; i < defaultCap; i += 1) {
99
+ expect(guard.validate(toolName, {}, request).allowed).toBe(true);
100
+ }
101
+ expect(guard.validate(toolName, {}, request)).toMatchObject({
102
+ allowed: false,
103
+ code: AbuseReasonCode.DAILY_CAP_REACHED,
104
+ });
105
+ });
106
+
107
+ it("set to a positive integer uses that cap", () => {
108
+ process.env[envVar] = "3";
109
+ const guard = createAbuseGuardFromEnv();
110
+ const request = requestFrom("198.51.100.6");
111
+
112
+ for (let i = 0; i < 3; i += 1) {
113
+ expect(guard.validate(toolName, {}, request).allowed).toBe(true);
114
+ }
115
+ expect(guard.validate(toolName, {}, request).allowed).toBe(false);
116
+ });
117
+ });
118
+ });
119
+
120
+ describe("AbuseGuard — create_booking's attendee.email (D3)", () => {
121
+ const guard = () =>
122
+ new AbuseGuard({ captureLeadDailyCapPerIp: 50, blockedUserAgents: "" });
123
+
124
+ it("refuses a malformed attendee.email with INVALID_EMAIL", () => {
125
+ const result = guard().validate(
126
+ "create_booking",
127
+ { attendee: { name: "Ada", email: "not-an-email" } },
128
+ requestFrom("198.51.100.7"),
129
+ );
130
+
131
+ expect(result).toMatchObject({
132
+ allowed: false,
133
+ code: AbuseReasonCode.INVALID_EMAIL,
134
+ });
135
+ });
136
+
137
+ it("allows a well-formed attendee.email", () => {
138
+ const result = guard().validate(
139
+ "create_booking",
140
+ { attendee: { name: "Ada", email: "ada@example.com" } },
141
+ requestFrom("198.51.100.8"),
142
+ );
143
+
144
+ expect(result.allowed).toBe(true);
145
+ });
146
+
147
+ it("does not read a top-level email for create_booking", () => {
148
+ const result = guard().validate(
149
+ "create_booking",
150
+ { email: "not-an-email", attendee: { email: "ada@example.com" } },
151
+ requestFrom("198.51.100.9"),
152
+ );
153
+
154
+ expect(result.allowed).toBe(true);
155
+ });
156
+
157
+ it.each([undefined, null, "ada@example.com", ["not-an-email"], 7, {}])(
158
+ "treats attendee %p as a missing email — allowed, never a throw",
159
+ (attendee) => {
160
+ expect(() =>
161
+ guard().validate(
162
+ "create_booking",
163
+ { attendee },
164
+ requestFrom("198.51.100.10"),
165
+ ),
166
+ ).not.toThrow();
167
+ expect(
168
+ guard().validate(
169
+ "create_booking",
170
+ { attendee },
171
+ requestFrom("198.51.100.10"),
172
+ ).allowed,
173
+ ).toBe(true);
174
+ },
175
+ );
176
+
177
+ it("tolerates a non-object input", () => {
178
+ for (const input of [null, "x", 3]) {
179
+ expect(
180
+ guard().validate("create_booking", input, requestFrom("198.51.100.11"))
181
+ .allowed,
182
+ ).toBe(true);
183
+ }
184
+ });
185
+
186
+ it("still reads capture_lead's top-level email", () => {
187
+ const result = guard().validate(
188
+ "capture_lead",
189
+ { email: "not-an-email" },
190
+ requestFrom("198.51.100.12"),
191
+ );
192
+
193
+ expect(result).toMatchObject({
194
+ allowed: false,
195
+ code: AbuseReasonCode.INVALID_EMAIL,
196
+ });
197
+ });
198
+ });
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The Vercel entry (#8070 D2, D5).
3
+ *
4
+ * `src/app.ts` awaits the boot at module level, which ts-jest's CommonJS
5
+ * compile cannot load — so, like `index.test.ts`, its shape is checked as
6
+ * source text, and its behaviour is exercised through `serverless-app.ts`,
7
+ * the part it delegates to.
8
+ *
9
+ * Acceptance criteria verified here (spec docs/specs/8070-spoke-mcp-vercel-backend-spec.md):
10
+ * - AC-4 (amendment 2): app.ts imports Fastify, caps a request with
11
+ * `export const config = { maxDuration: 30 }`, builds the app from
12
+ * SERVERLESS_FASTIFY_OPTIONS, configures it, awaits `app.ready()`, default-exports
13
+ * `serverlessHandler(app)` (the `(req, res)` function Vercel's Node runtime
14
+ * calls — a Fastify instance is not a shape it accepts) and never listens; the
15
+ * options are `{ logger: false, trustProxy: trustOneHop }` — the spec's
16
+ * `trustProxy: 1` is a no-op on Fastify 5.12 (pinned below), so the same
17
+ * one-hop trust is expressed as a function; an `http.createServer` serving
18
+ * `serverlessHandler(app)` answers `initialize` and a session-less `tools/list`
19
+ * with MCP_HTTP_STATELESS unset.
20
+ * - AC-5: on the serverless app the daily cap keys on the LAST
21
+ * X-Forwarded-For hop; the long-running app ignores the header.
22
+ * - AC-13: tsconfig.json names `module` and `moduleResolution`, and there is
23
+ * no vercel.json.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { createServer, type Server } from "node:http";
28
+ import type { AddressInfo } from "node:net";
29
+ import { resolve } from "node:path";
30
+
31
+ import Fastify, { type FastifyInstance } from "fastify";
32
+
33
+ import { buildHttpTransportApp } from "../src/http-transport.js";
34
+ import {
35
+ configureServerlessApp,
36
+ SERVERLESS_FASTIFY_OPTIONS,
37
+ serverlessHandler,
38
+ trustOneHop,
39
+ } from "../src/serverless-app.js";
40
+
41
+ // The hub call capture_lead makes after the guard is stubbed, as
42
+ // http-transport.test.ts stubs checkout: only the guard's verdict is asserted.
43
+ jest.mock("@working-theory/api-client", () => {
44
+ const actual = jest.requireActual("@working-theory/api-client");
45
+ return {
46
+ ...actual,
47
+ apiClient: {
48
+ ...actual.apiClient,
49
+ leads: {
50
+ ...actual.apiClient.leads,
51
+ submit: jest.fn().mockResolvedValue({
52
+ ok: true,
53
+ body: {
54
+ id: "lead_00000000-0000-7000-8000-000000008070",
55
+ persisted: "db",
56
+ },
57
+ }),
58
+ },
59
+ },
60
+ };
61
+ });
62
+
63
+ const VARS = [
64
+ "MCP_HTTP_STATELESS",
65
+ "MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
66
+ "MCP_CORS_WRITE_ALLOWED_ORIGINS",
67
+ ] as const;
68
+
69
+ const saved: Partial<Record<(typeof VARS)[number], string | undefined>> = {};
70
+
71
+ beforeEach(() => {
72
+ for (const name of VARS) {
73
+ saved[name] = process.env[name];
74
+ delete process.env[name];
75
+ }
76
+ });
77
+
78
+ afterEach(() => {
79
+ for (const name of VARS) {
80
+ const value = saved[name];
81
+ if (value === undefined) delete process.env[name];
82
+ else process.env[name] = value;
83
+ }
84
+ });
85
+
86
+ async function mcpPost(
87
+ port: number,
88
+ payload: object,
89
+ headers: Record<string, string> = {},
90
+ ): Promise<Response> {
91
+ return fetch(`http://127.0.0.1:${port}/mcp`, {
92
+ method: "POST",
93
+ body: JSON.stringify(payload),
94
+ headers: {
95
+ "content-type": "application/json",
96
+ accept: "application/json, text/event-stream",
97
+ ...headers,
98
+ },
99
+ });
100
+ }
101
+
102
+ async function listen(app: FastifyInstance): Promise<number> {
103
+ await app.listen({ host: "127.0.0.1", port: 0 });
104
+ return (app.server.address() as AddressInfo).port;
105
+ }
106
+
107
+ /** The app src/app.ts builds, minus the boot and the default export. */
108
+ async function serverlessApp(): Promise<FastifyInstance> {
109
+ const app = Fastify(SERVERLESS_FASTIFY_OPTIONS);
110
+ await configureServerlessApp(app);
111
+ await app.ready();
112
+ return app;
113
+ }
114
+
115
+ /** Serve the app through the exact function Vercel's Node runtime calls. */
116
+ async function serveHandler(
117
+ app: FastifyInstance,
118
+ ): Promise<{ server: Server; port: number }> {
119
+ const server = createServer(serverlessHandler(app));
120
+ await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
121
+ return { server, port: (server.address() as AddressInfo).port };
122
+ }
123
+
124
+ async function stop(server: Server | undefined): Promise<void> {
125
+ if (!server) return;
126
+ await new Promise<void>((done) => server.close(() => done()));
127
+ }
128
+
129
+ function captureLead(id: number, name: string): object {
130
+ return {
131
+ jsonrpc: "2.0",
132
+ id,
133
+ method: "tools/call",
134
+ params: {
135
+ name: "capture_lead",
136
+ arguments: {
137
+ name,
138
+ email: `${name.toLowerCase()}@example.com`,
139
+ message: "Interested in your services.",
140
+ },
141
+ },
142
+ };
143
+ }
144
+
145
+ describe("AC-4 — src/app.ts, read as source text", () => {
146
+ const src = readFileSync(resolve(__dirname, "../src/app.ts"), "utf-8");
147
+
148
+ it("imports Fastify by value, so Vercel's Node-backend detection finds it", () => {
149
+ expect(src).toContain('import Fastify from "fastify"');
150
+ });
151
+
152
+ it("awaits the shared boot, builds Fastify from the serverless options and configures it", () => {
153
+ expect(src).toContain("await bootMcp()");
154
+ expect(src).toContain("Fastify(SERVERLESS_FASTIFY_OPTIONS)");
155
+ expect(src).toContain("configureServerlessApp(app)");
156
+ });
157
+
158
+ it("boots before it builds the app, and readies it before it exports", () => {
159
+ expect(src.indexOf("await bootMcp()")).toBeLessThan(
160
+ src.indexOf("Fastify(SERVERLESS_FASTIFY_OPTIONS)"),
161
+ );
162
+ expect(src).toContain("await app.ready()");
163
+ expect(src.indexOf("configureServerlessApp(app)")).toBeLessThan(
164
+ src.indexOf("await app.ready()"),
165
+ );
166
+ });
167
+
168
+ it("caps one request at 30 s with a literal exported config Vercel reads statically", () => {
169
+ expect(src).toContain("export const config = { maxDuration: 30 }");
170
+ });
171
+
172
+ it("default-exports the handler, not the app, and never listens", () => {
173
+ expect(src).toContain("export default serverlessHandler(app)");
174
+ expect(src).not.toContain("export default app;");
175
+ expect(src).not.toContain("listen(");
176
+ });
177
+ });
178
+
179
+ describe("AC-13 — Vercel's type-check keeps strict", () => {
180
+ it("tsconfig.json names module and moduleResolution, so @vercel/node's fixConfig leaves strict alone", () => {
181
+ const tsconfig = JSON.parse(
182
+ readFileSync(resolve(__dirname, "../tsconfig.json"), "utf-8"),
183
+ ) as { compilerOptions: Record<string, unknown> };
184
+ expect(tsconfig.compilerOptions["module"]).toBe("ESNext");
185
+ expect(tsconfig.compilerOptions["moduleResolution"]).toBe("bundler");
186
+ });
187
+
188
+ it("there is no vercel.json (its functions pattern is rejected for the fastify builder)", () => {
189
+ expect(existsSync(resolve(__dirname, "../vercel.json"))).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("AC-4 — the serverless options and the configured app", () => {
194
+ let app: FastifyInstance | undefined;
195
+ let server: Server | undefined;
196
+ let port = 0;
197
+
198
+ afterEach(async () => {
199
+ await stop(server);
200
+ await app?.close();
201
+ app = undefined;
202
+ server = undefined;
203
+ });
204
+
205
+ it("SERVERLESS_FASTIFY_OPTIONS is { logger: false, trustProxy: trustOneHop }", () => {
206
+ expect(SERVERLESS_FASTIFY_OPTIONS).toEqual({
207
+ logger: false,
208
+ trustProxy: trustOneHop,
209
+ });
210
+ });
211
+
212
+ it("trustOneHop trusts the immediate peer (hop 0) and nothing behind it, whatever its address", () => {
213
+ expect(trustOneHop("127.0.0.1", 0)).toBe(true);
214
+ expect(trustOneHop("10.0.0.1", 0)).toBe(true);
215
+ expect(trustOneHop("9.9.9.9", 1)).toBe(false);
216
+ expect(trustOneHop("1.1.1.1", 2)).toBe(false);
217
+ });
218
+
219
+ it("why not trustProxy: 1 — this Fastify treats a numeric hop count as trust-nothing", async () => {
220
+ const numeric = Fastify({ logger: false, trustProxy: 1 });
221
+ numeric.get("/ip", async (request) => ({ ip: request.ip }));
222
+ const res = await numeric.inject({
223
+ url: "/ip",
224
+ remoteAddress: "127.0.0.1",
225
+ headers: { "x-forwarded-for": "3.3.3.3, 8.8.8.8" },
226
+ });
227
+ await numeric.close();
228
+
229
+ // Every caller would key on the edge's address.
230
+ expect(res.json()).toEqual({ ip: "127.0.0.1" });
231
+ });
232
+
233
+ it("serverlessHandler(app) is a (req, res) function", async () => {
234
+ app = await serverlessApp();
235
+ expect(typeof serverlessHandler(app)).toBe("function");
236
+ });
237
+
238
+ it("with MCP_HTTP_STATELESS unset, an http.createServer serving serverlessHandler(app) answers initialize and then a session-less tools/list", async () => {
239
+ app = await serverlessApp();
240
+ ({ server, port } = await serveHandler(app));
241
+
242
+ const init = await mcpPost(port, {
243
+ jsonrpc: "2.0",
244
+ id: 1,
245
+ method: "initialize",
246
+ params: {
247
+ protocolVersion: "2025-03-26",
248
+ capabilities: {},
249
+ clientInfo: { name: "app-entry-test", version: "1.0.0" },
250
+ },
251
+ });
252
+ expect(init.status).toBe(200);
253
+ const initBody = (await init.json()) as {
254
+ result?: { serverInfo?: { name?: string } };
255
+ };
256
+ expect(initBody.result?.serverInfo).toMatchObject({ name: "ailk" });
257
+
258
+ const list = await mcpPost(port, {
259
+ jsonrpc: "2.0",
260
+ id: 2,
261
+ method: "tools/list",
262
+ params: {},
263
+ });
264
+ expect(list.status).toBe(200);
265
+ });
266
+ });
267
+
268
+ describe("AC-5 — the daily cap keys on the last X-Forwarded-For hop", () => {
269
+ let app: FastifyInstance | undefined;
270
+ let server: Server | undefined;
271
+
272
+ afterEach(async () => {
273
+ await stop(server);
274
+ await app?.close();
275
+ app = undefined;
276
+ server = undefined;
277
+ });
278
+
279
+ it("serverless app: two callers sharing a last hop are one caller; a different last hop is another", async () => {
280
+ process.env["MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP"] = "1";
281
+ // Set explicitly so the write-origin check is never what refuses a call.
282
+ process.env["MCP_CORS_WRITE_ALLOWED_ORIGINS"] = "*";
283
+ app = await serverlessApp();
284
+ const { server: served, port } = await serveHandler(app);
285
+ server = served;
286
+
287
+ const first = await mcpPost(port, captureLead(10, "First"), {
288
+ "x-forwarded-for": "1.1.1.1, 9.9.9.9",
289
+ });
290
+ expect(first.status).toBe(200);
291
+
292
+ const second = await mcpPost(port, captureLead(11, "Second"), {
293
+ "x-forwarded-for": "2.2.2.2, 9.9.9.9",
294
+ });
295
+ expect(second.status).toBe(403);
296
+ const refusal = (await second.json()) as { error?: string };
297
+ expect(refusal.error).toBe("daily capture_lead cap reached for this IP");
298
+
299
+ const third = await mcpPost(port, captureLead(12, "Third"), {
300
+ "x-forwarded-for": "3.3.3.3, 8.8.8.8",
301
+ });
302
+ expect(third.status).toBe(200);
303
+ });
304
+
305
+ it("long-running app (no trustProxy): the header is ignored — every call is the socket's address", async () => {
306
+ app = await buildHttpTransportApp({
307
+ path: "/mcp",
308
+ stateless: true,
309
+ rateLimits: { readPerMin: 60, writePerMin: 10 },
310
+ cors: { writeAllowedOrigins: ["*"] },
311
+ abuse: { captureLeadDailyCapPerIp: 1, blockedUserAgents: "" },
312
+ });
313
+ const port = await listen(app);
314
+
315
+ const first = await mcpPost(port, captureLead(20, "First"), {
316
+ "x-forwarded-for": "3.3.3.3",
317
+ });
318
+ expect(first.status).toBe(200);
319
+
320
+ const second = await mcpPost(port, captureLead(21, "Second"), {
321
+ "x-forwarded-for": "4.4.4.4",
322
+ });
323
+ expect(second.status).toBe(403);
324
+ });
325
+ });