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.
- package/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- 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
|
-
|
|
17
|
-
|
|
16
|
+
contentListParamsSchema,
|
|
17
|
+
contentListQuerySchema,
|
|
18
|
+
contentListResultSchema,
|
|
18
19
|
ailkListPages,
|
|
19
20
|
type ListPagesInput,
|
|
20
|
-
type
|
|
21
|
+
type ContentListResult,
|
|
21
22
|
} from "./list_pages.js";
|
|
22
23
|
|
|
23
24
|
export {
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
contentPageParamsSchema,
|
|
26
|
+
contentPageQuerySchema,
|
|
27
|
+
contentPageResultSchema,
|
|
26
28
|
ailkReadContent,
|
|
27
29
|
type ReadContentInput,
|
|
28
|
-
type
|
|
30
|
+
type ContentPageResult,
|
|
29
31
|
} from "./read_content.js";
|
|
30
32
|
|
|
31
33
|
export {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
contentSearchQuerySchema,
|
|
35
|
+
contentSearchResultSchema,
|
|
34
36
|
ailkSearchContent,
|
|
35
37
|
type SearchContentInput,
|
|
36
|
-
type
|
|
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
|
-
|
|
97
|
-
|
|
101
|
+
leadSubmitBodySchema,
|
|
102
|
+
leadWriteOutputSchema,
|
|
98
103
|
ailkCaptureLead,
|
|
99
|
-
type
|
|
100
|
-
type
|
|
104
|
+
type LeadSubmitBody,
|
|
105
|
+
type LeadWriteOutput,
|
|
101
106
|
} from "./capture_lead.js";
|
|
102
107
|
|
|
103
108
|
export {
|
|
104
|
-
|
|
105
|
-
subscribeNewsletterOutputSchema,
|
|
109
|
+
leadSubscribeBodySchema,
|
|
106
110
|
ailkSubscribeNewsletter,
|
|
107
|
-
type
|
|
108
|
-
type SubscribeNewsletterOutput,
|
|
111
|
+
type LeadSubscribeBody,
|
|
109
112
|
} from "./subscribe_newsletter.js";
|
|
110
113
|
|
|
111
114
|
export {
|
|
112
|
-
|
|
113
|
-
requestCallbackOutputSchema,
|
|
115
|
+
leadCallbackBodySchema,
|
|
114
116
|
ailkRequestCallback,
|
|
115
|
-
type
|
|
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
|
|
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
|
|
14
|
-
* Output: {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
36
|
-
|
|
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<
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
* @working-theory/validation's
|
|
19
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 {
|
|
37
|
-
|
|
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<
|
|
49
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
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 —
|
|
2
|
+
* request_callback — request a callback: the body `POST /v1/leads/callback`
|
|
3
|
+
* takes.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
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 {
|
|
24
|
-
export type {
|
|
26
|
+
export { leadCallbackBodySchema };
|
|
27
|
+
export type { LeadCallbackBody };
|
|
25
28
|
|
|
26
29
|
export async function ailkRequestCallback(
|
|
27
|
-
input:
|
|
28
|
-
): Promise<
|
|
29
|
-
const parsed =
|
|
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(
|
|
53
|
-
: await apiClient.leads.callback(
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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: {
|
|
22
|
+
* Input: { q: string, type?: SchemaOrgType, category?: CategorySlug,
|
|
18
23
|
* limit?: number, locale?: string }
|
|
19
|
-
* Output: {
|
|
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
|
-
|
|
25
|
-
|
|
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 {
|
|
37
|
-
export type { SearchContentInput,
|
|
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<
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
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 {
|
|
24
|
-
export type {
|
|
28
|
+
export { leadSubscribeBodySchema };
|
|
29
|
+
export type { LeadSubscribeBody };
|
|
25
30
|
|
|
26
31
|
export async function ailkSubscribeNewsletter(
|
|
27
|
-
input:
|
|
28
|
-
): Promise<
|
|
29
|
-
const parsed =
|
|
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(
|
|
51
|
-
: await apiClient.leads.subscribe(
|
|
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
|
|
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
|
|
349
|
-
`/careers/<posting>/apply`. The cookie consent banner
|
|
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-
|
|
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
|
|
package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx
CHANGED
|
@@ -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 `
|
|
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
|
|
7
|
-
*
|
|
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 =
|
|
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 `
|
|
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
|