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
@@ -86,6 +86,9 @@ const mockClientForOrganization = jest.fn();
86
86
  jest.mock("../../../services/offerings/connection.js", () => ({
87
87
  getStripeConnectionSummary: (...args: unknown[]) =>
88
88
  mockConnectionSummary(...args),
89
+ // The connection state Offerings sells on (#9285): the stub answers both.
90
+ getOfferingsConnectionState: (...args: unknown[]) =>
91
+ mockConnectionSummary(...args),
89
92
  stripeClientForOrganization: (...args: unknown[]) =>
90
93
  mockClientForOrganization(...args),
91
94
  }));
@@ -209,6 +212,8 @@ const SECRETS = [
209
212
  "Growth_Monthly",
210
213
  "growth_pro_monthly",
211
214
  "wendi_retainer",
215
+ "ailk-pro-plus_monthly",
216
+ "ailk-pro_yearly",
212
217
  PROD_A.id,
213
218
  PROD_B.id,
214
219
  PROD_C.id,
@@ -451,6 +456,47 @@ describe("AC-1 — every miss is one 404 that never echoes the key", () => {
451
456
  });
452
457
  });
453
458
 
459
+ // ── #9338 DD5: the Offerings path never sells a hub plan ───────────────────
460
+
461
+ describe("#9338 DD5 (AC-5) — the anonymous Offerings path refuses the hub's plans", () => {
462
+ const PROD_HUB = product("prod_Hub9338");
463
+ const HUB_PP_MONTHLY = recurringPrice("price_Hub9338ppm", "ailk-pro-plus_monthly", PROD_HUB);
464
+ const HUB_PRO_YEARLY = {
465
+ ...recurringPrice("price_Hub9338proy", "ailk-pro_yearly", PROD_HUB),
466
+ recurring: { interval: "year", interval_count: 1, usage_type: "licensed" },
467
+ };
468
+
469
+ beforeEach(() => {
470
+ // The hub Product is enabled AND shown, so only the guard can refuse it.
471
+ givenRows([PROD_A.id, PROD_HUB.id], [PROD_A.id, PROD_HUB.id]);
472
+ client = fakeClient({
473
+ growth_monthly: [PRICE_A],
474
+ "ailk-pro-plus_monthly": [HUB_PP_MONTHLY],
475
+ "ailk-pro_yearly": [HUB_PRO_YEARLY],
476
+ });
477
+ });
478
+
479
+ it.each(["ailk-pro-plus_monthly", "ailk-pro_yearly"])(
480
+ "%s, shown and enabled, answers the one 404 before any read or Stripe call",
481
+ async (key) => {
482
+ const res = await post({ ...BODY, productCode: key });
483
+ expect(res.statusCode).toBe(404);
484
+ expect(res.body).toBe(JSON.stringify({ error: "unknown-product-code" }));
485
+ expect(mockClientForOrganization).not.toHaveBeenCalled();
486
+ expect(mockReadEnabledProductIds).not.toHaveBeenCalled();
487
+ expect(mockReadWorkspaceSelection).not.toHaveBeenCalled();
488
+ expect(client.prices.list).not.toHaveBeenCalled();
489
+ expect(client.checkout.sessions.create).not.toHaveBeenCalled();
490
+ expect(mockRecordCheckoutSession).not.toHaveBeenCalled();
491
+ },
492
+ );
493
+
494
+ it("growth_monthly from the same fixtures still sells", async () => {
495
+ const res = await post();
496
+ expect(res.statusCode).toBe(201);
497
+ });
498
+ });
499
+
454
500
  // ── AC-2: the stamped, anonymous, recorded session (A6, A7, A11) ────────────
455
501
 
456
502
  describe("#9070 D5 (AC-5) — a workspace saved per Price sells only its shown Prices", () => {
@@ -698,6 +744,24 @@ describe("AC-3 (A9) — the env path, composed with the real stripe-client and m
698
744
  expect(mockChargeWriteBudget).not.toHaveBeenCalled();
699
745
  });
700
746
 
747
+ it("the re-keyed Pro Plus (ailk-pro-plus_monthly, ailk_group plan) answers the 404 (#9338 DD5)", async () => {
748
+ mockEnvPricesList.mockResolvedValue({
749
+ data: [
750
+ {
751
+ id: "price_EnvPP",
752
+ type: "recurring",
753
+ product: { id: "prod_EnvPP", metadata: { ailk_group: "plan" } },
754
+ },
755
+ ],
756
+ });
757
+
758
+ const res = await post({ ...BODY, productCode: "ailk-pro-plus_monthly" });
759
+
760
+ expect(res.statusCode).toBe(404);
761
+ expect(res.json()).toEqual({ error: "unknown-product-code" });
762
+ expect(mockEnvSessionsCreate).not.toHaveBeenCalled();
763
+ });
764
+
701
765
  it("a Product with no ailk_group still sells on the env path", async () => {
702
766
  mockEnvPricesList.mockResolvedValue({
703
767
  data: [
@@ -1298,3 +1362,65 @@ describe("AC-6 — what the logs and the bodies never carry", () => {
1298
1362
  }
1299
1363
  });
1300
1364
  });
1365
+
1366
+ // ── #9285: an Organization selling through the environment key ──────────────
1367
+
1368
+ describe("#9296 — an Organization on the environment key never takes the Offerings path", () => {
1369
+ const SPOKE = { "x-ailk-spoke-key": "spoke-key-9285" };
1370
+
1371
+ beforeEach(() => {
1372
+ mockConnectionSummary.mockResolvedValue("environment");
1373
+ });
1374
+
1375
+ it("keeps the env path even when the workspace has a saved selection", async () => {
1376
+ // The default fixtures give the workspace a selection (givenRows).
1377
+ const res = await post();
1378
+
1379
+ expect(res.statusCode).toBe(500);
1380
+ expect(res.json()).toEqual({ error: "stripe-not-configured" });
1381
+ expect(mockClientForOrganization).not.toHaveBeenCalled();
1382
+ expect(mockRecordCheckoutSession).not.toHaveBeenCalled();
1383
+ });
1384
+
1385
+ it("keeps the env path while the workspace has no selection", async () => {
1386
+ mockReadWorkspaceSelection.mockResolvedValue(null);
1387
+
1388
+ const res = await post();
1389
+
1390
+ expect(res.statusCode).toBe(500);
1391
+ expect(res.json()).toEqual({ error: "stripe-not-configured" });
1392
+ expect(mockClientForOrganization).not.toHaveBeenCalled();
1393
+ expect(mockRecordCheckoutSession).not.toHaveBeenCalled();
1394
+ });
1395
+
1396
+ it("refuses a spoke's workspace as unconnected, selection or not", async () => {
1397
+ const res = await post(BODY, SPOKE);
1398
+
1399
+ expect(res.statusCode).toBe(404);
1400
+ expect(res.json()).toEqual({ error: "unknown-product-code" });
1401
+ expect(mockClientForOrganization).not.toHaveBeenCalled();
1402
+ });
1403
+
1404
+ it("still takes the Offerings path for a stored connection", async () => {
1405
+ mockConnectionSummary.mockResolvedValue(SUMMARY);
1406
+
1407
+ const res = await post();
1408
+
1409
+ expect(res.statusCode).toBe(201);
1410
+ expect(mockClientForOrganization).toHaveBeenCalledWith(ORG);
1411
+ expect(mockRecordCheckoutSession.mock.calls[0][0]).toMatchObject({
1412
+ workspaceId: WS,
1413
+ organizationId: ORG,
1414
+ });
1415
+ });
1416
+
1417
+ it("leaves a non-owner (no connection) on the env path, as before", async () => {
1418
+ mockConnectionSummary.mockResolvedValue(null);
1419
+
1420
+ const res = await post();
1421
+
1422
+ expect(res.statusCode).toBe(500);
1423
+ expect(res.json()).toEqual({ error: "stripe-not-configured" });
1424
+ expect(mockClientForOrganization).not.toHaveBeenCalled();
1425
+ });
1426
+ });
@@ -28,50 +28,36 @@
28
28
  * composed from the deployment's `_site.mdx` identity (identity node, optional
29
29
  * secondary, WebSite) rather than the page title. Every other type's body is
30
30
  * the page's own JSON-LD, spread at the root, as before.
31
+ *
32
+ * #7169: the document is built by `readContentDocument` in
33
+ * `@working-theory/content-adapters` — the shared seam the `read_content` MCP
34
+ * tool answers through, parsing the same `@working-theory/validation` exports —
35
+ * over the `_site.mdx` this route still reads (the package may not import
36
+ * `@working-theory/site-config`).
31
37
  */
32
38
  import { readFileSync } from "node:fs";
33
39
  import { join } from "node:path";
34
40
 
35
41
  import {
36
- SCHEMA_ORG_TYPES,
42
+ readContentDocument,
37
43
  type ContentAdapter,
38
- type ContentPage,
39
44
  type MdxContentAdapter,
40
- type MdxPageSource,
41
- type SchemaOrgType,
42
45
  } from "@working-theory/content-adapters";
43
- import {
44
- buildJsonLd,
45
- composeGraph,
46
- graphNodes,
47
- type ContentPageInput,
48
- type JsonLdObject,
49
- type SitewideIdentityInput,
50
- } from "@working-theory/schema";
51
46
  import {
52
47
  SiteConfigSchema,
53
48
  type SiteConfig,
54
49
  } from "@working-theory/site-config/schema";
50
+ import {
51
+ contentPageParamsSchema,
52
+ contentPageQuerySchema,
53
+ } from "@working-theory/validation";
55
54
  import type { FastifyPluginAsync } from "fastify";
56
55
  import { parse as parseYaml } from "yaml";
57
- import { z } from "zod";
58
56
 
59
57
  import { getContentRoot } from "../../lib/content-adapter.js";
60
58
  import { resolveLocale } from "../../lib/locales.js";
61
59
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
62
60
 
63
- import { SLUG_PATTERN } from "./slug.js";
64
-
65
- const paramsSchema = z.object({
66
- type: z.enum(SCHEMA_ORG_TYPES),
67
- slug: z.string().regex(SLUG_PATTERN),
68
- });
69
-
70
- const querySchema = z.object({
71
- locale: z.string().optional(),
72
- include: z.enum(["body"]).optional(),
73
- });
74
-
75
61
  /**
76
62
  * The raw-body capability, probed by the exact name the MDX adapter declares
77
63
  * and the database adapter borrows (#5874) — the same idiom, and the same
@@ -114,38 +100,6 @@ function readSiteConfig(contentRoot: string): SiteConfig | undefined {
114
100
  return siteConfigByRoot.get(contentRoot) ?? undefined;
115
101
  }
116
102
 
117
- /**
118
- * The `HomePage` body's `@graph` (#6597, spec D11): the spine plus the site's
119
- * sitewide identity nodes. The WebSite's `inLanguage` is the spine's own, so
120
- * both carry one value; the Organization falls back to the page's `image` as
121
- * its `logo` when the identity declares none, as the title-derived node did.
122
- */
123
- function homePageGraph(
124
- primary: JsonLdObject,
125
- image: string | undefined,
126
- ): { "@context": "https://schema.org"; "@graph": JsonLdObject[] } {
127
- const config = readSiteConfig(getContentRoot());
128
- let site: SitewideIdentityInput | undefined;
129
- if (config !== undefined) {
130
- const identity = config.siteIdentity;
131
- site = {
132
- origin: config.baseUrl.replace(/\/+$/, ""),
133
- siteName: config.siteName,
134
- baseUrl: config.baseUrl,
135
- inLanguage:
136
- typeof primary["inLanguage"] === "string" ? primary["inLanguage"] : undefined,
137
- identity:
138
- identity.kind !== "Person" && identity.logo === undefined && image !== undefined
139
- ? { ...identity, logo: image }
140
- : identity,
141
- };
142
- }
143
- return {
144
- "@context": "https://schema.org",
145
- "@graph": graphNodes(composeGraph({ primary, site, isHomePage: true })),
146
- };
147
- }
148
-
149
103
  export const getPageRoute: FastifyPluginAsync = async (fastify) => {
150
104
  fastify.get<{
151
105
  Params: { type: string; slug: string };
@@ -153,7 +107,7 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
153
107
  }>(
154
108
  "/v1/content/:type/:slug",
155
109
  async (request, reply) => {
156
- const parsed = paramsSchema.safeParse(request.params);
110
+ const parsed = contentPageParamsSchema.safeParse(request.params);
157
111
  if (!parsed.success) {
158
112
  return reply.status(400).send({
159
113
  error: "Invalid type or slug",
@@ -161,7 +115,7 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
161
115
  });
162
116
  }
163
117
 
164
- const queryParsed = querySchema.safeParse(request.query);
118
+ const queryParsed = contentPageQuerySchema.safeParse(request.query);
165
119
  if (!queryParsed.success) {
166
120
  return reply.status(400).send({
167
121
  error: "Invalid query parameters",
@@ -191,74 +145,35 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
191
145
  // Refused before any content query, so a caller asking for a body this
192
146
  // deployment cannot serve is never charged a read whose result is then
193
147
  // discarded.
194
- let readSource: MdxContentAdapter["getPageSource"] | undefined;
195
- if (include === "body") {
196
- if (!hasPageSource(adapter)) {
197
- return reply.status(400).send({
198
- error: "Invalid query parameters",
199
- details: [
200
- {
201
- path: ["include"],
202
- message: "body is not available from this content adapter",
203
- },
204
- ],
205
- });
206
- }
207
- readSource = adapter.getPageSource.bind(adapter);
148
+ if (include === "body" && !hasPageSource(adapter)) {
149
+ return reply.status(400).send({
150
+ error: "Invalid query parameters",
151
+ details: [
152
+ {
153
+ path: ["include"],
154
+ message: "body is not available from this content adapter",
155
+ },
156
+ ],
157
+ });
208
158
  }
209
159
 
210
- // #5927: ONE adapter call supplies everything the response sends.
211
- // `readSource` (bound above, only when `include=body`) returns BOTH
212
- // the frontmatter and the body from the SAME read, so the two halves
213
- // can never straddle a publish that lands between two independent
214
- // calls. Without the parameter, `getPageBySlug` is the only call, as
215
- // it always was. `source` stays `undefined` on that path — the
216
- // `source && {...}` below never attaches a body to the response.
217
- let source: MdxPageSource | null | undefined;
218
- let page: ContentPage | null;
219
- if (readSource) {
220
- source = await readSource(type as SchemaOrgType, slug, locale);
221
- page = source === null ? null : source.frontmatter;
222
- } else {
223
- page = await adapter.getPageBySlug(type as SchemaOrgType, slug, locale);
224
- }
225
- if (!page) {
160
+ // #5927: ONE adapter call supplies everything the response sends —
161
+ // the builder reads `getPageSource` only when `include=body`, and
162
+ // `getPageBySlug` alone otherwise, so the two halves can never
163
+ // straddle a publish that lands between two independent calls.
164
+ const document = await readContentDocument(
165
+ adapter,
166
+ { type, slug, locale, include },
167
+ readSiteConfig(getContentRoot()),
168
+ );
169
+ if (!document) {
226
170
  return reply.status(404).send({
227
171
  error: "Page not found",
228
172
  type,
229
173
  slug,
230
174
  });
231
175
  }
232
-
233
- // #8746 — the site's origin, from `_site.mdx`'s `baseUrl`, for a
234
- // builder that needs an absolute URL (JobPosting's ApplyAction).
235
- const origin = readSiteConfig(getContentRoot())?.baseUrl.replace(/\/+$/, "");
236
- const input: ContentPageInput = {
237
- type: page.type,
238
- slug: page.slug,
239
- title: page.title,
240
- description: page.description,
241
- ...(origin !== undefined && { origin }),
242
- ...(page.publishedAt !== undefined && {
243
- publishedAt: page.publishedAt,
244
- }),
245
- ...(page.updatedAt !== undefined && { updatedAt: page.updatedAt }),
246
- ...(page.image !== undefined && { image: page.image }),
247
- ...(page.schema !== undefined && { schema: page.schema }),
248
- // ADR-0026: carry the page's typed sections so recipes compose entity
249
- // JSON-LD from real section data (e.g. FAQPage.mainEntity from `faq`
250
- // sections) rather than a hand-rolled `schema` override. The adapter
251
- // now preserves `sections`; before, it was stripped at validation.
252
- ...(page.sections !== undefined && { sections: page.sections }),
253
- };
254
- const primary = buildJsonLd(input);
255
- const jsonLd =
256
- page.type === "HomePage" ? homePageGraph(primary, page.image) : primary;
257
- return reply.send({
258
- ...jsonLd,
259
- page,
260
- ...(source && { body: source.body }),
261
- });
176
+ return reply.send(document);
262
177
  } catch (error) {
263
178
  request.log.error({ err: error }, "[Content] getPage failed");
264
179
  return reply.status(500).send({ error: "Internal server error" });
@@ -5,43 +5,32 @@
5
5
  * pageSize 20; pageSize is capped at 100 to keep response payloads bounded.
6
6
  *
7
7
  * Response envelope is an `ItemList` JSON-LD document carrying the page
8
- * frontmatter under `items` and pagination metadata alongside. A deployment
9
- * whose site cannot be resolved (#5852) answers 404, not an empty list.
8
+ * frontmatter under `items` and pagination metadata alongside, built by
9
+ * `listPagesItemList` in `@working-theory/content-adapters` — the shared seam
10
+ * the `list_pages` MCP tool answers through, parsing the same
11
+ * `@working-theory/validation` exports (#7168). A deployment whose site
12
+ * cannot be resolved (#5852) answers 404, not an empty list.
10
13
  *
11
14
  * Phase 3 S3: optional `?locale=<locale>` query parameter. Validated against
12
15
  * `readLocales()` allowlist; invalid or unknown locale silently falls back to
13
16
  * the project's default locale (AC-i18n-07).
14
17
  */
15
- import { SCHEMA_ORG_TYPES, type SchemaOrgType } from "@working-theory/content-adapters";
18
+ import { listPagesItemList } from "@working-theory/content-adapters";
19
+ import {
20
+ contentListParamsSchema,
21
+ contentListQuerySchema,
22
+ } from "@working-theory/validation";
16
23
  import type { FastifyPluginAsync } from "fastify";
17
- import { z } from "zod";
18
24
 
19
25
  import { resolveLocale } from "../../lib/locales.js";
20
26
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
21
27
 
22
- const paramsSchema = z.object({
23
- type: z.enum(SCHEMA_ORG_TYPES),
24
- });
25
-
26
- const PAGE_SIZE_MAX = 100;
27
-
28
- const querySchema = z.object({
29
- page: z.coerce.number().int().min(1).default(1),
30
- pageSize: z.coerce
31
- .number()
32
- .int()
33
- .min(1)
34
- .default(20)
35
- .transform((v) => Math.min(v, PAGE_SIZE_MAX)),
36
- locale: z.string().optional(),
37
- });
38
-
39
28
  export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
40
29
  fastify.get<{
41
30
  Params: { type: string };
42
31
  Querystring: { page?: string; pageSize?: string; locale?: string };
43
32
  }>("/v1/content/:type", async (request, reply) => {
44
- const params = paramsSchema.safeParse(request.params);
33
+ const params = contentListParamsSchema.safeParse(request.params);
45
34
  if (!params.success) {
46
35
  return reply.status(400).send({
47
36
  error: "Invalid type",
@@ -49,7 +38,7 @@ export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
49
38
  });
50
39
  }
51
40
 
52
- const query = querySchema.safeParse(request.query);
41
+ const query = contentListQuerySchema.safeParse(request.query);
53
42
  if (!query.success) {
54
43
  return reply.status(400).send({
55
44
  error: "Invalid query parameters",
@@ -74,19 +63,9 @@ export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
74
63
  });
75
64
  }
76
65
 
77
- const all = await adapter.listPages(type as SchemaOrgType, locale);
78
- const totalItems = all.length;
79
- const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
80
- const start = (page - 1) * pageSize;
81
- const items = all.slice(start, start + pageSize);
82
-
83
- return reply.send({
84
- "@context": "https://schema.org",
85
- "@type": "ItemList",
86
- type,
87
- items,
88
- pagination: { page, pageSize, totalItems, totalPages },
89
- });
66
+ return reply.send(
67
+ await listPagesItemList(adapter, { type, page, pageSize, locale }),
68
+ );
90
69
  } catch (error) {
91
70
  request.log.error({ err: error }, "[Content] listPages failed");
92
71
  return reply.status(500).send({ error: "Internal server error" });
@@ -7,11 +7,12 @@
7
7
  * parity contract in the root CLAUDE.md. Until this route existed the parity
8
8
  * was one-sided, the tool having no endpoint to map to.
9
9
  *
10
- * Query:
11
- * q required, 1..200 chars — the search string. The ceiling matches
12
- * the `search_content` MCP tool's own cap (`searchContentInputSchema`)
13
- * so the pair rejects the same inputs, and bounds the work an
14
- * unauthenticated caller can ask of the scorer per request.
10
+ * Query — parsed with `contentSearchQuerySchema` (`@working-theory/validation`),
11
+ * the export the `search_content` MCP tool parses with too, so the pair
12
+ * accepts and rejects the same inputs (#7167):
13
+ * q required, 1..200 chars — the search string. The ceiling bounds
14
+ * the work an unauthenticated caller can ask of the scorer per
15
+ * request.
15
16
  * type optional — a schema.org page type
16
17
  * category optional — a LEAF slug from the site-category registry. Outside
17
18
  * the registry is a 400, not an empty result: the registry is
@@ -26,10 +27,10 @@
26
27
  * ordering is belt-and-braces, and `/v1/content/types` is the same shape that
27
28
  * already proves it.
28
29
  *
29
- * The response is an `ItemList` JSON-LD document. `items` is the adapter's own
30
- * `SearchResult[]`, unreshaped: a caller comparing this route against the MCP
31
- * tool must see the same objects, and a projection here would be the place
32
- * they silently diverged.
30
+ * The response is an `ItemList` JSON-LD document, built by
31
+ * `searchContentItemList` in `@working-theory/content-adapters` — the shared
32
+ * seam the MCP tool answers through, so the pair cannot diverge. `items` is
33
+ * the adapter's own `SearchResult[]`, unreshaped.
33
34
  *
34
35
  * Rate limiting is inherited: `server.ts` registers the limiter GLOBALLY with
35
36
  * the content ceiling (`rate-limit.ts`'s `CONTENT_RATE_LIMIT_UNAUTH` /
@@ -46,30 +47,13 @@
46
47
  * carries no detail an unauthenticated caller could use to tell it apart
47
48
  * from "nothing matched" (AC-WCE-05).
48
49
  */
49
- import {
50
- SCHEMA_ORG_TYPES,
51
- type SearchOptions,
52
- } from "@working-theory/content-adapters";
53
- import { CategorySlugSchema } from "@working-theory/schema/categories";
50
+ import { searchContentItemList } from "@working-theory/content-adapters";
51
+ import { contentSearchQuerySchema } from "@working-theory/validation";
54
52
  import type { FastifyPluginAsync } from "fastify";
55
- import { z } from "zod";
56
53
 
57
54
  import { resolveLocale } from "../../lib/locales.js";
58
55
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
59
56
 
60
- const LIMIT_MAX = 50;
61
- const LIMIT_DEFAULT = 10;
62
- /** Mirrors `searchContentInputSchema`'s `query` cap — the pair rejects alike. */
63
- const QUERY_MAX = 200;
64
-
65
- const querySchema = z.object({
66
- q: z.string().min(1).max(QUERY_MAX),
67
- type: z.enum(SCHEMA_ORG_TYPES).optional(),
68
- category: CategorySlugSchema.optional(),
69
- limit: z.coerce.number().int().min(1).max(LIMIT_MAX).default(LIMIT_DEFAULT),
70
- locale: z.string().optional(),
71
- });
72
-
73
57
  export const searchContentRoute: FastifyPluginAsync = async (fastify) => {
74
58
  fastify.get<{
75
59
  Querystring: {
@@ -80,7 +64,7 @@ export const searchContentRoute: FastifyPluginAsync = async (fastify) => {
80
64
  locale?: string;
81
65
  };
82
66
  }>("/v1/content/search", async (request, reply) => {
83
- const query = querySchema.safeParse(request.query);
67
+ const query = contentSearchQuerySchema.safeParse(request.query);
84
68
  if (!query.success) {
85
69
  return reply.status(400).send({
86
70
  error: "Invalid query parameters",
@@ -91,13 +75,6 @@ export const searchContentRoute: FastifyPluginAsync = async (fastify) => {
91
75
  const { q, type, category, limit } = query.data;
92
76
  const locale = resolveLocale(query.data.locale);
93
77
 
94
- const options: SearchOptions = {
95
- limit,
96
- ...(type !== undefined && { type }),
97
- ...(category !== undefined && { category }),
98
- locale,
99
- };
100
-
101
78
  try {
102
79
  // Site resolution precedes any content query (#5852, #5910). A refusal
103
80
  // is a 404 sharing the same "Page not found" error string a
@@ -112,15 +89,9 @@ export const searchContentRoute: FastifyPluginAsync = async (fastify) => {
112
89
  });
113
90
  }
114
91
 
115
- const items = await adapter.searchPages(q, options);
116
-
117
- return reply.send({
118
- "@context": "https://schema.org",
119
- "@type": "ItemList",
120
- query: q,
121
- items,
122
- totalItems: items.length,
123
- });
92
+ return reply.send(
93
+ await searchContentItemList(adapter, { q, type, category, limit, locale }),
94
+ );
124
95
  } catch (error) {
125
96
  request.log.error({ err: error }, "[Content] searchPages failed");
126
97
  return reply.status(500).send({ error: "Internal server error" });