create-ailk 0.6.0 → 0.7.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 (248) hide show
  1. package/README.md +44 -5
  2. package/component-catalog.json +146 -21
  3. package/component-catalog.md +9 -6
  4. package/dist/add-module-core.d.ts +22 -0
  5. package/dist/add-module-core.js +50 -0
  6. package/dist/add-module.d.ts +89 -0
  7. package/dist/add-module.js +334 -0
  8. package/dist/category-profile.d.ts +126 -0
  9. package/dist/category-profile.js +262 -0
  10. package/dist/cli.js +124 -13
  11. package/dist/copy.d.ts +14 -0
  12. package/dist/copy.js +38 -1
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +6 -1
  15. package/dist/lib/extract-module-bundle.d.ts +76 -2
  16. package/dist/lib/extract-module-bundle.js +219 -8
  17. package/dist/lib/license-key-files.d.ts +15 -0
  18. package/dist/lib/license-key-files.js +102 -0
  19. package/dist/lib/module-license-gate.d.ts +6 -1
  20. package/dist/lib/module-license-gate.js +6 -1
  21. package/dist/lib/pro-callout.d.ts +16 -10
  22. package/dist/lib/pro-callout.js +27 -4
  23. package/dist/license-set.d.ts +34 -0
  24. package/dist/license-set.js +194 -0
  25. package/dist/module-architecture.d.ts +20 -0
  26. package/dist/module-architecture.js +77 -19
  27. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  28. package/dist/modules/commerce/0.6.1.tar.gz +0 -0
  29. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  30. package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
  31. package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
  32. package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
  33. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  34. package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
  35. package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
  36. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  37. package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
  38. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  39. package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
  40. package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
  41. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  42. package/dist/modules/workspace/0.6.1.tar.gz +0 -0
  43. package/dist/parse-args.d.ts +7 -0
  44. package/dist/parse-args.js +23 -1
  45. package/dist/programmatic.d.ts +46 -0
  46. package/dist/programmatic.js +144 -19
  47. package/dist/site-chrome.d.ts +18 -7
  48. package/dist/site-chrome.js +32 -14
  49. package/dist/site-roster.d.ts +127 -0
  50. package/dist/site-roster.js +494 -0
  51. package/dist/surfaces.d.ts +20 -7
  52. package/dist/surfaces.js +149 -28
  53. package/dist/sync-routes.d.ts +9 -0
  54. package/dist/sync-routes.js +2 -2
  55. package/dist/template-manifest.d.ts +58 -2
  56. package/dist/template-manifest.js +196 -69
  57. package/package.json +6 -4
  58. package/site-templates/_base/content/en/BookCall/book-call.mdx +53 -0
  59. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +66 -0
  60. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +68 -0
  61. package/site-templates/_base/content/en/Pricing/pricing.mdx +71 -62
  62. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +2 -3
  63. package/site-templates/_base/content/en/TermsPage/terms.mdx +2 -3
  64. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +23 -0
  65. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +23 -0
  66. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +23 -0
  67. package/site-templates/designs/minimalism/DESIGN.md +162 -0
  68. package/site-templates/designs/standard/DESIGN.md +162 -0
  69. package/site-templates/marketing-site/DESIGN.md +162 -0
  70. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +312 -57
  71. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +209 -90
  72. package/site-templates/marketing-site/ailk-template.json +1 -0
  73. package/site-templates/marketing-site-free/DESIGN.md +162 -0
  74. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +260 -66
  75. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +294 -114
  76. package/site-templates/marketing-site-free/ailk-template.json +3 -2
  77. package/site-templates/minimalism-site/DESIGN.md +162 -0
  78. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +246 -19
  79. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +185 -51
  80. package/site-templates/minimalism-site/ailk-template.json +3 -2
  81. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +53 -0
  82. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +3 -37
  83. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +124 -0
  84. package/site-templates/saas-product/DESIGN.md +162 -0
  85. package/site-templates/simple-service/DESIGN.md +162 -0
  86. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +135 -26
  87. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +114 -59
  88. package/site-templates/simple-service/ailk-template.json +4 -3
  89. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +53 -0
  90. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +1 -36
  91. package/site-templates/simple-service/content/en/PurchasePolicyPage/purchase-policy.mdx +124 -0
  92. package/templates/.claude/settings.json +15 -0
  93. package/templates/.claude/skills/README.md +8 -8
  94. package/templates/.claude/skills/{scaffold-auth → add-auth}/SKILL.md +7 -7
  95. package/templates/.claude/skills/{scaffold-auth → add-auth}/references/env-vars.md +2 -2
  96. package/templates/.claude/skills/{scaffold-auth → add-auth}/references/oauth-app-setup.md +3 -3
  97. package/templates/.claude/skills/{scaffold-page → add-page}/SKILL.md +76 -62
  98. package/templates/.claude/skills/add-page/references/page-types.md +111 -0
  99. package/templates/.claude/skills/{scaffold-page → add-page}/templates/content.mdx.tmpl +4 -4
  100. package/templates/.claude/skills/{scaffold-page → add-page}/templates/route.tsx.tmpl +1 -1
  101. package/templates/.claude/skills/{audit-site → check-site}/SKILL.md +16 -17
  102. package/templates/.claude/skills/{audit-site → check-site}/references/common-errors.md +3 -3
  103. package/templates/.claude/skills/{audit-site → check-site}/references/findings-glossary.md +16 -14
  104. package/templates/.claude/skills/{audit-site → check-site}/references/severity-levels.md +1 -1
  105. package/templates/.claude/skills/fill-page/SKILL.md +180 -0
  106. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +80 -0
  107. package/templates/.claude/skills/fill-page/references/section-sources.md +41 -0
  108. package/templates/.claude/skills/{provision-config → setup-deploy}/SKILL.md +8 -8
  109. package/templates/.claude/skills/{provision-config → setup-deploy}/references/env-vars-template.md +2 -2
  110. package/templates/.claude/skills/{provision-config → setup-deploy}/references/runbook-template.md +8 -8
  111. package/templates/.claude/skills/{provision-config → setup-deploy}/references/vercel-json-template.md +1 -1
  112. package/templates/.claude/skills/upgrade-plan/SKILL.md +117 -0
  113. package/templates/.claude/skills/upgrade-plan/references/options.md +24 -0
  114. package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +527 -0
  115. package/templates/.github/workflows/ci.yml +5 -0
  116. package/templates/CLAUDE.md +10 -5
  117. package/templates/DESIGN.md +162 -0
  118. package/templates/README.md +134 -38
  119. package/templates/apps/api/.env.example +2 -1
  120. package/templates/apps/api/package.json +1 -1
  121. package/templates/apps/api/src/config/__tests__/index.test.ts +37 -2
  122. package/templates/apps/api/src/config/index.ts +19 -6
  123. package/templates/apps/api/src/lib/license-key-store.ts +45 -0
  124. package/templates/apps/api/src/lib/site.ts +5 -3
  125. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +97 -0
  126. package/templates/apps/api/src/middleware/auth.ts +9 -0
  127. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +127 -5
  128. package/templates/apps/api/src/routes/content/get-page.ts +86 -2
  129. package/templates/apps/api/src/server.ts +6 -0
  130. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +75 -1
  131. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +57 -9
  132. package/templates/apps/mcp/package.json +2 -1
  133. package/templates/apps/mcp/src/index.ts +7 -0
  134. package/templates/apps/mcp/src/lib/audit-single-page.ts +36 -10
  135. package/templates/apps/mcp/src/lib/license-key-store.ts +45 -0
  136. package/templates/apps/web/PRODUCT.md +86 -0
  137. package/templates/apps/web/__tests__/setup.ts +18 -0
  138. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +10 -2
  139. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +10 -4
  141. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +22 -14
  142. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +26 -18
  143. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +12 -4
  144. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +35 -4
  145. package/templates/apps/web/app/[locale]/[slug]/page.tsx +49 -9
  146. package/templates/apps/web/app/[locale]/book-a-call/page.tsx +50 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  148. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  149. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +3 -4
  150. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +94 -33
  151. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +53 -23
  152. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +96 -0
  153. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +84 -2
  154. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -6
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +25 -7
  156. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  157. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +3 -4
  158. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +4 -5
  159. package/templates/apps/web/app/[locale]/glossary/page.tsx +50 -0
  160. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  161. package/templates/apps/web/app/[locale]/how-to/page.tsx +25 -15
  162. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  163. package/templates/apps/web/app/[locale]/layout.tsx +113 -51
  164. package/templates/apps/web/app/[locale]/newsletter/page.tsx +50 -0
  165. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  166. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  167. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -5
  168. package/templates/apps/web/app/[locale]/{shipping → purchase-policy}/page.tsx +15 -10
  169. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +4 -5
  170. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  171. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +14 -1
  172. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  173. package/templates/apps/web/app/[locale]/software/page.tsx +25 -15
  174. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  175. package/templates/apps/web/app/[locale]/tech-articles/page.tsx +25 -15
  176. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +3 -4
  177. package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +48 -0
  178. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +3 -4
  179. package/templates/apps/web/app/[locale]/use-cases/page.tsx +15 -2
  180. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  181. package/templates/apps/web/app/[locale]/whitepapers/page.tsx +25 -15
  182. package/templates/apps/web/app/error-fallback.tsx +17 -45
  183. package/templates/apps/web/app/not-found.tsx +14 -38
  184. package/templates/apps/web/app/og/route.tsx +6 -0
  185. package/templates/apps/web/eslint.config.mjs +3 -0
  186. package/templates/apps/web/instrumentation.ts +14 -0
  187. package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
  188. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -0
  189. package/templates/apps/web/lib/license-key-store.ts +52 -0
  190. package/templates/apps/web/lib/nav-config.ts +64 -12
  191. package/templates/apps/web/lib/prisma-for-auth.ts +9 -6
  192. package/templates/apps/web/lib/site-brand.tsx +4 -8
  193. package/templates/apps/web/lib/site-chrome.ts +3 -3
  194. package/templates/apps/web/lib/site-identity.ts +134 -0
  195. package/templates/apps/web/lib/site-theme.ts +11 -0
  196. package/templates/apps/web/messages/en.json +52 -37
  197. package/templates/apps/web/middleware.ts +1 -1
  198. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  199. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  200. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  201. package/templates/apps/web/public/favicon-16x16.png +0 -0
  202. package/templates/apps/web/public/favicon-32x32.png +0 -0
  203. package/templates/apps/web/public/favicon.ico +0 -0
  204. package/templates/apps/web/public/favicon.svg +3 -3
  205. package/templates/apps/web/public/logomark.svg +6 -3
  206. package/templates/apps/web/public/logos/ai-launch-kit-light.svg +4 -0
  207. package/templates/apps/web/public/logos/ai-launch-kit.svg +4 -0
  208. package/templates/apps/web/public/logos/atomic-agent-light.svg +4 -0
  209. package/templates/apps/web/public/logos/atomic-agent.svg +4 -0
  210. package/templates/apps/web/public/logos/salessmyth-light.svg +4 -0
  211. package/templates/apps/web/public/logos/salessmyth.svg +4 -0
  212. package/templates/apps/web/public/site.webmanifest +4 -4
  213. package/templates/apps/web/public/wordmark-light.svg +4 -0
  214. package/templates/apps/web/public/wordmark.svg +3 -3
  215. package/templates/apps/web/security-headers.config.ts +5 -1
  216. package/templates/apps/web/src/lib/commerce-sections.ts +35 -3
  217. package/templates/content/_site.mdx +41 -3
  218. package/templates/content/en/AboutPage/about.mdx +150 -144
  219. package/templates/content/en/BlogList/blog.mdx +105 -0
  220. package/templates/content/en/ContactPage/contact.mdx +44 -85
  221. package/templates/content/en/FAQPage/faq.mdx +86 -57
  222. package/templates/content/en/HomePage/home.mdx +276 -217
  223. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +182 -0
  224. package/templates/content/en/SoftwareProduct/product.mdx +175 -0
  225. package/templates/content/en/UseCasesList/use-cases.mdx +170 -0
  226. package/templates/database/CHANGELOG.md +14 -0
  227. package/templates/database/package.json +3 -2
  228. package/templates/package.json +3 -1
  229. package/templates/tools/lint-architecture.ts +315 -0
  230. package/templates/tools/tsconfig.json +14 -0
  231. package/versions.json +33 -33
  232. package/templates/.claude/skills/add-schema/SKILL.md +0 -277
  233. package/templates/.claude/skills/add-schema/references/conflict-handling.md +0 -200
  234. package/templates/.claude/skills/add-schema/references/file-finding.md +0 -123
  235. package/templates/.claude/skills/add-schema/references/schema-types.md +0 -234
  236. package/templates/.claude/skills/scaffold-page/references/page-types.md +0 -169
  237. package/templates/.claude/skills/suggest-site-pages/SKILL.md +0 -242
  238. package/templates/.claude/skills/suggest-site-pages/references/categories.md +0 -287
  239. package/templates/apps/web/app/[locale]/cancellation/page.tsx +0 -44
  240. package/templates/apps/web/app/[locale]/returns/page.tsx +0 -44
  241. package/templates/apps/web/public/logo-dark.svg +0 -21
  242. package/templates/apps/web/public/logo-light.svg +0 -21
  243. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +0 -108
  244. package/templates/content/en/LandingPage/blog.mdx +0 -83
  245. package/templates/content/en/LandingPage/features.mdx +0 -103
  246. package/templates/content/en/LandingPage/how-it-works.mdx +0 -143
  247. package/templates/content/en/ReturnsPolicyPage/returns.mdx +0 -108
  248. package/templates/content/en/ShippingPolicyPage/shipping.mdx +0 -108
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file auth.database-not-configured.test.ts
3
+ *
4
+ * DATABASE_URL unset → 503 database_not_configured (#6482).
5
+ *
6
+ * Outside production apps/api boots database-less. The preHandler must say
7
+ * so before it reads any credential: placed after the lookups, the existing
8
+ * `.catch(() => undefined)` would turn the connection failure into a
9
+ * misleading 401.
10
+ */
11
+
12
+ import Fastify from "fastify";
13
+ import type { FastifyInstance } from "fastify";
14
+
15
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
16
+ jest.mock("../../lib/prisma.js", () => ({
17
+ prisma: {
18
+ session: { findUnique: jest.fn() },
19
+ apiToken: {
20
+ findUnique: jest.fn(),
21
+ update: jest.fn().mockResolvedValue(undefined),
22
+ },
23
+ },
24
+ }));
25
+
26
+ import { prisma } from "../../lib/prisma.js";
27
+ import { authPreHandlerPlugin } from "../auth.js";
28
+
29
+ const echoHandler = jest.fn(async () => ({ ok: true }));
30
+
31
+ function buildApp(): FastifyInstance {
32
+ const app = Fastify();
33
+ app.register(authPreHandlerPlugin, {
34
+ frontendOrigin: "https://app.example.com",
35
+ });
36
+ app.get("/echo", echoHandler);
37
+ app.post("/echo", echoHandler);
38
+ return app;
39
+ }
40
+
41
+ describe("auth preHandler — DATABASE_URL not configured", () => {
42
+ const originalDatabaseUrl = process.env.DATABASE_URL;
43
+ let app: FastifyInstance;
44
+
45
+ beforeEach(async () => {
46
+ jest.clearAllMocks();
47
+ delete process.env.DATABASE_URL;
48
+ app = buildApp();
49
+ await app.ready();
50
+ });
51
+
52
+ afterEach(async () => {
53
+ await app.close();
54
+ if (originalDatabaseUrl === undefined) {
55
+ delete process.env.DATABASE_URL;
56
+ } else {
57
+ process.env.DATABASE_URL = originalDatabaseUrl;
58
+ }
59
+ });
60
+
61
+ it.each([
62
+ ["no credentials", "GET", {}],
63
+ ["a bearer token", "GET", { authorization: "Bearer ailk_abc123" }],
64
+ ["a session cookie", "GET", { cookie: "better-auth.session_token=tok" }],
65
+ [
66
+ "a non-GET with a session cookie",
67
+ "POST",
68
+ {
69
+ cookie: "better-auth.session_token=tok",
70
+ origin: "https://app.example.com",
71
+ },
72
+ ],
73
+ ] as const)(
74
+ 'returns 503 { error: "database_not_configured" } for %s without any lookup',
75
+ async (_label, method, headers) => {
76
+ const res = await app.inject({ method, url: "/echo", headers });
77
+
78
+ expect(res.statusCode).toBe(503);
79
+ expect(JSON.parse(res.body)).toEqual({
80
+ error: "database_not_configured",
81
+ });
82
+ expect(prisma.session.findUnique).not.toHaveBeenCalled();
83
+ expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
84
+ expect(echoHandler).not.toHaveBeenCalled();
85
+ },
86
+ );
87
+
88
+ it("control: with DATABASE_URL set and no credentials, answers 401 unauthorized", async () => {
89
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
90
+
91
+ const res = await app.inject({ method: "GET", url: "/echo" });
92
+
93
+ expect(res.statusCode).toBe(401);
94
+ expect(JSON.parse(res.body)).toEqual({ error: "unauthorized" });
95
+ expect(echoHandler).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -22,6 +22,7 @@
22
22
  * - Both modes invalid → 401 unauthorized.
23
23
  * - Cookie expired + bearer valid → 401 (no silent fallback).
24
24
  * - Cookie absent, bearer malformed → 401 (no silent fallback).
25
+ * - DATABASE_URL unset → 503 database_not_configured (#6482).
25
26
  *
26
27
  * Public routes (health, content, leads, webhooks, openapi) are NOT registered
27
28
  * with this preHandler — server.ts narrows scope via a sub-plugin.
@@ -48,6 +49,7 @@ const COOKIE_NAMES = [
48
49
  ] as const;
49
50
 
50
51
  const UNAUTHORIZED = { error: "unauthorized" } as const;
52
+ const DATABASE_NOT_CONFIGURED = { error: "database_not_configured" } as const;
51
53
 
52
54
  declare module "fastify" {
53
55
  interface FastifyRequest {
@@ -201,6 +203,13 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
201
203
  request: FastifyRequest,
202
204
  reply: FastifyReply,
203
205
  ): Promise<void> {
206
+ // A database-less dev boot (#6482). First, and read per request: after
207
+ // the lookups below, their `.catch` would already have answered 401.
208
+ if (!process.env.DATABASE_URL) {
209
+ reply.code(503).send(DATABASE_NOT_CONFIGURED);
210
+ return;
211
+ }
212
+
204
213
  const now = getClock().now();
205
214
  const cookieToken = readSessionCookie(request);
206
215
 
@@ -6,6 +6,9 @@
6
6
  * No internal module mocks — the route exercises the real adapter stack.
7
7
  */
8
8
 
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
9
12
  import type { FastifyInstance } from "fastify";
10
13
 
11
14
  import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
@@ -17,6 +20,30 @@ import { contentRoutes } from "../index";
17
20
 
18
21
  const fixture = setupContentFixture();
19
22
 
23
+ /**
24
+ * A `_site.mdx` in the fixture's content root — an Organization identity at
25
+ * `https://example.com` (#6597). `logo` adds an identity-declared logo.
26
+ */
27
+ function writeSiteMdx(root: string, opts: { logo?: string } = {}): void {
28
+ writeFileSync(
29
+ join(root, "_site.mdx"),
30
+ [
31
+ "---",
32
+ 'version: "1"',
33
+ "siteName: Fixture Site",
34
+ "baseUrl: https://example.com",
35
+ "defaultLocale: en",
36
+ "siteIdentity:",
37
+ " kind: Organization",
38
+ " name: Fixture Co",
39
+ " url: https://example.com",
40
+ ...(opts.logo !== undefined ? [` logo: ${opts.logo}`] : []),
41
+ "---",
42
+ "",
43
+ ].join("\n"),
44
+ );
45
+ }
46
+
20
47
  describe("GET /api/content/:type/:slug", () => {
21
48
  let app: FastifyInstance;
22
49
 
@@ -31,6 +58,7 @@ describe("GET /api/content/:type/:slug", () => {
31
58
 
32
59
  it("returns the page with resolved JSON-LD at the document root", async () => {
33
60
  // The fixture writes "home" HomePage in beforeEach by default.
61
+ writeSiteMdx(fixture.getContentRoot());
34
62
  const res = await app.inject({
35
63
  method: "GET",
36
64
  url: "/v1/content/HomePage/home",
@@ -39,8 +67,8 @@ describe("GET /api/content/:type/:slug", () => {
39
67
  expect(res.statusCode).toBe(200);
40
68
  const body = JSON.parse(res.body);
41
69
  expect(body["@context"]).toBe("https://schema.org");
42
- // HomePage composes a Home/Landing @graph (WebPage spine + Organization +
43
- // WebSite siblings) — #2618. The page-level fields live on the spine.
70
+ // A HomePage body keeps its @graph envelope: the WebPage spine plus the
71
+ // sitewide identity from _site.mdx (#6597). Page fields live on the spine.
44
72
  const graph = body["@graph"] as Array<Record<string, unknown>>;
45
73
  expect(graph.map((n) => n["@type"])).toEqual([
46
74
  "WebPage",
@@ -405,9 +433,8 @@ describe("GET /v1/content/:type/:slug under CONTENT_ADAPTER=database (#5852)", (
405
433
  // route's capability branch is exercised through the real resolver — the file
406
434
  // header's "no internal module mocks" (#507) holds for these cases too.
407
435
 
408
- import { mkdtempSync, writeFileSync } from "node:fs";
436
+ import { mkdtempSync } from "node:fs";
409
437
  import { tmpdir } from "node:os";
410
- import { join } from "node:path";
411
438
 
412
439
  import * as contentAdapterModule from "../../../lib/content-adapter.js";
413
440
  import { resetContentAdapter } from "../../../lib/content-adapter.js";
@@ -453,7 +480,12 @@ describe("GET /v1/content/:type/:slug?include=body (#5890)", () => {
453
480
  });
454
481
 
455
482
  // AC-1, the floor half — the unparameterised read is unchanged to the byte.
483
+ // #6597 rewrote this pin by hand: the HomePage @graph is now composed from
484
+ // the fixture's _site.mdx rather than the page title (AC-9 checks every
485
+ // field of the pre-#6597 body is still present). The #5890 claim it guards
486
+ // — no `body` key without the parameter — is unchanged.
456
487
  it("serves a response byte-identical to the pre-change handler when the parameter is absent", async () => {
488
+ writeSiteMdx(fixture.getContentRoot());
457
489
  const res = await app.inject({
458
490
  method: "GET",
459
491
  url: "/v1/content/HomePage/home",
@@ -461,7 +493,7 @@ describe("GET /v1/content/:type/:slug?include=body (#5890)", () => {
461
493
 
462
494
  expect(res.statusCode).toBe(200);
463
495
  expect(res.body).toMatchInlineSnapshot(
464
- `"{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"HomePage home title","description":"HomePage home description"},{"@context":"https://schema.org","@type":"Organization","name":"HomePage home title"},{"@context":"https://schema.org","@type":"WebSite","name":"HomePage home title","inLanguage":"en"}],"page":{"type":"HomePage","slug":"home","title":"HomePage home title","description":"HomePage home description"}}"`,
496
+ `"{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"HomePage home title","description":"HomePage home description","mainEntity":{"@id":"https://example.com/#organization"},"isPartOf":{"@id":"https://example.com/#website"}},{"@context":"https://schema.org","@type":"Organization","@id":"https://example.com/#organization","name":"Fixture Co","url":"https://example.com"},{"@context":"https://schema.org","@type":"WebSite","@id":"https://example.com/#website","name":"Fixture Site","url":"https://example.com","inLanguage":"en","publisher":{"@id":"https://example.com/#organization"}}],"page":{"type":"HomePage","slug":"home","title":"HomePage home title","description":"HomePage home description"}}"`,
465
497
  );
466
498
  expect(JSON.parse(res.body)).not.toHaveProperty("body");
467
499
  });
@@ -693,3 +725,93 @@ describe("GET /v1/content/:type/:slug?include=body under CONTENT_ADAPTER=databas
693
725
  expect(prisma.artifactVersion.findFirst).not.toHaveBeenCalled();
694
726
  });
695
727
  });
728
+
729
+ // ─── #6597 AC-9 — the /v1 HomePage response keeps its @graph envelope ─────────
730
+
731
+ describe("GET /v1/content/HomePage/home — sitewide identity envelope (#6597)", () => {
732
+ let app: FastifyInstance;
733
+
734
+ beforeEach(async () => {
735
+ app = require("fastify")();
736
+ await app.register(contentRoutes);
737
+ await fixture.writeFixturePage("HomePage", "home", {
738
+ type: "HomePage",
739
+ slug: "home",
740
+ title: "HomePage home title",
741
+ description: "HomePage home description",
742
+ image: "https://example.com/home.png",
743
+ });
744
+ });
745
+
746
+ afterEach(async () => {
747
+ await app.close();
748
+ });
749
+
750
+ async function homeBody(query = ""): Promise<{
751
+ body: Record<string, unknown>;
752
+ graph: Array<Record<string, unknown>>;
753
+ }> {
754
+ const res = await app.inject({ method: "GET", url: `/v1/content/HomePage/home${query}` });
755
+ expect(res.statusCode).toBe(200);
756
+ const body = JSON.parse(res.body) as Record<string, unknown>;
757
+ return { body, graph: body["@graph"] as Array<Record<string, unknown>> };
758
+ }
759
+ const byType = (graph: Array<Record<string, unknown>>, type: string) =>
760
+ graph.find((n) => n["@type"] === type)!;
761
+
762
+ it("composes WebPage + Organization + WebSite from _site.mdx, keeping every pre-#6597 field", async () => {
763
+ writeSiteMdx(fixture.getContentRoot());
764
+ const { body, graph } = await homeBody();
765
+ expect(body["@context"]).toBe("https://schema.org");
766
+ expect(graph.map((n) => n["@type"])).toEqual(["WebPage", "Organization", "WebSite"]);
767
+
768
+ const org = byType(graph, "Organization");
769
+ expect(org["@id"]).toBe("https://example.com/#organization");
770
+ expect(org["name"]).toBe("Fixture Co");
771
+ // The pre-#6597 Organization took `logo` from the page image; kept as the
772
+ // fallback when the identity declares none.
773
+ expect(org["logo"]).toBe("https://example.com/home.png");
774
+
775
+ // Every key of every node of the pre-#6597 pinned body survives.
776
+ const pinned: Record<string, string[]> = {
777
+ WebPage: ["@context", "@type", "inLanguage", "name", "description"],
778
+ Organization: ["@context", "@type", "name"],
779
+ WebSite: ["@context", "@type", "name", "inLanguage"],
780
+ };
781
+ for (const [type, keys] of Object.entries(pinned)) {
782
+ for (const key of keys) expect(byType(graph, type)).toHaveProperty([key]);
783
+ }
784
+
785
+ const webPage = byType(graph, "WebPage");
786
+ expect(byType(graph, "WebSite")["inLanguage"]).toBe(webPage["inLanguage"]);
787
+ expect(webPage["inLanguage"]).toBe("en");
788
+ expect(graph.filter((n) => n["name"] === "HomePage home title")).toEqual([webPage]);
789
+ });
790
+
791
+ it("the WebSite and WebPage inLanguage stay one value under ?locale=<second locale>", async () => {
792
+ writeSiteMdx(fixture.getContentRoot());
793
+ const { graph } = await homeBody("?locale=pt-BR");
794
+ expect(byType(graph, "WebSite")["inLanguage"]).toBe(byType(graph, "WebPage")["inLanguage"]);
795
+ });
796
+
797
+ it("an identity-declared logo wins over the page image", async () => {
798
+ writeSiteMdx(fixture.getContentRoot(), { logo: "https://example.com/logo.png" });
799
+ const { graph } = await homeBody();
800
+ expect(byType(graph, "Organization")["logo"]).toBe("https://example.com/logo.png");
801
+ });
802
+
803
+ it("with no _site.mdx the body still has @context and a one-node @graph", async () => {
804
+ const { body, graph } = await homeBody();
805
+ expect(body["@context"]).toBe("https://schema.org");
806
+ expect(graph.map((n) => n["@type"])).toEqual(["WebPage"]);
807
+ });
808
+
809
+ it("other types keep their bare JSON-LD — no @graph key", async () => {
810
+ writeSiteMdx(fixture.getContentRoot());
811
+ const res = await app.inject({ method: "GET", url: "/v1/content/AboutPage/about" });
812
+ expect(res.statusCode).toBe(200);
813
+ const body = JSON.parse(res.body) as Record<string, unknown>;
814
+ expect(body).not.toHaveProperty("@graph");
815
+ expect(body["@type"]).toBe("AboutPage");
816
+ });
817
+ });
@@ -23,7 +23,15 @@
23
23
  * independent `getPageSource` call. Two independent adapter reads can
24
24
  * straddle a publish that lands between them (frontmatter from revision N,
25
25
  * body from revision N+1); one read cannot.
26
+ *
27
+ * #6597: a `HomePage` body keeps its `@context` + `@graph` envelope, now
28
+ * composed from the deployment's `_site.mdx` identity (identity node, optional
29
+ * secondary, WebSite) rather than the page title. Every other type's body is
30
+ * the page's own JSON-LD, spread at the root, as before.
26
31
  */
32
+ import { readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+
27
35
  import {
28
36
  SCHEMA_ORG_TYPES,
29
37
  type ContentAdapter,
@@ -32,10 +40,23 @@ import {
32
40
  type MdxPageSource,
33
41
  type SchemaOrgType,
34
42
  } from "@working-theory/content-adapters";
35
- import { buildJsonLd, type ContentPageInput } from "@working-theory/schema";
43
+ import {
44
+ buildJsonLd,
45
+ composeGraph,
46
+ graphNodes,
47
+ type ContentPageInput,
48
+ type JsonLdObject,
49
+ type SitewideIdentityInput,
50
+ } from "@working-theory/schema";
51
+ import {
52
+ SiteConfigSchema,
53
+ type SiteConfig,
54
+ } from "@working-theory/site-config/schema";
36
55
  import type { FastifyPluginAsync } from "fastify";
56
+ import { parse as parseYaml } from "yaml";
37
57
  import { z } from "zod";
38
58
 
59
+ import { getContentRoot } from "../../lib/content-adapter.js";
39
60
  import { resolveLocale } from "../../lib/locales.js";
40
61
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
41
62
 
@@ -64,6 +85,67 @@ function hasPageSource(
64
85
  );
65
86
  }
66
87
 
88
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
89
+
90
+ /** `_site.mdx` per content root, read once; `null` when it does not load. */
91
+ const siteConfigByRoot = new Map<string, SiteConfig | null>();
92
+
93
+ /**
94
+ * The deployment's `_site.mdx`, validated against `SiteConfigSchema`, or
95
+ * `undefined` on any failure (absent, unreadable, invalid). A local read with
96
+ * the client-safe `/schema` entry: `@working-theory/site-config/server` imports
97
+ * `server-only`, which throws outside a React Server Components runtime.
98
+ */
99
+ function readSiteConfig(contentRoot: string): SiteConfig | undefined {
100
+ if (!siteConfigByRoot.has(contentRoot)) {
101
+ let config: SiteConfig | null = null;
102
+ try {
103
+ const raw = readFileSync(join(contentRoot, "_site.mdx"), "utf8");
104
+ const match = FRONTMATTER_RE.exec(raw);
105
+ if (match) {
106
+ const parsed = SiteConfigSchema.safeParse(parseYaml(match[1] ?? ""));
107
+ if (parsed.success) config = parsed.data;
108
+ }
109
+ } catch {
110
+ // Absent or unreadable — no site.
111
+ }
112
+ siteConfigByRoot.set(contentRoot, config);
113
+ }
114
+ return siteConfigByRoot.get(contentRoot) ?? undefined;
115
+ }
116
+
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
+
67
149
  export const getPageRoute: FastifyPluginAsync = async (fastify) => {
68
150
  fastify.get<{
69
151
  Params: { type: string; slug: string };
@@ -165,7 +247,9 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
165
247
  // now preserves `sections`; before, it was stripped at validation.
166
248
  ...(page.sections !== undefined && { sections: page.sections }),
167
249
  };
168
- const jsonLd = buildJsonLd(input);
250
+ const primary = buildJsonLd(input);
251
+ const jsonLd =
252
+ page.type === "HomePage" ? homePageGraph(primary, page.image) : primary;
169
253
  return reply.send({
170
254
  ...jsonLd,
171
255
  page,
@@ -23,6 +23,7 @@ import { config } from "./config/index.js";
23
23
  import { isModuleEnabled } from "./config/modules.js";
24
24
  import { registerErrorSerializer } from "./lib/error-serializer.js";
25
25
  import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
26
+ import { registerLicenseKeyStore } from "./lib/license-key-store.js";
26
27
  import { requireCommerceClaim } from "./lib/require-commerce-claim.js";
27
28
  import { assertSiteAliasResolves } from "./lib/site.js";
28
29
  import { loadSlice } from "./lib/slice-load.js";
@@ -522,6 +523,11 @@ export async function build() {
522
523
  // database-less dev boot).
523
524
  await assertSiteAliasResolves();
524
525
 
526
+ // #6677 DD-6 — the renewed licence key's durable home: with a database,
527
+ // one deployment-wide SiteSetting row, so a redeploy keeps the key this
528
+ // site renewed. Never throws; without DATABASE_URL the file store stays.
529
+ await registerLicenseKeyStore();
530
+
525
531
  // Runtime sandbox-mode guard: reject live-mode Stripe keys before any
526
532
  // webhook or checkout call can exercise them. Idempotent — safe even if
527
533
  // assertSandboxKey is called again inside a route handler. UNCONDITIONAL
@@ -15,9 +15,17 @@
15
15
  * - No fetch() call (structural test)
16
16
  */
17
17
 
18
- import { readFileSync } from "node:fs";
18
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
19
20
  import { resolve, join } from "node:path";
20
21
 
22
+ import {
23
+ entityMarkup,
24
+ runAudit,
25
+ schemaCoverage,
26
+ type AuditReport,
27
+ } from "@working-theory/aeo";
28
+
21
29
  import { buildSinglePageAuditInput } from "../src/lib/audit-single-page.js";
22
30
 
23
31
  const REPO_ROOT = resolve(__dirname, "../../..");
@@ -211,3 +219,69 @@ describe("structural guard — no fetch()", () => {
211
219
  expect(codeOnly).not.toMatch(/\bfetch\s*\(/);
212
220
  });
213
221
  });
222
+
223
+ describe("buildSinglePageAuditInput — sitewide identity (#6597 AC-7)", () => {
224
+ const evidenceOf = (report: AuditReport, id: string) =>
225
+ report.perRule.find((r) => r.id === id);
226
+ const auditOf = async (input: { slug: string; type?: "AboutPage" }, root: string) =>
227
+ runAudit(await buildSinglePageAuditInput(input, root), [schemaCoverage, entityMarkup]);
228
+
229
+ it("schema-coverage passes for the monorepo's AboutPage/about", async () => {
230
+ const report = await auditOf({ slug: "about", type: "AboutPage" }, CONTENT_ROOT);
231
+ expect(evidenceOf(report, "schema-coverage")?.pass).toBe(true);
232
+ });
233
+
234
+ it("the monorepo home finds the identity: Identity (organization) ✓", async () => {
235
+ const report = await auditOf({ slug: "home" }, CONTENT_ROOT);
236
+ expect(evidenceOf(report, "entity-markup")?.evidence).toContain(
237
+ "Identity (organization) ✓",
238
+ );
239
+ });
240
+
241
+ it("stores the flat node array, the page's own primary first", async () => {
242
+ const input = await buildSinglePageAuditInput({ slug: "about", type: "AboutPage" }, CONTENT_ROOT);
243
+ const nodes = input.jsonLdByPage["AboutPage/about"] as Array<Record<string, unknown>>;
244
+ expect(nodes[0]!["@type"]).toBe("AboutPage");
245
+ expect(nodes.map((n) => n["@type"])).toEqual(
246
+ expect.arrayContaining(["Organization", "WebSite"]),
247
+ );
248
+ });
249
+
250
+ describe("a Person site", () => {
251
+ let root: string;
252
+ beforeAll(() => {
253
+ root = mkdtempSync(join(tmpdir(), "mcp-6597-"));
254
+ writeFileSync(
255
+ join(root, "_site.mdx"),
256
+ [
257
+ "---",
258
+ 'version: "1"',
259
+ "siteName: Ada",
260
+ "baseUrl: https://example.com",
261
+ "defaultLocale: en",
262
+ "siteIdentity:",
263
+ " kind: Person",
264
+ " name: Ada Lovelace",
265
+ " url: https://example.com",
266
+ "---",
267
+ "",
268
+ ].join("\n"),
269
+ );
270
+ mkdirSync(join(root, "en", "HomePage"), { recursive: true });
271
+ writeFileSync(
272
+ join(root, "en", "HomePage", "home.mdx"),
273
+ ["---", "type: HomePage", "slug: home", "title: Ada", "description: Ada's site.", "---", ""].join("\n"),
274
+ );
275
+ });
276
+ afterAll(() => {
277
+ rmSync(root, { recursive: true, force: true });
278
+ });
279
+
280
+ it("derives person mode from the kind: Identity (person) ✓", async () => {
281
+ const input = await buildSinglePageAuditInput({ slug: "home" }, root);
282
+ expect(input.identityMode).toBe("person");
283
+ const report = runAudit(input, [entityMarkup]);
284
+ expect(evidenceOf(report, "entity-markup")?.evidence).toContain("Identity (person) ✓");
285
+ });
286
+ });
287
+ });
@@ -10,18 +10,18 @@
10
10
  * error, never logged. This is the ordinary "site has nothing of this
11
11
  * type" case every real adapter (MDX, database) takes today.
12
12
  * - a type whose adapter call THROWS is a genuine failure. It must not take
13
- * down the audit (the other four reads still complete and their pages
13
+ * down the audit (the other two reads still complete and their pages
14
14
  * still count), but it must be visible — logged via `console.error`
15
15
  * (stderr, NOT stdout — the default stdio MCP transport's JSON-RPC
16
16
  * framing channel, #6012 round 2) — rather than silently collapsing into
17
17
  * the same vacuous "nothing found" result.
18
18
  *
19
- * The call-count assertions below prove that all five reads still ran despite
19
+ * The call-count assertions below prove that all three reads still ran despite
20
20
  * the throw — the read AFTER the failing one is not skipped, which a
21
21
  * sequential loop that ABORTED on the first throw would fail to show. They
22
22
  * are NOT, on their own, proof of CONCURRENCY: the prior sequential `for`
23
23
  * loop this replaced used `continue` (not a re-throw), so it too issued all
24
- * five calls. The `Promise.all` in the source is what delivers the
24
+ * three calls. The `Promise.all` in the source is what delivers the
25
25
  * concurrency AC; these assertions cover the narrower, still load-bearing
26
26
  * property that a throw part-way through never truncates the scan.
27
27
  */
@@ -93,10 +93,10 @@ describe("scanSitePages — type-not-registered vs adapter-threw (#6012)", () =>
93
93
 
94
94
  expect(result.readiness!.sitePages).toEqual([]);
95
95
  expect(errorSpy).not.toHaveBeenCalled();
96
- // All five reads still ran — two listDetail (Product, Service), three
97
- // listPages (the three policy types).
96
+ // All three reads still ran — two listDetail (Product, Service), one
97
+ // listPages (PurchasePolicyPage, #6646).
98
98
  expect(listDetail).toHaveBeenCalledTimes(2);
99
- expect(listPages).toHaveBeenCalledTimes(3);
99
+ expect(listPages).toHaveBeenCalledTimes(1);
100
100
  });
101
101
 
102
102
  it("a type whose adapter call throws is logged to stderr and does not fail the audit", async () => {
@@ -135,9 +135,57 @@ describe("scanSitePages — type-not-registered vs adapter-threw (#6012)", () =>
135
135
  // the stdio transport's JSON-RPC channel).
136
136
  expect(errorSpy).toHaveBeenCalledTimes(1);
137
137
  expect(errorSpy.mock.calls[0]![0]).toMatch(/Service/);
138
- // All five reads still ran despite the throw — the read AFTER the
139
- // throwing one (ReturnsPolicyPage, et al.) was not skipped.
138
+ // All three reads still ran despite the throw — the read AFTER the
139
+ // throwing one (PurchasePolicyPage) was not skipped.
140
140
  expect(listDetail).toHaveBeenCalledTimes(2);
141
- expect(listPages).toHaveBeenCalledTimes(3);
141
+ expect(listPages).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("a PurchasePolicyPage contributes its document-chassis section ids (#6646)", async () => {
145
+ const purchasePolicy: ContentPage = {
146
+ type: "PurchasePolicyPage",
147
+ slug: "purchase-policy",
148
+ title: "Purchase Policy",
149
+ description: "Returns, shipping and refunds.",
150
+ sections: [
151
+ {
152
+ id: "purchase-policy-document",
153
+ type: "document-chassis",
154
+ data: {
155
+ headline: "Purchase Policy",
156
+ sections: [
157
+ { id: "returns", heading: "Returns", blocks: [] },
158
+ { id: "shipping", heading: "Shipping", blocks: [] },
159
+ { id: "refunds", heading: "Refunds", blocks: [] },
160
+ ],
161
+ },
162
+ },
163
+ ] as ContentPage["sections"],
164
+ };
165
+ mockResolveContentAdapter.mockReturnValue({
166
+ getPageBySlug: jest.fn().mockResolvedValue(HOME_PAGE),
167
+ listPages: jest.fn().mockResolvedValue([purchasePolicy]),
168
+ listDetail: jest.fn().mockResolvedValue([]),
169
+ getDetailBySlug: jest.fn(),
170
+ searchPages: jest.fn(),
171
+ extractStructuredData: jest.fn(),
172
+ createPage: jest.fn(),
173
+ updatePage: jest.fn(),
174
+ deletePage: jest.fn(),
175
+ getPageVersion: jest.fn(),
176
+ });
177
+
178
+ const result = await buildSinglePageAuditInput({
179
+ slug: "home",
180
+ type: "HomePage",
181
+ });
182
+
183
+ expect(result.readiness!.sitePages).toEqual([
184
+ {
185
+ slug: "purchase-policy",
186
+ type: "PurchasePolicyPage",
187
+ chassisSectionIds: ["returns", "shipping", "refunds"],
188
+ },
189
+ ]);
142
190
  });
143
191
  });
@@ -9,7 +9,8 @@
9
9
  "lint": "eslint .",
10
10
  "build": "tsc -b",
11
11
  "type-check": "tsc --noEmit",
12
- "dev": "node --experimental-vm-modules dist/index.js",
12
+ "dev": "tsx watch src/index.ts",
13
+ "start": "node --experimental-vm-modules dist/index.js",
13
14
  "test": "jest",
14
15
  "test:watch": "jest --watch",
15
16
  "test:coverage": "jest --coverage"
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { initMcpCommerceClaim } from "./lib/commerce-claim.js";
25
+ import { registerLicenseKeyStore } from "./lib/license-key-store.js";
25
26
  import { initMcpPublicWorkspace } from "./lib/public-workspace.js";
26
27
 
27
28
  // Resolves the MCP server's own workspace ONCE, before either transport
@@ -48,6 +49,12 @@ try {
48
49
  );
49
50
  }
50
51
 
52
+ // Registers the renewed licence key's durable home (#6677 DD-6) BEFORE the
53
+ // commerce claim below memoises the licence: with a database, the key a
54
+ // sibling app renewed lives in one SiteSetting row, and the claim must read
55
+ // it. Never throws; without DATABASE_URL the file store stays in place.
56
+ await registerLicenseKeyStore();
57
+
51
58
  // Resolves the deployment's `commerce` add-on claim ONCE, before either
52
59
  // transport branch — the sibling of the workspace resolution above and the
53
60
  // same #5876 idiom (#5971 spec D2). Without this await the memoised claim is