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
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
// `
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (
|
|
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
|
|
9
|
-
*
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* unauthenticated caller can ask of the scorer per
|
|
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
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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" });
|