create-ailk 0.5.0 → 0.6.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 (299) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/lib/api-boot-assertion.d.ts +46 -0
  5. package/dist/lib/api-boot-assertion.js +70 -0
  6. package/dist/lib/import-graph.d.ts +29 -0
  7. package/dist/lib/import-graph.js +140 -0
  8. package/dist/module-architecture.d.ts +1 -0
  9. package/dist/module-architecture.js +253 -127
  10. package/dist/surfaces.js +105 -7
  11. package/dist/template-manifest.d.ts +33 -0
  12. package/dist/template-manifest.js +99 -16
  13. package/dist/template-names.d.ts +1 -1
  14. package/dist/template-names.js +1 -0
  15. package/package.json +3 -2
  16. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  17. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  18. package/site-templates/marketing-site/ailk-template.json +1 -1
  19. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  20. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  21. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  22. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  23. package/site-templates/minimalism-site/ailk-template.json +2 -1
  24. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  25. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  26. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  27. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  28. package/site-templates/simple-service/ailk-template.json +10 -0
  29. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  30. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  31. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  32. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  33. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  34. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  35. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  36. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  37. package/site-templates/simple-service/provenance.json +4 -0
  38. package/templates/.env.example +72 -7
  39. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  40. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  41. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  42. package/templates/apps/api/.env.example +13 -0
  43. package/templates/apps/api/CLAUDE.md +26 -2
  44. package/templates/apps/api/jest.config.cjs +3 -0
  45. package/templates/apps/api/package.json +1 -0
  46. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  47. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  48. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  49. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  50. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  51. package/templates/apps/api/src/config/index.ts +62 -0
  52. package/templates/apps/api/src/config/modules.ts +7 -3
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  54. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  55. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  56. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  57. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  58. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  59. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  60. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  61. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  62. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  63. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  64. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  65. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  66. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  67. package/templates/apps/api/src/lib/site.ts +208 -9
  68. package/templates/apps/api/src/lib/stripe.ts +66 -0
  69. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  70. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  71. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  72. package/templates/apps/api/src/openapi/spec.ts +543 -2
  73. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  74. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  75. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  76. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  77. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  78. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  80. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  81. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  82. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  83. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  84. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  85. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  87. package/templates/apps/api/src/routes/content/index.ts +13 -73
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  89. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  90. package/templates/apps/api/src/routes/content/search.ts +129 -0
  91. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  92. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  93. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  94. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  95. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  96. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  97. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  98. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  99. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  100. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  101. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  102. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  103. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  104. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  105. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  106. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  111. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  112. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  113. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  114. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  115. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  116. package/templates/apps/api/src/server.ts +439 -49
  117. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  118. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  119. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  120. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  121. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  122. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  123. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  124. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  125. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  126. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  127. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  128. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  129. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  130. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  131. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  132. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  133. package/templates/apps/api/src/services/audit-log.ts +668 -0
  134. package/templates/apps/api/src/services/consent.ts +18 -0
  135. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  136. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  137. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  138. package/templates/apps/api/src/services/entitlements.ts +80 -0
  139. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  140. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  141. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  142. package/templates/apps/api/src/services/listing-config.ts +27 -9
  143. package/templates/apps/api/src/services/project-listings.ts +201 -6
  144. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  145. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  146. package/templates/apps/api/src/services/revalidation.ts +173 -0
  147. package/templates/apps/api/src/services/verified-email.ts +71 -0
  148. package/templates/apps/api/src/vercel-handler.ts +75 -35
  149. package/templates/apps/mcp/.env.example +17 -0
  150. package/templates/apps/mcp/CLAUDE.md +57 -24
  151. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  152. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  153. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  154. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  155. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  156. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  157. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  158. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  159. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  160. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  161. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  162. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  163. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  164. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  165. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  166. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  167. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  168. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  169. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  170. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  171. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  172. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  173. package/templates/apps/mcp/jest.config.cjs +13 -0
  174. package/templates/apps/mcp/package.json +2 -0
  175. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  176. package/templates/apps/mcp/src/config/modules.ts +10 -5
  177. package/templates/apps/mcp/src/http-transport.ts +102 -29
  178. package/templates/apps/mcp/src/index.ts +73 -11
  179. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  180. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  181. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  182. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  183. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  184. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  185. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  186. package/templates/apps/mcp/src/server.ts +59 -35
  187. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  188. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  189. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  190. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  191. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  192. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  193. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  194. package/templates/apps/mcp/src/tools/index.ts +6 -4
  195. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  196. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  197. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  198. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  199. package/templates/apps/web/.env.example +71 -5
  200. package/templates/apps/web/CLAUDE.md +44 -0
  201. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  202. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  203. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  204. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  205. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  206. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  207. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  208. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  209. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  210. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  211. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  212. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  213. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  214. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  215. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  216. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  217. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  218. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  219. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  220. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  221. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  222. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  223. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  224. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  225. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  226. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  227. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  228. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  229. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  230. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  232. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  233. package/templates/apps/web/auth.ts +407 -8
  234. package/templates/apps/web/jest.config.cjs +8 -4
  235. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  236. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  237. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  238. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  239. package/templates/apps/web/lib/auth-email.ts +318 -0
  240. package/templates/apps/web/lib/auth-env.ts +85 -0
  241. package/templates/apps/web/lib/auth-routes.ts +55 -0
  242. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  243. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  244. package/templates/apps/web/messages/en.json +187 -0
  245. package/templates/apps/web/package.json +2 -1
  246. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  247. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  248. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  249. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  250. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  251. package/templates/database/CHANGELOG.md +142 -0
  252. package/templates/database/README.md +10 -3
  253. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  254. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  255. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  256. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  257. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  258. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  259. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  260. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  261. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  262. package/templates/database/__tests__/tenancy.test.ts +141 -0
  263. package/templates/database/config/schema.prisma +79 -2
  264. package/templates/database/content/schema.prisma +87 -11
  265. package/templates/database/inbox/schema.prisma +452 -9
  266. package/templates/database/jest.config.cjs +5 -3
  267. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  268. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  269. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  270. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  271. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  272. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  273. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  274. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  275. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  276. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  277. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  278. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  279. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  280. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  281. package/templates/database/package.json +1 -1
  282. package/templates/database/tenancy.ts +86 -1
  283. package/templates/database/tsconfig.json +1 -0
  284. package/versions.json +34 -34
  285. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  286. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  287. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  288. package/templates/apps/api/src/routes/content/create.ts +0 -115
  289. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  290. package/templates/apps/api/src/routes/content/update.ts +0 -112
  291. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  292. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  293. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  294. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  295. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  296. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  297. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  298. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  299. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -0,0 +1,670 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * Tests for the /api/revalidate receiver (#5877, spec
7
+ * docs/specs/5877-revalidate-receiver-spec.md).
8
+ *
9
+ * Contract (AC from the spec):
10
+ * - AC-1 — a POST whose signature verifies against CONTENT_REVALIDATE_SECRET
11
+ * calls revalidateTag exactly once for the page tag and once for the list
12
+ * tag cache-tags.ts derives from the body, and returns 200. The signature
13
+ * is produced here by an INDEPENDENT fake signer (node:crypto, never the
14
+ * route's own helper), which is what makes the assertion meaningful.
15
+ * - AC-2 — the 503 → 401 → 400 ladder (NFR2), each rung asserting that
16
+ * revalidateTag was not called, and the unset-secret rung asserting
17
+ * exactly one log line.
18
+ * - AC-3 — the AC-WCE-02 publish-to-live harness, env-gated and SKIPPED by
19
+ * default (D12). It needs a live CONTENT_ADAPTER=database deployment; see
20
+ * the block at the bottom of this file.
21
+ *
22
+ * `next/cache` is mocked because the real revalidateTag throws
23
+ * "Invariant: static generation store missing" outside a Next server context
24
+ * (next@16.3.3 dist/server/web/spec-extension/revalidate.js:110-117) — and
25
+ * mocking it is also what lets "exactly once per tag" be asserted by call
26
+ * count (D11). The node environment is required because jsdom exposes no
27
+ * WHATWG Request/Response.
28
+ *
29
+ * Timing-safety (NFR1) is scored by ONE structural assertion plus code
30
+ * review; the spec does not claim more. No black-box input can distinguish a
31
+ * constant-time compare from a short-circuiting one, because a same-length
32
+ * wrong signature returns 401 either way. The assertion below is therefore a
33
+ * regression guard against a later edit, read off the route module's AST
34
+ * rather than by text grep — D3's own chosen code contains a legitimate `!==`
35
+ * between the two buffers' LENGTHS.
36
+ */
37
+
38
+ // --- Mocks set up BEFORE imports ---
39
+
40
+ jest.mock("next/cache", () => ({
41
+ __esModule: true,
42
+ revalidateTag: jest.fn(),
43
+ }));
44
+
45
+ import { createHmac } from "node:crypto";
46
+ import { readFileSync } from "node:fs";
47
+ import { join } from "node:path";
48
+
49
+ import { contentListTag, contentPageTag } from "@working-theory/page-renderer";
50
+ import { revalidateTag } from "next/cache";
51
+ import * as ts from "typescript";
52
+
53
+ import { POST } from "../route";
54
+
55
+ const mockRevalidateTag = revalidateTag as jest.Mock;
56
+
57
+ // Deliberately low-entropy, dictionary-word fixtures: a random-looking value
58
+ // here trips the repo's gitleaks `generic-api-key` rule, and an allowlist entry
59
+ // for a fake secret would weaken the scan for a real one.
60
+ const SECRET = "test-secret-the-right-one";
61
+ const WRONG_SECRET = "test-secret-the-other-one";
62
+
63
+ const VALID_BODY = {
64
+ workspaceId: "ws_fixture",
65
+ type: "Service",
66
+ slug: "some-slug",
67
+ locale: "en",
68
+ };
69
+
70
+ /**
71
+ * The fake signer — AC-1's "proved by test against a fake signer". It is the
72
+ * #5851 wire contract written out here independently: HMAC-SHA256 over the
73
+ * exact body bytes, lowercase hex, `sha256=` prefix.
74
+ */
75
+ function sign(rawBody: string, secret: string): string {
76
+ return `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
77
+ }
78
+
79
+ function makeRequest(opts: {
80
+ rawBody: string;
81
+ signature?: string;
82
+ event?: string;
83
+ requestId?: string;
84
+ }): Request {
85
+ const headers: Record<string, string> = {
86
+ "content-type": "application/json",
87
+ };
88
+ if (opts.signature !== undefined) {
89
+ headers["x-ailk-revalidate-signature"] = opts.signature;
90
+ }
91
+ if (opts.event !== undefined) {
92
+ headers["x-ailk-revalidate-event"] = opts.event;
93
+ }
94
+ if (opts.requestId !== undefined) {
95
+ headers["x-request-id"] = opts.requestId;
96
+ }
97
+ return new Request("http://localhost/api/revalidate", {
98
+ method: "POST",
99
+ headers,
100
+ body: opts.rawBody,
101
+ });
102
+ }
103
+
104
+ /** A correctly-signed POST of `body` under `secret`. */
105
+ function signedRequest(
106
+ body: unknown,
107
+ secret: string,
108
+ event = "publish",
109
+ ): Request {
110
+ const rawBody = JSON.stringify(body);
111
+ return makeRequest({ rawBody, signature: sign(rawBody, secret), event });
112
+ }
113
+
114
+ describe("POST /api/revalidate", () => {
115
+ const originalSecret = process.env.CONTENT_REVALIDATE_SECRET;
116
+
117
+ beforeEach(() => {
118
+ process.env.CONTENT_REVALIDATE_SECRET = SECRET;
119
+ mockRevalidateTag.mockClear();
120
+ jest.spyOn(console, "error").mockImplementation(() => {});
121
+ jest.spyOn(console, "warn").mockImplementation(() => {});
122
+ jest.spyOn(console, "info").mockImplementation(() => {});
123
+ });
124
+
125
+ afterEach(() => {
126
+ jest.restoreAllMocks();
127
+ if (originalSecret === undefined) {
128
+ delete process.env.CONTENT_REVALIDATE_SECRET;
129
+ } else {
130
+ process.env.CONTENT_REVALIDATE_SECRET = originalSecret;
131
+ }
132
+ });
133
+
134
+ // --- AC-1 ---------------------------------------------------------------
135
+
136
+ describe("AC-1 — a verified POST purges both tags and returns 200", () => {
137
+ it("returns 200 with { revalidated: true }", async () => {
138
+ const response = await POST(signedRequest(VALID_BODY, SECRET));
139
+
140
+ expect(response.status).toBe(200);
141
+ await expect(response.json()).resolves.toEqual({ revalidated: true });
142
+ });
143
+
144
+ it("calls revalidateTag exactly twice — once per tag, each with { expire: 0 }", async () => {
145
+ await POST(signedRequest(VALID_BODY, SECRET));
146
+
147
+ // The expected tags are obtained by CALLING cache-tags.ts's own builders,
148
+ // never typed as literals — which is what keeps the assertion independent
149
+ // of the {workspace} segment's value (D5).
150
+ const pageTag = contentPageTag({
151
+ type: VALID_BODY.type,
152
+ slug: VALID_BODY.slug,
153
+ locale: VALID_BODY.locale,
154
+ });
155
+ const listTag = contentListTag({ type: VALID_BODY.type });
156
+
157
+ expect(mockRevalidateTag).toHaveBeenCalledTimes(2);
158
+ expect(mockRevalidateTag).toHaveBeenCalledWith(pageTag, { expire: 0 });
159
+ expect(mockRevalidateTag).toHaveBeenCalledWith(listTag, { expire: 0 });
160
+ });
161
+
162
+ it("purges tags built from the body's own type/slug/locale", async () => {
163
+ const body = {
164
+ workspaceId: "ws_other",
165
+ type: "Article",
166
+ slug: "another-slug",
167
+ locale: "fr",
168
+ };
169
+
170
+ await POST(signedRequest(body, SECRET));
171
+
172
+ expect(mockRevalidateTag).toHaveBeenCalledWith(
173
+ contentPageTag({ type: "Article", slug: "another-slug", locale: "fr" }),
174
+ { expire: 0 },
175
+ );
176
+ expect(mockRevalidateTag).toHaveBeenCalledWith(
177
+ contentListTag({ type: "Article" }),
178
+ { expire: 0 },
179
+ );
180
+ });
181
+
182
+ it("treats publish and unpublish identically — one code path (FR7, D6)", async () => {
183
+ await POST(signedRequest(VALID_BODY, SECRET, "publish"));
184
+ const publishCalls = mockRevalidateTag.mock.calls;
185
+
186
+ mockRevalidateTag.mockClear();
187
+
188
+ const response = await POST(
189
+ signedRequest(VALID_BODY, SECRET, "unpublish"),
190
+ );
191
+
192
+ expect(response.status).toBe(200);
193
+ expect(mockRevalidateTag.mock.calls).toEqual(publishCalls);
194
+ });
195
+
196
+ it("accepts a request with no event header, logging it as unknown (FR7)", async () => {
197
+ const rawBody = JSON.stringify(VALID_BODY);
198
+ const response = await POST(
199
+ makeRequest({ rawBody, signature: sign(rawBody, SECRET) }),
200
+ );
201
+
202
+ expect(response.status).toBe(200);
203
+ expect(mockRevalidateTag).toHaveBeenCalledTimes(2);
204
+ expect(console.info).toHaveBeenCalledTimes(1);
205
+ expect((console.info as jest.Mock).mock.calls[0][0]).toContain("unknown");
206
+ });
207
+
208
+ it("is idempotent under retry — a second identical delivery purges the same tags (NFR3)", async () => {
209
+ await POST(signedRequest(VALID_BODY, SECRET));
210
+ const firstCalls = mockRevalidateTag.mock.calls;
211
+ mockRevalidateTag.mockClear();
212
+
213
+ const second = await POST(signedRequest(VALID_BODY, SECRET));
214
+
215
+ expect(second.status).toBe(200);
216
+ expect(mockRevalidateTag.mock.calls).toEqual(firstCalls);
217
+ });
218
+
219
+ it("logs one line prefixed [revalidate] carrying the correlation id (FR8, D8)", async () => {
220
+ const rawBody = JSON.stringify(VALID_BODY);
221
+ await POST(
222
+ makeRequest({
223
+ rawBody,
224
+ signature: sign(rawBody, SECRET),
225
+ event: "publish",
226
+ requestId: "req-abc-123",
227
+ }),
228
+ );
229
+
230
+ expect(console.info).toHaveBeenCalledTimes(1);
231
+ const line = (console.info as jest.Mock).mock.calls[0][0] as string;
232
+ expect(line).toContain("[revalidate]");
233
+ expect(line).toContain("req-abc-123");
234
+ });
235
+
236
+ it("never logs the secret or the signature header value (FR9)", async () => {
237
+ const rawBody = JSON.stringify(VALID_BODY);
238
+ const signature = sign(rawBody, SECRET);
239
+
240
+ await POST(makeRequest({ rawBody, signature, event: "publish" }));
241
+
242
+ const logged = [
243
+ ...(console.info as jest.Mock).mock.calls,
244
+ ...(console.warn as jest.Mock).mock.calls,
245
+ ...(console.error as jest.Mock).mock.calls,
246
+ ]
247
+ .flat()
248
+ .map((arg) => String(arg))
249
+ .join("\n");
250
+
251
+ expect(logged).not.toContain(SECRET);
252
+ expect(logged).not.toContain(signature);
253
+ });
254
+ });
255
+
256
+ // --- AC-1, NFR1: the structural timing-safety guard ---------------------
257
+
258
+ describe("NFR1 — the comparison is timing-safe", () => {
259
+ const sourceFile = ts.createSourceFile(
260
+ "route.ts",
261
+ readFileSync(join(__dirname, "..", "route.ts"), "utf8"),
262
+ ts.ScriptTarget.ESNext,
263
+ );
264
+
265
+ function walk(node: ts.Node, visit: (n: ts.Node) => void): void {
266
+ visit(node);
267
+ ts.forEachChild(node, (child) => walk(child, visit));
268
+ }
269
+
270
+ /** The base identifier of `a`, `a.b` or `a.b.c` — `undefined` otherwise. */
271
+ function baseIdentifier(node: ts.Node): string | undefined {
272
+ let current: ts.Node = node;
273
+ while (ts.isPropertyAccessExpression(current)) {
274
+ current = current.expression;
275
+ }
276
+ return ts.isIdentifier(current) ? current.text : undefined;
277
+ }
278
+
279
+ const isLengthAccess = (node: ts.Node): boolean =>
280
+ ts.isPropertyAccessExpression(node) && node.name.text === "length";
281
+
282
+ const EQUALITY_OPERATORS = new Set<ts.SyntaxKind>([
283
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
284
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
285
+ ]);
286
+
287
+ // One traversal serves both tests below: the names timingSafeEqual is
288
+ // called with, every local declaration's initialiser, and every strict
289
+ // equality comparison in the module.
290
+ const comparedNames: string[] = [];
291
+ const declaredFrom = new Map<string, string[]>();
292
+ const equalities: ts.BinaryExpression[] = [];
293
+
294
+ walk(sourceFile, (node) => {
295
+ if (
296
+ ts.isCallExpression(node) &&
297
+ baseIdentifier(node.expression) === "timingSafeEqual"
298
+ ) {
299
+ for (const arg of node.arguments) {
300
+ const name = baseIdentifier(arg);
301
+ if (name !== undefined) comparedNames.push(name);
302
+ }
303
+ return;
304
+ }
305
+ if (
306
+ ts.isVariableDeclaration(node) &&
307
+ ts.isIdentifier(node.name) &&
308
+ node.initializer !== undefined
309
+ ) {
310
+ const reads: string[] = [];
311
+ walk(node.initializer, (inner) => {
312
+ if (ts.isIdentifier(inner)) reads.push(inner.text);
313
+ });
314
+ declaredFrom.set(node.name.text, reads);
315
+ return;
316
+ }
317
+ if (
318
+ ts.isBinaryExpression(node) &&
319
+ EQUALITY_OPERATORS.has(node.operatorToken.kind)
320
+ ) {
321
+ equalities.push(node);
322
+ }
323
+ });
324
+
325
+ /**
326
+ * The names that carry the two signature VALUES: what `timingSafeEqual` is
327
+ * called with, plus the locals whose initialisers those bindings are built
328
+ * from — one hop, which is what separates `expectedBuf`/`providedBuf` from
329
+ * the strings behind them. Derived from the AST, never hard-coded, so a
330
+ * rename cannot silently defeat the guard.
331
+ */
332
+ function signatureValueNames(): Set<string> {
333
+ const names = new Set(comparedNames);
334
+ for (const compared of comparedNames) {
335
+ for (const read of declaredFrom.get(compared) ?? []) {
336
+ if (declaredFrom.has(read)) names.add(read);
337
+ }
338
+ }
339
+ return names;
340
+ }
341
+
342
+ it("calls timingSafeEqual", () => {
343
+ expect(comparedNames.length).toBeGreaterThan(0);
344
+ });
345
+
346
+ it("compares no two signature values with === or !== (only their lengths)", () => {
347
+ const names = signatureValueNames();
348
+ expect(names.size).toBeGreaterThan(0);
349
+
350
+ const offenders = equalities
351
+ .filter((node) => {
352
+ const left = baseIdentifier(node.left);
353
+ const right = baseIdentifier(node.right);
354
+ return (
355
+ left !== undefined &&
356
+ right !== undefined &&
357
+ names.has(left) &&
358
+ names.has(right) &&
359
+ !isLengthAccess(node.left) &&
360
+ !isLengthAccess(node.right)
361
+ );
362
+ })
363
+ .map((node) => node.getText(sourceFile));
364
+
365
+ expect(offenders).toEqual([]);
366
+ });
367
+ });
368
+
369
+ // --- AC-2 ---------------------------------------------------------------
370
+
371
+ describe("AC-2 — the 503 → 401 → 400 ladder, never revalidating unsigned", () => {
372
+ it("returns 503 and logs exactly once with the secret unset, without revalidating (FR2, D10)", async () => {
373
+ delete process.env.CONTENT_REVALIDATE_SECRET;
374
+
375
+ const response = await POST(signedRequest(VALID_BODY, SECRET));
376
+
377
+ expect(response.status).toBe(503);
378
+ await expect(response.json()).resolves.toEqual({
379
+ error: "not_configured",
380
+ });
381
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
382
+ expect(console.error).toHaveBeenCalledTimes(1);
383
+ });
384
+
385
+ it("returns 503 with the secret set to the empty string (FR2)", async () => {
386
+ process.env.CONTENT_REVALIDATE_SECRET = "";
387
+
388
+ const response = await POST(signedRequest(VALID_BODY, SECRET));
389
+
390
+ expect(response.status).toBe(503);
391
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("returns 401 when the signature header is absent", async () => {
395
+ const response = await POST(
396
+ makeRequest({
397
+ rawBody: JSON.stringify(VALID_BODY),
398
+ event: "publish",
399
+ }),
400
+ );
401
+
402
+ expect(response.status).toBe(401);
403
+ await expect(response.json()).resolves.toEqual({
404
+ error: "invalid_signature",
405
+ });
406
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it("returns 401 when the signature header is malformed", async () => {
410
+ const rawBody = JSON.stringify(VALID_BODY);
411
+
412
+ for (const malformed of [
413
+ "not-a-signature",
414
+ "sha256=short",
415
+ `sha1=${"a".repeat(64)}`,
416
+ `sha256=${"A".repeat(64)}`, // uppercase hex is not the wire encoding
417
+ sign(rawBody, SECRET).slice("sha256=".length), // prefix stripped
418
+ ]) {
419
+ mockRevalidateTag.mockClear();
420
+
421
+ const response = await POST(
422
+ makeRequest({ rawBody, signature: malformed, event: "publish" }),
423
+ );
424
+
425
+ expect(response.status).toBe(401);
426
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
427
+ }
428
+ });
429
+
430
+ it("returns 401 for a correctly-shaped signature made with the wrong key", async () => {
431
+ const response = await POST(signedRequest(VALID_BODY, WRONG_SECRET));
432
+
433
+ expect(response.status).toBe(401);
434
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
435
+ });
436
+
437
+ it("returns 401 when the body was altered after signing", async () => {
438
+ const signedOver = JSON.stringify(VALID_BODY);
439
+ const delivered = JSON.stringify({ ...VALID_BODY, slug: "tampered" });
440
+
441
+ const response = await POST(
442
+ makeRequest({
443
+ rawBody: delivered,
444
+ signature: sign(signedOver, SECRET),
445
+ event: "publish",
446
+ }),
447
+ );
448
+
449
+ expect(response.status).toBe(401);
450
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
451
+ });
452
+
453
+ it("returns 400 for a correctly-signed body that is not four-key JSON", async () => {
454
+ for (const rawBody of [
455
+ "not json at all",
456
+ "[]",
457
+ "null",
458
+ '"a string"',
459
+ JSON.stringify({ type: "Service", slug: "s", locale: "en" }), // no workspaceId
460
+ JSON.stringify({ ...VALID_BODY, locale: 42 }), // locale not a string
461
+ ]) {
462
+ mockRevalidateTag.mockClear();
463
+
464
+ const response = await POST(
465
+ makeRequest({
466
+ rawBody,
467
+ signature: sign(rawBody, SECRET),
468
+ event: "publish",
469
+ }),
470
+ );
471
+
472
+ expect(response.status).toBe(400);
473
+ await expect(response.json()).resolves.toEqual({
474
+ error: "invalid_body",
475
+ });
476
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
477
+ }
478
+ });
479
+
480
+ it("prefers 401 over 400 — a wrong-key request with a malformed body is 401, never 400 (NFR2)", async () => {
481
+ const rawBody = "not json at all";
482
+
483
+ const response = await POST(
484
+ makeRequest({
485
+ rawBody,
486
+ signature: sign(rawBody, WRONG_SECRET),
487
+ event: "publish",
488
+ }),
489
+ );
490
+
491
+ expect(response.status).toBe(401);
492
+ await expect(response.json()).resolves.toEqual({
493
+ error: "invalid_signature",
494
+ });
495
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
496
+ });
497
+
498
+ it("reads the secret per request, not at module load (D10)", async () => {
499
+ // The module was imported with whatever the ambient env held. Rotating
500
+ // the value between two calls, with no jest.resetModules(), is only
501
+ // observable if the read happens inside the handler.
502
+ delete process.env.CONTENT_REVALIDATE_SECRET;
503
+ expect((await POST(signedRequest(VALID_BODY, SECRET))).status).toBe(503);
504
+
505
+ process.env.CONTENT_REVALIDATE_SECRET = SECRET;
506
+ expect((await POST(signedRequest(VALID_BODY, SECRET))).status).toBe(200);
507
+ });
508
+ });
509
+ });
510
+
511
+ // --- AC-3: the AC-WCE-02 publish-to-live harness (D12) --------------------
512
+
513
+ /**
514
+ * Env-gated and SKIPPED by default. It needs a live CONTENT_ADAPTER=database
515
+ * deployment, a reachable apps/api and a workspace token — none of which a CI
516
+ * job or a developer machine has, so running it unconditionally would be red
517
+ * for infrastructure reasons on every PR (D12). Run it by hand:
518
+ *
519
+ * RUN_WCE02_HARNESS=1 \
520
+ * WCE02_SITE_URL=https://<fixture-site> \
521
+ * WCE02_API_URL=https://<fixture-api> \
522
+ * WCE02_WORKSPACE_ID=<id> \
523
+ * WCE02_API_TOKEN=<token> \
524
+ * pnpm --filter @working-theory/web test -- app/api/revalidate
525
+ *
526
+ * Paste the ten elapsed-ms samples and the computed p95 into the PR body's
527
+ * ## Verification block; AC-3 is scored on that paste and on nothing else.
528
+ *
529
+ * Idiom: packages/create-ailk/__tests__/paid-module-honest-states.test.ts:510.
530
+ */
531
+ const describeHarness =
532
+ process.env["RUN_WCE02_HARNESS"] === "1" ? describe : describe.skip;
533
+
534
+ const HARNESS_ENV = [
535
+ "WCE02_SITE_URL",
536
+ "WCE02_API_URL",
537
+ "WCE02_WORKSPACE_ID",
538
+ "WCE02_API_TOKEN",
539
+ ] as const;
540
+
541
+ const HARNESS_RUNS = 10;
542
+ const HARNESS_BUDGET_MS = 60_000;
543
+ const HARNESS_POLL_INTERVAL_MS = 1_000;
544
+
545
+ describeHarness(
546
+ "AC-3 / AC-WCE-02 — publish-to-live p95 ≤ 60 s on the fixture site",
547
+ () => {
548
+ function requireEnv(): Record<(typeof HARNESS_ENV)[number], string> {
549
+ const missing = HARNESS_ENV.filter(
550
+ (name) => (process.env[name] ?? "") === "",
551
+ );
552
+ if (missing.length > 0) {
553
+ throw new Error(
554
+ `RUN_WCE02_HARNESS=1 but these are unset: ${missing.join(", ")}. ` +
555
+ `The harness needs a live CONTENT_ADAPTER=database deployment; see D12.`,
556
+ );
557
+ }
558
+ return Object.fromEntries(
559
+ HARNESS_ENV.map((name) => [name, process.env[name] as string]),
560
+ ) as Record<(typeof HARNESS_ENV)[number], string>;
561
+ }
562
+
563
+ const FIXTURE_TYPE = process.env["WCE02_TYPE"] ?? "Service";
564
+ const FIXTURE_SLUG = process.env["WCE02_SLUG"] ?? "wce02-fixture";
565
+ const FIXTURE_LOCALE = process.env["WCE02_LOCALE"] ?? "en";
566
+ const publicUrl = (siteUrl: string): string =>
567
+ `${siteUrl.replace(/\/$/, "")}/${FIXTURE_LOCALE}/${FIXTURE_TYPE.toLowerCase()}/${FIXTURE_SLUG}`;
568
+
569
+ /**
570
+ * One resolution of the fixture target, so the API path template
571
+ * `/v1/workspaces/{id}/content/{type}/{slug}` and the auth headers are
572
+ * written once — an API route change cannot break one of the two tests
573
+ * below and not the other.
574
+ */
575
+ function harnessTarget(): {
576
+ contentPath: string;
577
+ authHeaders: Record<string, string>;
578
+ siteUrl: string;
579
+ } {
580
+ const env = requireEnv();
581
+ const api = env.WCE02_API_URL.replace(/\/$/, "");
582
+ return {
583
+ contentPath: `${api}/v1/workspaces/${env.WCE02_WORKSPACE_ID}/content/${FIXTURE_TYPE}/${FIXTURE_SLUG}`,
584
+ authHeaders: {
585
+ authorization: `Bearer ${env.WCE02_API_TOKEN}`,
586
+ "content-type": "application/json",
587
+ },
588
+ siteUrl: env.WCE02_SITE_URL,
589
+ };
590
+ }
591
+
592
+ /** Polls the public URL until `predicate` holds or the budget elapses. */
593
+ async function pollUntil(
594
+ url: string,
595
+ predicate: (body: string) => boolean,
596
+ ): Promise<number> {
597
+ const startedAt = Date.now();
598
+ for (;;) {
599
+ const response = await fetch(url, { cache: "no-store" });
600
+ // Always drained, ok or not: an unconsumed body holds the socket until
601
+ // GC, and the unpublish poll hits the not-ok branch by design.
602
+ const text = await response.text();
603
+ const body = response.ok ? text : "";
604
+ if (predicate(body)) return Date.now() - startedAt;
605
+ if (Date.now() - startedAt >= HARNESS_BUDGET_MS) {
606
+ throw new Error(
607
+ `${url} did not converge within ${HARNESS_BUDGET_MS} ms`,
608
+ );
609
+ }
610
+ await new Promise((resolve) =>
611
+ setTimeout(resolve, HARNESS_POLL_INTERVAL_MS),
612
+ );
613
+ }
614
+ }
615
+
616
+ it(`publishes ${HARNESS_RUNS} times with no deploy and observes each within ${HARNESS_BUDGET_MS} ms`, async () => {
617
+ const { contentPath, authHeaders, siteUrl } = harnessTarget();
618
+ const samples: number[] = [];
619
+
620
+ for (let run = 0; run < HARNESS_RUNS; run += 1) {
621
+ const marker = `wce02-${Date.now()}-${run}`;
622
+
623
+ const draft = await fetch(contentPath, {
624
+ method: "PATCH",
625
+ headers: authHeaders,
626
+ body: JSON.stringify({ body: `# ${marker}\n` }),
627
+ });
628
+ expect(draft.ok).toBe(true);
629
+
630
+ const published = await fetch(`${contentPath}/publish`, {
631
+ method: "POST",
632
+ headers: authHeaders,
633
+ });
634
+ expect(published.ok).toBe(true);
635
+
636
+ samples.push(
637
+ await pollUntil(publicUrl(siteUrl), (body) => body.includes(marker)),
638
+ );
639
+ }
640
+
641
+ // Nearest-rank p95 over ten samples is ceil(0.95 × 10) = 10 — the
642
+ // tenth, i.e. the maximum. So the criterion reduces to "every run
643
+ // within 60 s" (D12).
644
+ const p95 = Math.max(...samples);
645
+ console.info(
646
+ `[wce02] samples(ms)=${samples.join(",")} p95=${p95} budget=${HARNESS_BUDGET_MS}`,
647
+ );
648
+
649
+ expect(p95).toBeLessThanOrEqual(HARNESS_BUDGET_MS);
650
+ }, 90_000);
651
+
652
+ it(`unpublishes and observes the page stop serving within ${HARNESS_BUDGET_MS} ms`, async () => {
653
+ const { contentPath, authHeaders, siteUrl } = harnessTarget();
654
+
655
+ const unpublished = await fetch(`${contentPath}/unpublish`, {
656
+ method: "POST",
657
+ headers: authHeaders,
658
+ });
659
+ expect(unpublished.ok).toBe(true);
660
+
661
+ const elapsed = await pollUntil(
662
+ publicUrl(siteUrl),
663
+ (body) => body === "",
664
+ );
665
+ console.info(`[wce02] unpublish elapsed(ms)=${elapsed}`);
666
+
667
+ expect(elapsed).toBeLessThanOrEqual(HARNESS_BUDGET_MS);
668
+ }, 90_000);
669
+ },
670
+ );