create-ailk 0.23.0 → 0.24.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 (96) hide show
  1. package/README.md +27 -14
  2. package/dist/add-module.js +6 -1
  3. package/dist/add-route.js +7 -1
  4. package/dist/derive-architecture.js +17 -0
  5. package/dist/lib/scaffold-render-sweep.d.ts +33 -0
  6. package/dist/lib/scaffold-render-sweep.js +45 -0
  7. package/dist/module-architecture.js +17 -0
  8. package/dist/sync-routes.d.ts +30 -4
  9. package/dist/sync-routes.js +271 -18
  10. package/package.json +3 -3
  11. package/templates/.env.example +4 -0
  12. package/templates/CLAUDE.md +4 -2
  13. package/templates/apps/api/.env.example +7 -0
  14. package/templates/apps/api/CLAUDE.md +25 -11
  15. package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
  16. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  17. package/templates/apps/api/src/lib/site.ts +4 -1
  18. package/templates/apps/api/src/lib/stripe.ts +3 -0
  19. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  20. package/templates/apps/api/src/openapi/spec.ts +333 -0
  21. package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
  22. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
  23. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  24. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  25. package/templates/apps/api/src/routes/content/search.ts +16 -45
  26. package/templates/apps/api/src/routes/leads/index.ts +364 -298
  27. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  28. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  29. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  30. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  31. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
  32. package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
  33. package/templates/apps/api/src/server.ts +25 -0
  34. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  35. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
  36. package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
  37. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
  38. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  39. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  40. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  41. package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
  42. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
  43. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
  44. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
  45. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
  46. package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
  47. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
  48. package/templates/apps/api/src/services/offerings/connection.ts +83 -3
  49. package/templates/apps/api/src/services/offerings/index.ts +6 -6
  50. package/templates/apps/api/src/services/person.ts +23 -0
  51. package/templates/apps/api/src/services/project-listings.ts +43 -31
  52. package/templates/apps/mcp/CLAUDE.md +11 -5
  53. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  54. package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
  55. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  56. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  57. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  58. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  59. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  60. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  61. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  62. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  63. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  64. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  65. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  66. package/templates/apps/mcp/package.json +1 -0
  67. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  68. package/templates/apps/mcp/src/http-transport.ts +47 -9
  69. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  70. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  71. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  72. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  73. package/templates/apps/mcp/src/tools/index.ts +22 -21
  74. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  75. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  76. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  77. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  78. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  79. package/templates/apps/web/CLAUDE.md +12 -3
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  81. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
  82. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  83. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
  84. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
  85. package/templates/apps/web/lib/magnet-routes.ts +3 -1
  86. package/templates/apps/web/lib/newsletter.actions.ts +9 -6
  87. package/templates/database/CHANGELOG.md +20 -0
  88. package/templates/database/__tests__/seed.test.ts +33 -1
  89. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  90. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  91. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  92. package/templates/database/package.json +1 -1
  93. package/templates/database/seed.ts +27 -0
  94. package/templates/tools/lint-architecture.ts +205 -1
  95. package/versions.json +34 -34
  96. package/templates/apps/api/src/routes/content/slug.ts +0 -9
@@ -469,6 +469,29 @@ function usableCustomerId(raw: string | null | undefined): string | null {
469
469
  : null;
470
470
  }
471
471
 
472
+ /** A Stripe customer id: `cus_` and letters or digits only, no separator. */
473
+ const STRIPE_CUSTOMER_ID = /^cus_[A-Za-z0-9]+$/;
474
+
475
+ /**
476
+ * The Stripe Dashboard link to one customer (#9349 DD5). Pure: the caller
477
+ * reads the mode. `null` unless `customerId` is a well-formed `cus_` id of
478
+ * at most 255 characters, so a stored value can never add a path segment,
479
+ * a query or a fragment to the fixed Dashboard host.
480
+ */
481
+ export function stripeCustomerDashboardUrl(
482
+ customerId: string,
483
+ mode: "test" | "live",
484
+ ): string | null {
485
+ if (
486
+ customerId.length > MAX_CUSTOMER_ID_LENGTH ||
487
+ !STRIPE_CUSTOMER_ID.test(customerId)
488
+ ) {
489
+ return null;
490
+ }
491
+ const segment = mode === "test" ? "test/customers" : "customers";
492
+ return `https://dashboard.stripe.com/${segment}/${customerId}`;
493
+ }
494
+
472
495
  // ─── findOrCreatePerson ───────────────────────────────────────────────────────
473
496
 
474
497
  /** The one delegate this file writes through — the shared client or a transaction's. */
@@ -176,6 +176,14 @@ export function deriveListingSlug(name: string): string {
176
176
  */
177
177
  const SLUG_COLLISION_SCAN_LIMIT = 200;
178
178
 
179
+ /**
180
+ * The most active rows the anonymous page-render read returns (#9243). No
181
+ * consumer in this repo names a shelf size, so the bound is the one the other
182
+ * anonymous listing read already carries — the directory's
183
+ * `LISTING_DIRECTORY_MAX_ENTRIES` — rather than a second number to keep in step.
184
+ */
185
+ const PUBLIC_LISTINGS_MAX_ROWS = LISTING_DIRECTORY_MAX_ENTRIES;
186
+
179
187
  /**
180
188
  * The first free slug for `base` on this site. Uniqueness is already scoped
181
189
  * `@@unique([siteId, slug])`, so the suffix is too: a second "Atlas" on this
@@ -412,10 +420,10 @@ function toResponseRow(
412
420
  * Since #5036 D6d the listing stores the experiment's KEY rather than its id,
413
421
  * and that key is the same string `WaitlistScore.experimentKey` holds — so the
414
422
  * `WaitlistExperiment` read that existed purely to translate one into the
415
- * other is gone. One `WaitlistScore.count` per DISTINCT key remains, bounded
416
- * by the small number of distinct experiments a dashboard page carries rather
417
- * than by row count (`waitlist-dashboard.ts`'s "one read across every returned
418
- * row" discipline).
423
+ * other is gone. ONE `WaitlistScore.groupBy` over every DISTINCT key remains
424
+ * (#9243): one read across every returned row (`waitlist-dashboard.ts`'s
425
+ * discipline), however many experiments the page carries — the per-key
426
+ * `count()` fan-out this replaced ran on the anonymous public read.
419
427
  */
420
428
  async function attachWaitlistCounts(
421
429
  siteId: string,
@@ -426,29 +434,28 @@ async function attachWaitlistCounts(
426
434
  ];
427
435
  if (experimentKeys.length === 0) return new Map();
428
436
 
429
- const counts = new Map<string, number>();
430
- await Promise.all(
431
- experimentKeys.map(async (experimentKey) => {
432
- // fence: workspaceId === the scope id this query already filters siteId by
433
- // (workspace_site_agree CHECK, 20260922120000_tenancy_fence). Filtering the
434
- // CONSTRAINED column makes the scope correct by construction; the format
435
- // CHECK makes a raw alias match nothing rather than by accident.
436
- //
437
- // What that id IS depends on the caller, and only two of the six are
438
- // route-resolved: `listProjectListings` and `getProjectListing` receive
439
- // `tenant.workspaceId`, so for them this is an isolation control.
440
- // `listMyProjectListings` and the two `publicProjectListings` call sites
441
- // pass a ProjectListing row's OWN `siteId`, so for them it is a
442
- // CONSISTENCY control — the count agrees with the row it is reported
443
- // against — exactly as `project-listing-decision.ts#waitlistCountFor`
444
- // spells out (#6258 § 1.6 / § 5 D8, security review L-2).
445
- const count = await prisma.waitlistScore.count({
446
- where: { siteId, workspaceId: siteId, experimentKey },
447
- });
448
- counts.set(experimentKey, count);
449
- }),
450
- );
451
- return counts;
437
+ // fence: workspaceId === the scope id this query already filters siteId by
438
+ // (workspace_site_agree CHECK, 20260922120000_tenancy_fence). Filtering the
439
+ // CONSTRAINED column makes the scope correct by construction; the format
440
+ // CHECK makes a raw alias match nothing rather than by accident.
441
+ //
442
+ // What that id IS depends on the caller, and only two of the six are
443
+ // route-resolved: `listProjectListings` and `getProjectListing` receive
444
+ // `tenant.workspaceId`, so for them this is an isolation control.
445
+ // `listMyProjectListings` and the two `publicProjectListings` call sites
446
+ // pass a ProjectListing row's OWN `siteId`, so for them it is a
447
+ // CONSISTENCY control — the count agrees with the row it is reported
448
+ // against — exactly as `project-listing-decision.ts#waitlistCountFor`
449
+ // spells out (#6258 § 1.6 / § 5 D8, security review L-2).
450
+ //
451
+ // A key with no score rows is absent from a GROUP BY result; `waitlistCountFor`
452
+ // reads it as 0, the same value the per-key `count()` produced.
453
+ const grouped = await prisma.waitlistScore.groupBy({
454
+ by: ["experimentKey"],
455
+ where: { siteId, workspaceId: siteId, experimentKey: { in: experimentKeys } },
456
+ _count: { _all: true },
457
+ });
458
+ return new Map(grouped.map((g) => [g.experimentKey, g._count._all]));
452
459
  }
453
460
 
454
461
  function waitlistCountFor(
@@ -1902,10 +1909,10 @@ function orderPublicListings<T extends { row: { id: string; decidedAt: Date | nu
1902
1909
  }
1903
1910
 
1904
1911
  /**
1905
- * The page-render read (#4998 AC4, reshaped by #5033): every `active` row for
1906
- * the site, the THREE newest by `decidedAt` first, then the rest by
1907
- * `waitlistCount` desc, then `decidedAt` desc. `founderEmail` never leaves
1908
- * this function.
1912
+ * The page-render read (#4998 AC4, reshaped by #5033, bounded by #9243): the
1913
+ * newest-decided `PUBLIC_LISTINGS_MAX_ROWS` `active` rows for the site, the
1914
+ * THREE newest by `decidedAt` first, then the rest by `waitlistCount` desc,
1915
+ * then `decidedAt` desc. `founderEmail` never leaves this function.
1909
1916
  *
1910
1917
  * Rows whose window has elapsed are returned and read `closed` — computed,
1911
1918
  * per row, against one instant, with no job having run. They are not filtered
@@ -1917,6 +1924,11 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
1917
1924
  const listingConfig = await listingConfigFor(siteId);
1918
1925
  const rows = await prisma.projectListing.findMany({
1919
1926
  where: { siteId, status: "active" },
1927
+ // Same window as the directory read: with a `take`, the rows kept must be
1928
+ // chosen, and "newest decided first, NULLS LAST" is the order the public
1929
+ // shelf leads with anyway.
1930
+ orderBy: [{ decidedAt: { sort: "desc", nulls: "last" } }, { id: "asc" }],
1931
+ take: PUBLIC_LISTINGS_MAX_ROWS,
1920
1932
  select: PROJECT_LISTING_SELECT,
1921
1933
  });
1922
1934
  const counts = await attachWaitlistCounts(siteId, rows);
@@ -43,8 +43,10 @@ Inherits root `CLAUDE.md`. This file adds MCP-specific rules.
43
43
  name points straight at the Vercel project; a second proxy in front keys every caller on
44
44
  that proxy. It is a function, not `trustProxy: 1`, because Fastify 5.12 treats a numeric
45
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,
46
+ - **Per address, per instance.** 60 requests a minute across every request
47
+ (`MCP_RATE_LIMIT_READ_PER_MIN`), plus a tighter 10 a minute on the six anonymous write tools
48
+ (`MCP_RATE_LIMIT_WRITE_PER_MIN`, #9251), refused with the same 429 shape; `capture_lead` 50 a
49
+ day, `start_purchase` 20 a day,
48
50
  `create_booking` 20 a day and `submit_flow_step` 50 a day, each on its own budget.
49
51
  `subscribe_newsletter` and `request_callback` have no daily cap. Each warm instance counts
50
52
  on its own and a cold start resets the counts, so a caller reaching N instances gets up to
@@ -73,8 +75,8 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
73
75
 
74
76
  **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
77
 
76
- **Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
77
- **Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
78
+ **Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`. The first three adopted their `apps/api` routes' arguments and results (#7167, #7168, #7169 — the operator's decision of 2026-10-19): `search_content` takes `q` and answers the route's `ItemList` (`query`, `items`, `totalItems`); `list_pages` requires `type`, takes `page`/`pageSize` and answers the paginated `ItemList` (`items`, `pagination`); `read_content` takes `include` and answers the page's JSON-LD at the root with `page`, plus `body` only with `include: "body"` — a miss is the not-found body. Each parses the same `@working-theory/validation` export its route parses with and answers through the same `@working-theory/content-adapters` builder, so the parity guard holds them as `twin`s
79
+ **Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback` — each takes the body its `POST /v1/leads/{submit,subscribe,callback}` route parses (`leadSubmitBodySchema`: `source`, `email`, `consent`, `payload`; `leadSubscribeBodySchema`: `email`, `source`; `leadCallbackBodySchema`: `name`, `phone`, `preferredTime`, `payload`) and returns the route's 201 body `{ id, persisted, email }` unchanged (#7171, #7172, #7173 — the operator's decision of 2026-10-19), so the parity guard holds the three as `twin`s. `source` is the agent's to choose — `mcp` unless the operator says otherwise — and a consent-bearing source needs `consent: true`, exactly as a form does; `capture_lead` still spreads the spoke marker `payload.site` from `AILK_LEAD_SITE` last. The AbuseGuard reads the first two's `email` at the top level, else inside `payload` (the route lifts it from there), so a malformed address is refused wherever it sits
78
80
  **Maintenance (2):** `audit`, `create_project`
79
81
  **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
82
  **Booking (4):** `list_availability`, `get_event_meta`, `list_meetings`, `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). `list_meetings` (#8843 D5) twins `GET /v1/schedule/meetings`: it takes no argument and returns the site's Live meetings — slug, visibility, position and whether each can be booked, plus a bookable one's calLink, title, length, description and price; `private` means unlisted, not secret
@@ -223,7 +225,11 @@ Example (lead routing):
223
225
  - Don't add a tool without the matching `apps/api` endpoint.
224
226
  - Don't put business logic in tool handlers — they're adapters.
225
227
 
226
- Last updated: 2026-10-18 — #9137: the three listing application tools are `list_listing_applications`, `get_listing_application` and `decide_listing_application`, and their pre-rename names stay one release as deprecated aliases — the signed-in workspace surface is eighteen tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
228
+ Last updated: 2026-10-20 — #7171, #7172, #7173: the three lead writes adopted their routes' bodies and 201 results and are parity `twin`s — the Conversion-write line records the adopted shapes — verified against `apps/mcp/src/tools/{capture_lead,subscribe_newsletter,request_callback}.ts`, `packages/validation/src/schemas/leads.ts` and `tests/parity/api-parity/kinds/site.ts`.
229
+
230
+ Prior: 2026-10-20 — #7167, #7168, #7169: the three content reads adopted their routes' arguments and results and are parity `twin`s — the Read line records the adopted shapes — verified against `apps/mcp/src/tools/{search_content,list_pages,read_content}.ts`, `packages/validation/src/schemas/content-reads.ts`, `packages/content-adapters/src/content-reads.ts` and `tests/parity/api-parity/kinds/site.ts`.
231
+
232
+ Prior: 2026-10-18 — #9137: the three listing application tools are `list_listing_applications`, `get_listing_application` and `decide_listing_application`, and their pre-rename names stay one release as deprecated aliases — the signed-in workspace surface is eighteen tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
227
233
 
228
234
  Prior: 2026-10-18 — #8843 PR A: `list_meetings` twins `GET /v1/schedule/meetings` — Booking is four tools, the counts are 21 in TOOLS, 20 registered keyless and 21 commerce-claimed, and six tools carry `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/server.ts` and `tests/parity/api-parity/kinds/site.ts`.
229
235
 
@@ -134,9 +134,9 @@ function captureLead(id: number, name: string): object {
134
134
  params: {
135
135
  name: "capture_lead",
136
136
  arguments: {
137
- name,
137
+ source: "mcp",
138
138
  email: `${name.toLowerCase()}@example.com`,
139
- message: "Interested in your services.",
139
+ payload: { name, message: "Interested in your services." },
140
140
  },
141
141
  },
142
142
  };
@@ -185,8 +185,9 @@ describe("CORS enforcement on write tools", () => {
185
185
  params: {
186
186
  name: "capture_lead",
187
187
  arguments: {
188
- name: "Bot",
188
+ source: "mcp",
189
189
  email: "bot@evil.com",
190
+ payload: { name: "Bot" },
190
191
  },
191
192
  },
192
193
  };
@@ -214,8 +215,9 @@ describe("CORS enforcement on write tools", () => {
214
215
  params: {
215
216
  name: "capture_lead",
216
217
  arguments: {
217
- name: "Jane",
218
+ source: "mcp",
218
219
  email: "jane@example.com",
220
+ payload: { name: "Jane" },
219
221
  },
220
222
  },
221
223
  };
@@ -297,8 +299,31 @@ describe("Abuse rules", () => {
297
299
  params: {
298
300
  name: "capture_lead",
299
301
  arguments: {
300
- name: "Spammer",
302
+ source: "mcp",
301
303
  email: "not-an-email",
304
+ payload: { name: "Spammer" },
305
+ },
306
+ },
307
+ };
308
+
309
+ const res = await mcpPost(port, payload);
310
+ expect(res.status).toBe(422);
311
+ });
312
+
313
+ // #7171 — `email` is optional at the top level of the route body (the
314
+ // site's own envelope carries it inside `payload`, and the route lifts it
315
+ // from there), so the guard reads `payload.email` when the top level has
316
+ // none: a malformed address cannot be nested past the check.
317
+ it("rejects capture_lead with a malformed payload.email and no top-level email", async () => {
318
+ const payload = {
319
+ jsonrpc: "2.0",
320
+ id: 23,
321
+ method: "tools/call",
322
+ params: {
323
+ name: "capture_lead",
324
+ arguments: {
325
+ source: "mcp",
326
+ payload: { name: "Spammer", email: "not-an-email" },
302
327
  },
303
328
  },
304
329
  };
@@ -315,9 +340,10 @@ describe("Abuse rules", () => {
315
340
  params: {
316
341
  name: "capture_lead",
317
342
  arguments: {
318
- name: "Bot",
343
+ source: "mcp",
319
344
  email: "bot@example.com",
320
345
  honeypot: "i-am-a-bot",
346
+ payload: { name: "Bot" },
321
347
  },
322
348
  },
323
349
  };
@@ -346,7 +372,11 @@ describe("Abuse rules", () => {
346
372
  method: "tools/call",
347
373
  params: {
348
374
  name: "capture_lead",
349
- arguments: { name: "Bot", email: "bot@example.com" },
375
+ arguments: {
376
+ source: "mcp",
377
+ email: "bot@example.com",
378
+ payload: { name: "Bot" },
379
+ },
350
380
  },
351
381
  },
352
382
  { "user-agent": "EvilBot/1.0" },
@@ -373,7 +403,14 @@ describe("capture_lead forwards the trusted caller address (#8073)", () => {
373
403
  beforeEach(async () => {
374
404
  submitSpy = jest
375
405
  .spyOn(apiClient.leads, "submit")
376
- .mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
406
+ .mockResolvedValue({
407
+ ok: true,
408
+ body: {
409
+ id: "lead_1",
410
+ persisted: "db",
411
+ email: { ok: true, messageId: "msg_1" },
412
+ },
413
+ });
377
414
  app = await buildHttpTransportApp(DEFAULT_CONFIG);
378
415
  await app.listen({ host: "127.0.0.1", port: 0 });
379
416
  port = (app.server.address() as AddressInfo).port;
@@ -390,7 +427,10 @@ describe("capture_lead forwards the trusted caller address (#8073)", () => {
390
427
  jsonrpc: "2.0",
391
428
  id,
392
429
  method: "tools/call",
393
- params: { name: "capture_lead", arguments: { name: "Jane", email } },
430
+ params: {
431
+ name: "capture_lead",
432
+ arguments: { source: "mcp", email, payload: { name: "Jane" } },
433
+ },
394
434
  };
395
435
  }
396
436
 
@@ -411,6 +451,36 @@ describe("capture_lead forwards the trusted caller address (#8073)", () => {
411
451
  });
412
452
  });
413
453
 
454
+ // #7171 AC-11, AC-14 — the body reaches the hub as the agent sent it; an
455
+ // agent's `siteId` is STRIPPED by the SDK's parse against the registered
456
+ // shape (never refused, as the route strips it), so none is ever posted.
457
+ it("posts the route body as sent, and strips an agent's siteId rather than refusing it", async () => {
458
+ delete process.env.AILK_LEAD_SITE;
459
+
460
+ const res = await mcpPost(port, {
461
+ jsonrpc: "2.0",
462
+ id: 42,
463
+ method: "tools/call",
464
+ params: {
465
+ name: "capture_lead",
466
+ arguments: {
467
+ source: "mcp",
468
+ email: "jane3@example.com",
469
+ payload: { name: "Jane" },
470
+ siteId: "evil",
471
+ },
472
+ },
473
+ });
474
+
475
+ expect(res.status).toBe(200);
476
+ expect(submitSpy).toHaveBeenCalledTimes(1);
477
+ expect(submitSpy.mock.calls[0]?.[0]).toEqual({
478
+ source: "mcp",
479
+ email: "jane3@example.com",
480
+ payload: { name: "Jane" },
481
+ });
482
+ });
483
+
414
484
  it("sends no visitor headers (and no second argument) when the key is unset", async () => {
415
485
  delete process.env.AILK_VISITOR_FORWARD_KEY;
416
486
 
@@ -646,7 +716,7 @@ describe("JSON-RPC batch bodies take the write branch (#5971)", () => {
646
716
  method: "tools/call",
647
717
  params: {
648
718
  name: "capture_lead",
649
- arguments: { name: "A", email: "a@example.com" },
719
+ arguments: { source: "mcp", email: "a@example.com", payload: { name: "A" } },
650
720
  },
651
721
  });
652
722
 
@@ -752,6 +822,9 @@ describe("a non-finite daily cap falls back to the default, never to unlimited (
752
822
  });
753
823
  app = await buildHttpTransportApp({
754
824
  ...DEFAULT_CONFIG,
825
+ // 21 writes in one minute: lift the per-minute write bucket (#9251) so
826
+ // the DAILY cap is the limit this test exercises.
827
+ rateLimits: { readPerMin: 60, writePerMin: 1000 },
755
828
  cors: { writeAllowedOrigins: ["*"] as string[] },
756
829
  abuse: {
757
830
  captureLeadDailyCapPerIp: 50,
@@ -855,12 +928,20 @@ describe("abuse refusal status is keyed on the typed reason code (#5994)", () =>
855
928
  // the preHandler, is under test.
856
929
  await mcpPost(
857
930
  port,
858
- captureLeadPayload(40, { name: "First", email: "first@example.com" }),
931
+ captureLeadPayload(40, {
932
+ source: "mcp",
933
+ email: "first@example.com",
934
+ payload: { name: "First" },
935
+ }),
859
936
  );
860
937
 
861
938
  const capped = await mcpPost(
862
939
  port,
863
- captureLeadPayload(41, { name: "Second", email: "second@example.com" }),
940
+ captureLeadPayload(41, {
941
+ source: "mcp",
942
+ email: "second@example.com",
943
+ payload: { name: "Second" },
944
+ }),
864
945
  );
865
946
 
866
947
  expect(capped.status).toBe(403);
@@ -57,9 +57,14 @@ describe("read_content ↔ apps/api adapter parity (CONTENT_ADAPTER=mdx)", () =>
57
57
  });
58
58
  const viaApi = await apiAdapter.getPageBySlug("HomePage", "home", locale);
59
59
 
60
- expect(viaMcp).not.toBeNull();
60
+ // #7169: the tool answers the route's document — the frontmatter sits
61
+ // under `page` (a HomePage document's root is `@context`/`@graph`/`page`),
62
+ // and a miss is the not-found body rather than `null`.
63
+ if (!("page" in viaMcp)) {
64
+ throw new Error(`read_content answered ${JSON.stringify(viaMcp)}`);
65
+ }
61
66
  expect(viaApi).not.toBeNull();
62
- expect(viaMcp!.slug).toBe(viaApi!.slug);
63
- expect(viaMcp!.frontmatter.title).toBe(viaApi!.title);
67
+ expect(viaMcp.page.slug).toBe(viaApi!.slug);
68
+ expect(viaMcp.page.title).toBe(viaApi!.title);
64
69
  });
65
70
  });
@@ -12,9 +12,6 @@
12
12
  * - it forwards the caller's resolved `locale` as `defaultLocale`;
13
13
  * - it defaults `contentRoot` to `mcpContentRoot()` when the caller passes
14
14
  * none (#8070 D3; the resolver's order is pinned in `content-root.test.ts`);
15
- * - its MDX-narrowed twin `getMcpMdxContentAdapter()` (which `read_content`
16
- * needs for `getPageSource()`) resolves the same way and raises a named
17
- * error rather than a TypeError when the selected adapter is not MDX;
18
15
  * - on `CONTENT_ADAPTER=mdx` (the default, unset), it passes NO `workspaceId`;
19
16
  * - on `CONTENT_ADAPTER=database`, it reads `mcpPublicWorkspace()` (#5876
20
17
  * spec D2/D4/D5/D6): a resolved workspace forwards `workspaceId` + `prisma`
@@ -34,7 +31,6 @@ import type { ArtifactPrismaClient } from "@working-theory/content-adapters";
34
31
 
35
32
  import {
36
33
  getMcpContentAdapter,
37
- getMcpMdxContentAdapter,
38
34
  mcpContentRoot,
39
35
  } from "../../src/lib/content-adapter.js";
40
36
  import {
@@ -164,24 +160,3 @@ describe("getMcpContentAdapter — CONTENT_ADAPTER=database", () => {
164
160
  await expect(adapter.getPageBySlug("HomePage", "home")).resolves.toBeNull();
165
161
  });
166
162
  });
167
-
168
- describe("getMcpMdxContentAdapter", () => {
169
- beforeEach(() => {
170
- mockResolveContentAdapter.mockReset();
171
- });
172
-
173
- it("returns the resolved adapter when it exposes getPageSource", () => {
174
- const mdxish = { getPageSource: jest.fn() };
175
- mockResolveContentAdapter.mockReturnValue(mdxish);
176
-
177
- expect(getMcpMdxContentAdapter({ contentRoot: "/fixtures/content" })).toBe(
178
- mdxish,
179
- );
180
- });
181
-
182
- it("throws a named error when the resolved adapter is not MDX-capable", () => {
183
- mockResolveContentAdapter.mockReturnValue({ getPageBySlug: jest.fn() });
184
-
185
- expect(() => getMcpMdxContentAdapter()).toThrow(/CONTENT_ADAPTER=mdx/);
186
- });
187
- });
@@ -78,21 +78,36 @@ describe("every content tool — CONTENT_ADAPTER=database, unresolved workspace"
78
78
  expect(stub.artifactVersion.findMany).not.toHaveBeenCalled();
79
79
  }
80
80
 
81
- it("ailkReadContent returns null", async () => {
81
+ // #7167, #7168, #7169 (the operator's decision of 2026-10-19): the three
82
+ // content reads answer their routes' shapes — the MCP not-found body for a
83
+ // missing page, and the routes' empty ItemLists — still with no query.
84
+ it("ailkReadContent returns the not-found body", async () => {
82
85
  const result = await ailkReadContent({ type: "HomePage", slug: "home" });
83
- expect(result).toBeNull();
86
+ expect(result).toEqual({ error: "not_found", message: "Page not found" });
84
87
  expectNoQuery();
85
88
  });
86
89
 
87
- it("ailkListPages returns { pages: [] }", async () => {
90
+ it("ailkListPages returns the empty paginated ItemList", async () => {
88
91
  const result = await ailkListPages({ type: "HomePage" });
89
- expect(result).toEqual({ pages: [] });
92
+ expect(result).toEqual({
93
+ "@context": "https://schema.org",
94
+ "@type": "ItemList",
95
+ type: "HomePage",
96
+ items: [],
97
+ pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 1 },
98
+ });
90
99
  expectNoQuery();
91
100
  });
92
101
 
93
- it("ailkSearchContent returns { results: [] }", async () => {
94
- const result = await ailkSearchContent({ query: "anything" });
95
- expect(result).toEqual({ results: [] });
102
+ it("ailkSearchContent returns the empty ItemList", async () => {
103
+ const result = await ailkSearchContent({ q: "anything" });
104
+ expect(result).toEqual({
105
+ "@context": "https://schema.org",
106
+ "@type": "ItemList",
107
+ query: "anything",
108
+ items: [],
109
+ totalItems: 0,
110
+ });
96
111
  expectNoQuery();
97
112
  });
98
113