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
@@ -13,27 +13,29 @@ export {
13
13
  } from "./purchase.js";
14
14
 
15
15
  export {
16
- listPagesInputSchema,
17
- listPagesOutputSchema,
16
+ contentListParamsSchema,
17
+ contentListQuerySchema,
18
+ contentListResultSchema,
18
19
  ailkListPages,
19
20
  type ListPagesInput,
20
- type ListPagesOutput,
21
+ type ContentListResult,
21
22
  } from "./list_pages.js";
22
23
 
23
24
  export {
24
- readContentInputSchema,
25
- readContentOutputSchema,
25
+ contentPageParamsSchema,
26
+ contentPageQuerySchema,
27
+ contentPageResultSchema,
26
28
  ailkReadContent,
27
29
  type ReadContentInput,
28
- type ReadContentOutput,
30
+ type ContentPageResult,
29
31
  } from "./read_content.js";
30
32
 
31
33
  export {
32
- searchContentInputSchema,
33
- searchContentOutputSchema,
34
+ contentSearchQuerySchema,
35
+ contentSearchResultSchema,
34
36
  ailkSearchContent,
35
37
  type SearchContentInput,
36
- type SearchContentOutput,
38
+ type ContentSearchResult,
37
39
  } from "./search_content.js";
38
40
 
39
41
  export {
@@ -92,28 +94,27 @@ export {
92
94
  type AuditOutput,
93
95
  } from "./audit.js";
94
96
 
97
+ // #7171, #7172, #7173 — the three lead writes take their routes' body
98
+ // exports and share one output schema (the route's 201 body or the tool's
99
+ // error), re-exported once from capture_lead.
95
100
  export {
96
- submitLeadInputSchema,
97
- submitLeadOutputSchema,
101
+ leadSubmitBodySchema,
102
+ leadWriteOutputSchema,
98
103
  ailkCaptureLead,
99
- type SubmitLeadInput,
100
- type SubmitLeadOutput,
104
+ type LeadSubmitBody,
105
+ type LeadWriteOutput,
101
106
  } from "./capture_lead.js";
102
107
 
103
108
  export {
104
- subscribeNewsletterInputSchema,
105
- subscribeNewsletterOutputSchema,
109
+ leadSubscribeBodySchema,
106
110
  ailkSubscribeNewsletter,
107
- type SubscribeNewsletterInput,
108
- type SubscribeNewsletterOutput,
111
+ type LeadSubscribeBody,
109
112
  } from "./subscribe_newsletter.js";
110
113
 
111
114
  export {
112
- requestCallbackInputSchema,
113
- requestCallbackOutputSchema,
115
+ leadCallbackBodySchema,
114
116
  ailkRequestCallback,
115
- type RequestCallbackInput,
116
- type RequestCallbackOutput,
117
+ type LeadCallbackBody,
117
118
  } from "./request_callback.js";
118
119
 
119
120
  export {
@@ -1,29 +1,35 @@
1
1
  /**
2
- * list_pages — returns published content pages, optionally filtered by type.
2
+ * list_pages — returns one page of the published content pages of a type.
3
3
  *
4
4
  * Pure read: resolves its adapter through `getMcpContentAdapter()` — the one
5
- * resolver seam (#5805, ADR 0011), MDX by default.
5
+ * resolver seam (#5805, ADR 0011), MDX by default — and answers through
6
+ * `listPagesItemList` (`@working-theory/content-adapters`).
6
7
  * No auth, no side effects, no network. The adapter reads from the filesystem.
7
8
  *
9
+ * HTTP twin (#7168): `GET /v1/content/:type` in `apps/api` parses the same
10
+ * `contentListParamsSchema` + `contentListQuerySchema` and answers through
11
+ * the same builder — the tool adopted the API, arguments and results: `type`
12
+ * is required, and `page` (default 1) / `pageSize` (default 20, at most 100)
13
+ * pick the window.
14
+ *
8
15
  * Phase 3 S3: optional `locale` parameter. Validated against `readLocales()`
9
16
  * allowlist; invalid or unknown values silently fall back to the project's
10
17
  * default locale (AC-i18n-07). Lists are locale-scoped — pages must exist in
11
18
  * the requested locale's directory to be enumerated.
12
19
  *
13
- * Input: { type?: SchemaOrgType, locale?: string }
14
- * Output: { pages: Array<{ slug, type, title, description? }> }
20
+ * Input: { type: SchemaOrgType, page?: number, pageSize?: number, locale?: string }
21
+ * Output: { "@context", "@type": "ItemList", type, items: ContentPage[],
22
+ * pagination: { page, pageSize, totalItems, totalPages } }
15
23
  * Session/scope: none — read-only public content.
16
24
  */
17
25
 
26
+ import { listPagesItemList } from "@working-theory/content-adapters";
18
27
  import {
19
- SCHEMA_ORG_TYPES,
20
- type SchemaOrgType,
21
- } from "@working-theory/content-adapters";
22
- import {
23
- listPagesInputSchema,
24
- listPagesOutputSchema,
28
+ contentListParamsSchema,
29
+ contentListQuerySchema,
30
+ contentListResultSchema,
31
+ type ContentListResult,
25
32
  type ListPagesInput,
26
- type ListPagesOutput,
27
33
  } from "@working-theory/validation";
28
34
 
29
35
  import {
@@ -32,8 +38,12 @@ import {
32
38
  } from "../lib/content-adapter.js";
33
39
  import { resolveLocale } from "../lib/resolve-locale.js";
34
40
 
35
- export { listPagesInputSchema, listPagesOutputSchema };
36
- export type { ListPagesInput, ListPagesOutput };
41
+ export {
42
+ contentListParamsSchema,
43
+ contentListQuerySchema,
44
+ contentListResultSchema,
45
+ };
46
+ export type { ListPagesInput, ContentListResult };
37
47
 
38
48
  /**
39
49
  * list_pages handler.
@@ -45,30 +55,14 @@ export type { ListPagesInput, ListPagesOutput };
45
55
  export async function ailkListPages(
46
56
  input: ListPagesInput,
47
57
  contentRoot?: string,
48
- ): Promise<ListPagesOutput> {
49
- listPagesInputSchema.parse(input);
58
+ ): Promise<ContentListResult> {
59
+ const { type } = contentListParamsSchema.parse(input);
60
+ const { page, pageSize } = contentListQuerySchema.parse(input);
50
61
 
51
62
  // Default: the one content-root resolver (#8070 D3).
52
63
  const root = contentRoot ?? mcpContentRoot();
53
64
  const locale = resolveLocale(input.locale);
54
65
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
55
66
 
56
- const types: readonly SchemaOrgType[] = input.type
57
- ? [input.type]
58
- : SCHEMA_ORG_TYPES;
59
-
60
- const perType = await Promise.all(
61
- types.map((type) => adapter.listPages(type, locale)),
62
- );
63
-
64
- const pages: ListPagesOutput["pages"] = perType.flatMap((results) =>
65
- results.map((page) => ({
66
- slug: page.slug,
67
- type: page.type,
68
- title: page.title,
69
- ...(page.description !== undefined && { description: page.description }),
70
- })),
71
- );
72
-
73
- return listPagesOutputSchema.parse({ pages });
67
+ return listPagesItemList(adapter, { type, page, pageSize, locale });
74
68
  }
@@ -1,40 +1,58 @@
1
1
  /**
2
- * read_content — returns a single page's frontmatter + raw MDX body.
2
+ * read_content — returns a single page's document: its schema.org JSON-LD at
3
+ * the root, its frontmatter under `page`, and the raw MDX body on request.
3
4
  *
4
- * Pure read: resolves its adapter through `getMcpMdxContentAdapter()` — the
5
- * MDX-narrowed twin of the one resolver seam (#5805, ADR 0011; needed here
6
- * for `getPageSource()`'s raw body, which the base `ContentAdapter` contract
7
- * doesn't carry), MDX by default.
8
- * Composes getPageBySlug (frontmatter) + getPageSource (body) into a single
9
- * output shape. Returns null when the slug is not found — never throws for
10
- * a missing page.
5
+ * Pure read: resolves its adapter through `getMcpContentAdapter()` — the one
6
+ * resolver seam (#5805, ADR 0011), MDX by default — and answers through
7
+ * `readContentDocument` (`@working-theory/content-adapters`), over the
8
+ * deployment's `_site.mdx` (`lib/site-config.ts`). A missing page is the MCP
9
+ * not-found body (`lib/errors.ts`), carrying the route's 404 message — never
10
+ * a throw.
11
+ *
12
+ * HTTP twin (#7169): `GET /v1/content/:type/:slug` in `apps/api` parses the
13
+ * same `contentPageParamsSchema` + `contentPageQuerySchema` and answers
14
+ * through the same builder — the tool adopted the API, arguments and results:
15
+ * `body` is served only with `include: "body"`, from ONE `getPageSource`
16
+ * read (#5927). The builder's own capability check replaces the retired
17
+ * MDX-narrowed resolver twin this tool used to call: every adapter the
18
+ * resolver returns carries `getPageSource`, the fail-closed empty one
19
+ * included (#5876 D5).
11
20
  *
12
21
  * Phase 3 S3: optional `locale` parameter. Validated against `readLocales()`
13
22
  * allowlist; invalid or unknown values silently fall back to the project's
14
23
  * default locale (AC-i18n-07). The adapter applies its own fallback chain.
15
24
  *
16
- * Input: { type: SchemaOrgType, slug: string, locale?: string } — `type`
17
- * admits the full SchemaOrgType set (#6030 D1), mirrored in
18
- * @working-theory/validation's MCP_SCHEMA_ORG_TYPES.
19
- * Output: { slug, type, frontmatter, body } | null
25
+ * Input: { type: SchemaOrgType, slug: string, locale?: string,
26
+ * include?: "body" } — `type` admits the full SchemaOrgType set
27
+ * (#6030 D1), mirrored in @working-theory/validation's
28
+ * MCP_SCHEMA_ORG_TYPES.
29
+ * Output: { ...jsonLd, page, body? } | { error: "not_found", message }
20
30
  * Session/scope: none — read-only public content.
21
31
  */
22
32
 
33
+ import { readContentDocument } from "@working-theory/content-adapters";
23
34
  import {
24
- readContentInputSchema,
25
- readContentOutputSchema,
35
+ contentPageParamsSchema,
36
+ contentPageQuerySchema,
37
+ contentPageResultSchema,
38
+ type ContentPageResult,
26
39
  type ReadContentInput,
27
- type ReadContentOutput,
28
40
  } from "@working-theory/validation";
29
41
 
30
42
  import {
31
- getMcpMdxContentAdapter,
43
+ getMcpContentAdapter,
32
44
  mcpContentRoot,
33
45
  } from "../lib/content-adapter.js";
46
+ import { notFoundError, type ErrorBody } from "../lib/errors.js";
34
47
  import { resolveLocale } from "../lib/resolve-locale.js";
48
+ import { readSiteConfig } from "../lib/site-config.js";
35
49
 
36
- export { readContentInputSchema, readContentOutputSchema };
37
- export type { ReadContentInput, ReadContentOutput };
50
+ export {
51
+ contentPageParamsSchema,
52
+ contentPageQuerySchema,
53
+ contentPageResultSchema,
54
+ };
55
+ export type { ReadContentInput, ContentPageResult };
38
56
 
39
57
  /**
40
58
  * read_content handler.
@@ -45,31 +63,19 @@ export type { ReadContentInput, ReadContentOutput };
45
63
  export async function ailkReadContent(
46
64
  input: ReadContentInput,
47
65
  contentRoot?: string,
48
- ): Promise<ReadContentOutput> {
49
- readContentInputSchema.parse(input);
66
+ ): Promise<ContentPageResult | ErrorBody> {
67
+ const { type, slug } = contentPageParamsSchema.parse(input);
68
+ const { include } = contentPageQuerySchema.parse(input);
50
69
 
51
70
  // Default: the one content-root resolver (#8070 D3).
52
71
  const root = contentRoot ?? mcpContentRoot();
53
72
  const locale = resolveLocale(input.locale);
73
+ const adapter = getMcpContentAdapter({ contentRoot: root, locale });
54
74
 
55
- const adapter = getMcpMdxContentAdapter({ contentRoot: root, locale });
56
-
57
- const source = await adapter.getPageSource(input.type, input.slug, locale);
58
- if (source === null) return null;
59
-
60
- return readContentOutputSchema.parse({
61
- // Prefer the stored frontmatter's slug/type; fall back to the
62
- // request-validated input (#5876 spec D9). On the MDX path the
63
- // frontmatter always carries both — required, non-optional fields of
64
- // FrontmatterSchema — so this is inert there and the mdx payload stays
65
- // byte-identical. The fallback matters for cross-adapter identity: the
66
- // Epic #5324 AC-WCE-04 comparison needs `read_content`'s payload to be
67
- // identical across adapters, which a request-validated value guarantees
68
- // and a stored-bookkeeping value only guarantees if the write path
69
- // agrees.
70
- slug: source.frontmatter.slug ?? input.slug,
71
- type: source.frontmatter.type ?? input.type,
72
- frontmatter: source.frontmatter as Record<string, unknown>,
73
- body: source.body,
74
- });
75
+ const document = await readContentDocument(
76
+ adapter,
77
+ { type, slug, locale, include },
78
+ readSiteConfig(root),
79
+ );
80
+ return document ?? notFoundError("Page not found");
75
81
  }
@@ -1,32 +1,35 @@
1
1
  /**
2
- * request_callback — schedule a callback request.
2
+ * request_callback — request a callback: the body `POST /v1/leads/callback`
3
+ * takes.
3
4
  *
4
- * Validates input via Zod, forwards to POST /api/leads/callback via
5
- * @working-theory/api-client, and shapes the response into the MCP output schema.
6
- * No business logic — orchestration lives in apps/api B6.
5
+ * The tool adopted the API, arguments and results (#7173 — the operator's
6
+ * decision of 2026-10-19). Its input IS the route's `leadCallbackBodySchema`
7
+ * (`{ name, phone, preferredTime?, payload? }`; the catalog registers the
8
+ * same shape, so the SDK has already parsed the arguments against it — the
9
+ * `safeParse` here keeps the stdio path and the `invalid_input` branch
10
+ * honest), the parsed body is posted as the agent sent it — no `siteId`, no
11
+ * stamped `payload: {}` — and the route's 201 body `{ id, persisted, email }`
12
+ * comes back unchanged. No business logic — orchestration lives in apps/api.
7
13
  *
8
- * Input: { name, phone, preferredTime? }
9
- * Output: { ok: true, id, persisted } | { ok: false, error: { kind, ... } }
10
14
  * Session/scope: none — anonymous write, no auth required.
11
15
  */
12
16
 
13
17
  import { apiClient } from "@working-theory/api-client";
14
18
  import {
15
- requestCallbackInputSchema,
16
- requestCallbackOutputSchema,
17
- type RequestCallbackInput,
18
- type RequestCallbackOutput,
19
+ leadCallbackBodySchema,
20
+ type LeadCallbackBody,
21
+ type LeadWriteOutput,
19
22
  } from "@working-theory/validation";
20
23
 
21
24
  import { visitorForwardHeadersForThisCall } from "../request-context.js";
22
25
 
23
- export { requestCallbackInputSchema, requestCallbackOutputSchema };
24
- export type { RequestCallbackInput, RequestCallbackOutput };
26
+ export { leadCallbackBodySchema };
27
+ export type { LeadCallbackBody };
25
28
 
26
29
  export async function ailkRequestCallback(
27
- input: RequestCallbackInput,
28
- ): Promise<RequestCallbackOutput> {
29
- const parsed = requestCallbackInputSchema.safeParse(input);
30
+ input: LeadCallbackBody,
31
+ ): Promise<LeadWriteOutput> {
32
+ const parsed = leadCallbackBodySchema.safeParse(input);
30
33
  if (!parsed.success) {
31
34
  return {
32
35
  ok: false,
@@ -34,26 +37,16 @@ export async function ailkRequestCallback(
34
37
  };
35
38
  }
36
39
 
37
- const { name, phone, preferredTime } = parsed.data;
38
- const siteId = process.env.AILK_SITE_ID ?? "default";
39
- const body = {
40
- siteId,
41
- name,
42
- phone,
43
- ...(preferredTime !== undefined && { preferredTime }),
44
- payload: {},
45
- };
46
-
47
40
  // #8073 — forward the address the transport trusted (never an identity
48
41
  // argument on this tool's own schema) so the hub counts the write in that
49
42
  // visitor's own rate-limit bucket rather than this MCP host's.
50
43
  const headers = visitorForwardHeadersForThisCall();
51
44
  const res = headers
52
- ? await apiClient.leads.callback(body, { headers })
53
- : await apiClient.leads.callback(body);
45
+ ? await apiClient.leads.callback(parsed.data, { headers })
46
+ : await apiClient.leads.callback(parsed.data);
54
47
  if (!res.ok) {
55
48
  return { ok: false, error: { kind: "api_error", status: res.status } };
56
49
  }
57
50
 
58
- return { ok: true, id: res.body.id, persisted: res.body.persisted };
51
+ return res.body;
59
52
  }
@@ -2,9 +2,14 @@
2
2
  * search_content — full-text search across published content pages.
3
3
  *
4
4
  * Pure read: resolves its adapter through `getMcpContentAdapter()` — the one
5
- * resolver seam (#5805, ADR 0011), MDX by default.
6
- * Delegates to adapter.searchPages which scores by title/description/body match.
7
- * Limit defaults to 10; max 50. Query capped at 200 chars (enforced by Zod).
5
+ * resolver seam (#5805, ADR 0011), MDX by default — and answers through
6
+ * `searchContentItemList` (`@working-theory/content-adapters`), which scores by
7
+ * title/description/body match through `adapter.searchPages`.
8
+ *
9
+ * HTTP twin (#7167): `GET /v1/content/search` in `apps/api` parses the same
10
+ * `contentSearchQuerySchema` and answers through the same builder — the tool
11
+ * adopted the API, arguments and results. Limit defaults to 10; max 50. The
12
+ * search string `q` is capped at 200 chars (enforced by Zod).
8
13
  *
9
14
  * Phase 3 S3: optional `locale` parameter. Search is locale-scoped — only the
10
15
  * requested locale's content directory is searched. Invalid or unknown values
@@ -14,17 +19,19 @@
14
19
  * registry. Unlike `locale`, an unrecognised value is a validation error, not
15
20
  * a silent fallback: the registry is closed, so a bad slug is a caller mistake.
16
21
  *
17
- * Input: { query: string, type?: SchemaOrgType, category?: CategorySlug,
22
+ * Input: { q: string, type?: SchemaOrgType, category?: CategorySlug,
18
23
  * limit?: number, locale?: string }
19
- * Output: { results: Array<{ slug, type, title, snippet, score }> }
24
+ * Output: { "@context", "@type": "ItemList", query, items: Array<{ slug, type,
25
+ * title, snippet, score }>, totalItems }
20
26
  * Session/scope: none — read-only public content.
21
27
  */
22
28
 
29
+ import { searchContentItemList } from "@working-theory/content-adapters";
23
30
  import {
24
- searchContentInputSchema,
25
- searchContentOutputSchema,
31
+ contentSearchQuerySchema,
32
+ contentSearchResultSchema,
33
+ type ContentSearchResult,
26
34
  type SearchContentInput,
27
- type SearchContentOutput,
28
35
  } from "@working-theory/validation";
29
36
 
30
37
  import {
@@ -33,10 +40,8 @@ import {
33
40
  } from "../lib/content-adapter.js";
34
41
  import { resolveLocale } from "../lib/resolve-locale.js";
35
42
 
36
- export { searchContentInputSchema, searchContentOutputSchema };
37
- export type { SearchContentInput, SearchContentOutput };
38
-
39
- const DEFAULT_LIMIT = 10;
43
+ export { contentSearchQuerySchema, contentSearchResultSchema };
44
+ export type { SearchContentInput, ContentSearchResult };
40
45
 
41
46
  /**
42
47
  * search_content handler.
@@ -47,21 +52,12 @@ const DEFAULT_LIMIT = 10;
47
52
  export async function ailkSearchContent(
48
53
  input: SearchContentInput,
49
54
  contentRoot?: string,
50
- ): Promise<SearchContentOutput> {
51
- searchContentInputSchema.parse(input);
55
+ ): Promise<ContentSearchResult> {
56
+ const { q, type, category, limit } = contentSearchQuerySchema.parse(input);
52
57
 
53
58
  const root = contentRoot ?? mcpContentRoot();
54
59
  const locale = resolveLocale(input.locale);
55
60
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
56
61
 
57
- const options = {
58
- limit: input.limit ?? DEFAULT_LIMIT,
59
- ...(input.type !== undefined && { type: input.type }),
60
- ...(input.category !== undefined && { category: input.category }),
61
- locale,
62
- };
63
-
64
- const rawResults = await adapter.searchPages(input.query, options);
65
-
66
- return searchContentOutputSchema.parse({ results: rawResults });
62
+ return searchContentItemList(adapter, { q, type, category, limit, locale });
67
63
  }
@@ -1,32 +1,37 @@
1
1
  /**
2
- * subscribe_newsletter — add an email to the newsletter list.
2
+ * subscribe_newsletter — add an email to the newsletter list: the body
3
+ * `POST /v1/leads/subscribe` takes.
3
4
  *
4
- * Validates input via Zod, forwards to POST /api/leads/subscribe via
5
- * @working-theory/api-client, and shapes the response into the MCP output schema.
6
- * No business logic — orchestration lives in apps/api B6.
5
+ * The tool adopted the API, arguments and results (#7172 — the operator's
6
+ * decision of 2026-10-19). Its input IS the route's `leadSubscribeBodySchema`
7
+ * (`{ email, source }`; the catalog registers the same shape, so the SDK has
8
+ * already parsed the arguments against it — the `safeParse` here keeps the
9
+ * stdio path and the `invalid_input` branch honest), the parsed body is
10
+ * posted as the agent sent it — no `siteId`, no stamped `source`, and no
11
+ * `firstName`, which never reached the wire — and the route's 201 body
12
+ * `{ id, persisted, email }` comes back unchanged. No spoke marker: a
13
+ * Subscriber row has no payload to carry it. No business logic —
14
+ * orchestration lives in apps/api.
7
15
  *
8
- * Input: { email, firstName? }
9
- * Output: { ok: true, id, persisted } | { ok: false, error: { kind, ... } }
10
16
  * Session/scope: none — anonymous write, no auth required.
11
17
  */
12
18
 
13
19
  import { apiClient } from "@working-theory/api-client";
14
20
  import {
15
- subscribeNewsletterInputSchema,
16
- subscribeNewsletterOutputSchema,
17
- type SubscribeNewsletterInput,
18
- type SubscribeNewsletterOutput,
21
+ leadSubscribeBodySchema,
22
+ type LeadSubscribeBody,
23
+ type LeadWriteOutput,
19
24
  } from "@working-theory/validation";
20
25
 
21
26
  import { visitorForwardHeadersForThisCall } from "../request-context.js";
22
27
 
23
- export { subscribeNewsletterInputSchema, subscribeNewsletterOutputSchema };
24
- export type { SubscribeNewsletterInput, SubscribeNewsletterOutput };
28
+ export { leadSubscribeBodySchema };
29
+ export type { LeadSubscribeBody };
25
30
 
26
31
  export async function ailkSubscribeNewsletter(
27
- input: SubscribeNewsletterInput,
28
- ): Promise<SubscribeNewsletterOutput> {
29
- const parsed = subscribeNewsletterInputSchema.safeParse(input);
32
+ input: LeadSubscribeBody,
33
+ ): Promise<LeadWriteOutput> {
34
+ const parsed = leadSubscribeBodySchema.safeParse(input);
30
35
  if (!parsed.success) {
31
36
  return {
32
37
  ok: false,
@@ -34,24 +39,16 @@ export async function ailkSubscribeNewsletter(
34
39
  };
35
40
  }
36
41
 
37
- const { email } = parsed.data;
38
- // firstName is not part of the canonical subscribeSchema (no payload field);
39
- // it is accepted by the MCP input but dropped here for now. The #7397 spoke
40
- // marker (LEAD_SITE_MARKER_KEY) is absent for the same reason: a Subscriber
41
- // row has no payload column, so subscribe has nowhere to carry it.
42
- const siteId = process.env.AILK_SITE_ID ?? "default";
43
- const body = { siteId, email, source: "mcp" };
44
-
45
42
  // #8073 — forward the address the transport trusted (never an identity
46
43
  // argument on this tool's own schema) so the hub counts the write in that
47
44
  // visitor's own rate-limit bucket rather than this MCP host's.
48
45
  const headers = visitorForwardHeadersForThisCall();
49
46
  const res = headers
50
- ? await apiClient.leads.subscribe(body, { headers })
51
- : await apiClient.leads.subscribe(body);
47
+ ? await apiClient.leads.subscribe(parsed.data, { headers })
48
+ : await apiClient.leads.subscribe(parsed.data);
52
49
  if (!res.ok) {
53
50
  return { ok: false, error: { kind: "api_error", status: res.status } };
54
51
  }
55
52
 
56
- return { ok: true, id: res.body.id, persisted: res.body.persisted };
53
+ return res.body;
57
54
  }
@@ -345,8 +345,9 @@ Every multi-step magnet is a page pair (#8840), rendered by one module,
345
345
  - **`/<magnet>/start`** — the walk, `noindex, follow`, full-screen with the
346
346
  back arrow alone (`NavBackLink`) above step 1. `lib/magnet-routes.ts`'s
347
347
  `isMagnetWalkPath` is what the locale layout drops the Nav and Footer on:
348
- any path ending in `start`, plus `/careers/apply` and
349
- `/careers/<posting>/apply`. The cookie consent banner stays.
348
+ any path ending in `start`, plus `/careers/apply`,
349
+ `/careers/<posting>/apply` and `/trial/request`. The cookie consent banner
350
+ stays.
350
351
  - **The ending.** A flow with an `ending` ends on it, never on `thankYou`:
351
352
  `FlowStepperClient` picks the outcome from the walk's answers
352
353
  (`selectFlowEndingOutcome`) — a screen in the finished card with `{name}`
@@ -362,6 +363,12 @@ Every multi-step magnet is a page pair (#8840), rendered by one module,
362
363
  - **The pairs.** `/flows/<slug>` is the canonical pair. `/careers/apply` and
363
364
  `/careers/<posting>/apply` walk the Job application magnet (the second
364
365
  titles step 1 with the posting and sends its slug as the hidden `job`).
366
+ `/trial` and `/trial/request` are the trial magnet's pair (#9287): the
367
+ landing passes `renderMagnetLanding` a `startPath`, so its one button
368
+ (the magnet's `landing.cta`, "Request a free trial") goes to
369
+ `/trial/request` rather than `/trial/start`, and the walk passes a
370
+ `title`, so an absent flow renders the contact email under its own `<h1>`,
371
+ as `/careers/apply` does. Both are `signal-collectors` slice files.
365
372
  Any other pair is written by `create-ailk add-route magnet --path <path>
366
373
  --flow <slug>`: two short pages carrying the `Standard magnet page
367
374
  (#8840)` marker, which `lib/__tests__/structured-data.test.tsx` exempts
@@ -539,7 +546,9 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
539
546
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
540
547
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
541
548
 
542
- Last updated: 2026-10-19 — § Magnet pages: `/careers/apply` renders the contact email under its own `<h1>` when its flow is absent or the hub cannot answer; `readMagnet`'s `not-configured` kind 404s an unknown `/reports/<slug>` (and its `/start`) on a site with no hub to ask, while a configured hub that is down keeps #8848 D6's contact email (#9219) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/careers/apply/page.tsx`, `app/[locale]/reports/deliverable-page.tsx` and `app/[locale]/reports/[slug]/{page,start/page}.tsx`.
549
+ Last updated: 2026-10-20 — § Magnet pages: the trial magnet's pair, `/trial` (its button to `/trial/request` through `renderMagnetLanding`'s `startPath`) and the chrome-free `/trial/request` walk with its own `<h1>` fallback (#9287 C2, DD10) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/trial/{page,request/page}.tsx` and `lib/magnet-routes.ts`.
550
+
551
+ Prior: 2026-10-19 — § Magnet pages: `/careers/apply` renders the contact email under its own `<h1>` when its flow is absent or the hub cannot answer; `readMagnet`'s `not-configured` kind 404s an unknown `/reports/<slug>` (and its `/start`) on a site with no hub to ask, while a configured hub that is down keeps #8848 D6's contact email (#9219) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/careers/apply/page.tsx`, `app/[locale]/reports/deliverable-page.tsx` and `app/[locale]/reports/[slug]/{page,start/page}.tsx`.
543
552
 
544
553
  Prior: 2026-10-18 — § Spoke checkout: the Offerings buy route is the one selling path, and the legacy env-key buy route, webhook route, `lib/checkout.ts` and checkout e2e workflow are retired (#9142) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts`, `app/[locale]/checkout/*/page.tsx`, `lib/checkout-config.ts` and create-ailk's `spoke-checkout.ts` and `sync-routes.ts`.
545
554
 
@@ -7,7 +7,7 @@
7
7
  * boundary is mocked (`@working-theory/api-client`'s `leads.submit`) —
8
8
  * the REAL `ConversationalIntake` block (#4543) and its engine (#4532),
9
9
  * the REAL `captureLeadAction` (the SAME action the shared
10
- * `LeadCaptureForm` on `/contact` uses), and the REAL `submitLeadInputSchema` validation all run, proving
10
+ * `LeadCaptureForm` on `/contact` uses), and the REAL `contactFormSchema` validation all run, proving
11
11
  * the whole chain end to end. No file under apps/api is imported, mocked,
12
12
  * or touched by this test or by the wiring it exercises.
13
13
  * AC2 — Skip renders the existing form, carrying over answers already
@@ -3,8 +3,10 @@
3
3
  /**
4
4
  * captureLeadAction — Server Action paired with ailkCaptureLead MCP tool.
5
5
  *
6
- * Validates contact-form input with submitLeadInputSchema (shared schema per
7
- * ADR 0013), calls POST /v1/leads/submit via @working-theory/api-client, and returns
6
+ * Validates contact-form input with its own `contactFormSchema` (the form's
7
+ * field set is the page's, not a wire contract — the `capture_lead` MCP tool
8
+ * takes the route body since #7171), calls POST /v1/leads/submit via
9
+ * @working-theory/api-client, and returns
8
10
  * the shared `LeadCaptureForm` block's `LeadCaptureFormState`.
9
11
  *
10
12
  * Canonical filename: contact.actions.ts (*.actions.ts per ADR 0013 / U0 spec
@@ -102,11 +104,11 @@ import {
102
104
  CONTACT_LEAD_SOURCES,
103
105
  flowCheckoutOfferingSchema,
104
106
  leadSubmitNextSchema,
105
- submitLeadInputSchema,
106
107
  } from "@working-theory/validation";
107
108
  import { cookies, headers } from "next/headers";
108
109
  import { redirect } from "next/navigation";
109
110
  import { getLocale, getTranslations } from "next-intl/server";
111
+ import { z } from "zod";
110
112
 
111
113
  import { accountInviteHref } from "../../../lib/account-invite";
112
114
  import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
@@ -139,6 +141,14 @@ const DEFAULT_SOURCE = "contact-form";
139
141
  const CONSENT_REQUIRED = "Please accept the terms to send your message.";
140
142
  const MAX_EXTRA_FIELD_LENGTH = 2000;
141
143
 
144
+ /** The form's four fields and bounds; each field error is zod's own message. */
145
+ const contactFormSchema = z.object({
146
+ name: z.string().min(1).max(200),
147
+ email: z.string().email(),
148
+ message: z.string().max(2000).optional(),
149
+ phone: z.string().max(50).optional(),
150
+ });
151
+
142
152
  /**
143
153
  * The declared extra fields, or `null` when they exceed the bounds. Non-string
144
154
  * entries (files) and Next's own `$ACTION_…` keys are skipped.
@@ -253,7 +263,7 @@ export async function captureLeadAction(
253
263
  fieldErrors.consent = CONSENT_REQUIRED;
254
264
  }
255
265
 
256
- const parsed = submitLeadInputSchema.safeParse(raw);
266
+ const parsed = contactFormSchema.safeParse(raw);
257
267
  if (!parsed.success) {
258
268
  for (const issue of parsed.error.issues) {
259
269
  const field = String(issue.path[0] ?? "form");
@@ -4,7 +4,7 @@
4
4
  * leg).
5
5
  *
6
6
  * Three open questions, deliberately keyed `name` / `message` / `email` —
7
- * the SAME field names `submitLeadInputSchema` (packages/validation)
7
+ * the SAME field names `contactFormSchema` (`./contact.actions.ts`)
8
8
  * expects. `ConversationalIntakeWrapper` reads `IntakeEngineState.answers`
9
9
  * by these exact ids to build the `FormData` it hands to the existing
10
10
  * `captureLeadAction` (`./contact.actions.ts`), so the script's question