create-ailk 0.7.0 → 0.9.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 (310) hide show
  1. package/README.md +112 -10
  2. package/component-catalog.json +798 -49
  3. package/component-catalog.md +9 -6
  4. package/dist/add-module.d.ts +20 -0
  5. package/dist/add-module.js +43 -7
  6. package/dist/add-route.d.ts +56 -0
  7. package/dist/add-route.js +286 -0
  8. package/dist/apply-design-md.d.ts +7 -0
  9. package/dist/apply-design-md.js +50 -0
  10. package/dist/apply-design.d.ts +64 -0
  11. package/dist/apply-design.js +273 -0
  12. package/dist/claude-md.d.ts +111 -0
  13. package/dist/claude-md.js +334 -0
  14. package/dist/cli.js +114 -28
  15. package/dist/derive-architecture.js +41 -36
  16. package/dist/design-compile.d.ts +71 -0
  17. package/dist/design-compile.js +98 -0
  18. package/dist/design-package.d.ts +83 -0
  19. package/dist/design-package.js +468 -0
  20. package/dist/design-presets.d.ts +86 -0
  21. package/dist/design-presets.js +642 -0
  22. package/dist/design-scaffold.d.ts +76 -0
  23. package/dist/design-scaffold.js +220 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +4 -1
  26. package/dist/legacy-nav-renderers.d.ts +45 -0
  27. package/dist/legacy-nav-renderers.js +545 -0
  28. package/dist/lib/auth-secret-file.d.ts +19 -0
  29. package/dist/lib/auth-secret-file.js +112 -0
  30. package/dist/lib/design-checks.d.ts +78 -0
  31. package/dist/lib/design-checks.js +174 -0
  32. package/dist/lib/extract-module-bundle.js +5 -1
  33. package/dist/lib/license-key-files.d.ts +9 -0
  34. package/dist/lib/license-key-files.js +2 -2
  35. package/dist/lib/module-license-gate.d.ts +89 -0
  36. package/dist/lib/module-license-gate.js +171 -12
  37. package/dist/lib/module-tier.d.ts +45 -16
  38. package/dist/lib/module-tier.js +76 -0
  39. package/dist/lib/pro-callout.js +10 -2
  40. package/dist/lib/render-verdict.d.ts +52 -0
  41. package/dist/lib/render-verdict.js +77 -0
  42. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  43. package/dist/lib/scaffold-render-sweep.js +236 -0
  44. package/dist/module-architecture.d.ts +19 -16
  45. package/dist/module-architecture.js +64 -21
  46. package/dist/parse-args.d.ts +88 -10
  47. package/dist/parse-args.js +272 -21
  48. package/dist/programmatic.d.ts +66 -12
  49. package/dist/programmatic.js +361 -34
  50. package/dist/site-chrome.d.ts +19 -9
  51. package/dist/site-chrome.js +23 -13
  52. package/dist/site-manifest.d.ts +25 -0
  53. package/dist/site-manifest.js +254 -0
  54. package/dist/site-roster.d.ts +41 -0
  55. package/dist/site-roster.js +149 -11
  56. package/dist/starter-grid.d.ts +232 -0
  57. package/dist/starter-grid.js +545 -0
  58. package/dist/surfaces.d.ts +41 -9
  59. package/dist/surfaces.js +268 -92
  60. package/dist/template-manifest.d.ts +65 -29
  61. package/dist/template-manifest.js +161 -382
  62. package/dist/template-names.d.ts +14 -12
  63. package/dist/template-names.js +13 -17
  64. package/package.json +5 -4
  65. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  66. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  67. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  68. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  69. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  70. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  71. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  72. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  73. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  74. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  75. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  76. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  77. package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
  78. package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
  79. package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
  80. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  81. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  82. package/site-templates/designs/minimalism/sample/README.md +48 -0
  83. package/site-templates/designs/standard/DESIGN.md +103 -6
  84. package/site-templates/marketing-site/DESIGN.md +103 -6
  85. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
  86. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
  87. package/site-templates/marketing-site-free/DESIGN.md +103 -6
  88. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
  89. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
  90. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  91. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  92. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  93. package/site-templates/saas-product/DESIGN.md +103 -6
  94. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  95. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
  96. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
  97. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  98. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  99. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  100. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  101. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  102. package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  103. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  104. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  105. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  106. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  107. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  108. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
  109. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
  110. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  111. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  112. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  113. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  114. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  115. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  116. package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
  117. package/site-templates/shapes/organization-service/provenance.json +4 -0
  118. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
  119. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
  120. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  121. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  122. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  123. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  124. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  125. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
  126. package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
  127. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  128. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  129. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  130. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  131. package/site-templates/shapes/person-service/provenance.json +4 -0
  132. package/templates/.claude/rules/architecture.md +7 -0
  133. package/templates/.claude/skills/README.md +5 -3
  134. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  135. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  136. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  137. package/templates/.claude/skills/add-page/references/page-types.md +85 -72
  138. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  139. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  140. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  141. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  142. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  143. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  144. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  145. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  146. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  147. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  148. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  149. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  150. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
  151. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  152. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  153. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  154. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  155. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  156. package/templates/.github/workflows/ci.yml +4 -0
  157. package/templates/.prettierignore +1 -0
  158. package/templates/CLAUDE.md +146 -103
  159. package/templates/DESIGN.md +103 -6
  160. package/templates/README.md +63 -43
  161. package/templates/apps/api/.env.example +2 -1
  162. package/templates/apps/api/CLAUDE.md +3 -2
  163. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  164. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  165. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  166. package/templates/apps/api/src/config/index.ts +35 -16
  167. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  168. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  169. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  170. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  171. package/templates/apps/api/src/lib/stripe.ts +9 -0
  172. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  173. package/templates/apps/api/src/middleware/auth.ts +54 -0
  174. package/templates/apps/api/src/openapi/spec.ts +63 -12
  175. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  176. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  177. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  178. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  179. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  180. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  181. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  182. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  183. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  184. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  185. package/templates/apps/api/src/server.ts +35 -0
  186. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  187. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  188. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  189. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  190. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  191. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  192. package/templates/apps/api/src/services/revalidation.ts +60 -15
  193. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  194. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  195. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  196. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  197. package/templates/apps/web/.env.example +13 -0
  198. package/templates/apps/web/CLAUDE.md +22 -28
  199. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  200. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  201. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  202. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  203. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  204. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  205. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  206. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  207. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  208. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  209. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  210. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  211. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  212. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  213. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  214. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  215. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  216. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  217. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  218. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
  219. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  220. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  221. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  222. package/templates/apps/web/app/[locale]/layout.tsx +65 -17
  223. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  224. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  225. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  226. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  227. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  228. package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
  229. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  230. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  232. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  233. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  234. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  235. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  236. package/templates/apps/web/lib/locale-guard.ts +18 -0
  237. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  238. package/templates/apps/web/lib/nav-config.ts +50 -37
  239. package/templates/apps/web/lib/site-brand.tsx +13 -9
  240. package/templates/apps/web/lib/site-identity.ts +14 -0
  241. package/templates/apps/web/lib/site-logo.tsx +36 -0
  242. package/templates/apps/web/lib/site-theme.ts +46 -26
  243. package/templates/apps/web/messages/en.json +45 -5
  244. package/templates/apps/web/middleware.ts +28 -4
  245. package/templates/content/_site.mdx +4 -2
  246. package/templates/content/en/BlogList/blog.mdx +37 -16
  247. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  248. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  249. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  250. package/templates/content/en/BookCall/start-project.mdx +56 -0
  251. package/templates/content/en/ContactPage/contact.mdx +43 -34
  252. package/templates/content/en/HomePage/home.mdx +10 -22
  253. package/templates/content/en/LandingPage/meet.mdx +59 -0
  254. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  255. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  256. package/templates/database/CHANGELOG.md +14 -0
  257. package/templates/database/package.json +1 -1
  258. package/templates/package.json +2 -1
  259. package/templates/tools/check-claude-md.ts +128 -0
  260. package/versions.json +33 -33
  261. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
  262. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  263. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  264. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  265. package/site-templates/minimalism-site/DESIGN.md +0 -162
  266. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
  267. package/site-templates/minimalism-site/ailk-template.json +0 -23
  268. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  269. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
  270. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  271. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  272. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  273. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  274. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  275. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  276. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  277. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  278. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  279. package/site-templates/minimalism-site/provenance.json +0 -4
  280. package/site-templates/simple-service/DESIGN.md +0 -162
  281. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  282. package/site-templates/simple-service/ailk-template.json +0 -11
  283. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  284. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  285. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  286. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  287. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  288. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  289. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  290. package/site-templates/simple-service/provenance.json +0 -4
  291. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  292. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  293. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  294. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  295. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
  296. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  297. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  298. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  299. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  300. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  301. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  302. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  303. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  304. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  305. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  306. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  307. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
  308. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
  309. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
  310. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
@@ -15,6 +15,10 @@
15
15
  * - AC-2 — the 503 → 401 → 400 ladder (NFR2), each rung asserting that
16
16
  * revalidateTag was not called, and the unset-secret rung asserting
17
17
  * exactly one log line.
18
+ * - #5902 — the signed material is `v0:{timestamp}:{event}:{rawBody}`; a
19
+ * timestamp outside the 5-minute tolerance, a missing or flipped event,
20
+ * and a pre-#5902 body-only signature are all 401; a type, slug or locale
21
+ * outside its bounded shape is 400.
18
22
  * - AC-3 — the AC-WCE-02 publish-to-live harness, env-gated and SKIPPED by
19
23
  * default (D12). It needs a live CONTENT_ADAPTER=database deployment; see
20
24
  * the block at the bottom of this file.
@@ -60,6 +64,12 @@ const mockRevalidateTag = revalidateTag as jest.Mock;
60
64
  const SECRET = "test-secret-the-right-one";
61
65
  const WRONG_SECRET = "test-secret-the-other-one";
62
66
 
67
+ const TOLERANCE_SECONDS = 5 * 60;
68
+
69
+ function nowSeconds(): number {
70
+ return Math.floor(Date.now() / 1000);
71
+ }
72
+
63
73
  const VALID_BODY = {
64
74
  workspaceId: "ws_fixture",
65
75
  type: "Service",
@@ -69,22 +79,32 @@ const VALID_BODY = {
69
79
 
70
80
  /**
71
81
  * 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.
82
+ * #5851 wire contract as amended by #5902, written out here independently:
83
+ * HMAC-SHA256 over `v0:{timestamp}:{event}:{rawBody}`, lowercase hex,
84
+ * `sha256=` prefix.
74
85
  */
75
- function sign(rawBody: string, secret: string): string {
76
- return `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
86
+ function sign(
87
+ rawBody: string,
88
+ secret: string,
89
+ timestamp: string,
90
+ event = "publish",
91
+ ): string {
92
+ return `sha256=${createHmac("sha256", secret).update(`v0:${timestamp}:${event}:${rawBody}`, "utf8").digest("hex")}`;
77
93
  }
78
94
 
79
95
  function makeRequest(opts: {
80
96
  rawBody: string;
81
97
  signature?: string;
82
- event?: string;
98
+ event?: string | undefined;
99
+ timestamp?: string | undefined;
83
100
  requestId?: string;
84
101
  }): Request {
85
102
  const headers: Record<string, string> = {
86
103
  "content-type": "application/json",
87
104
  };
105
+ if (opts.timestamp !== undefined) {
106
+ headers["x-ailk-revalidate-timestamp"] = opts.timestamp;
107
+ }
88
108
  if (opts.signature !== undefined) {
89
109
  headers["x-ailk-revalidate-signature"] = opts.signature;
90
110
  }
@@ -101,14 +121,31 @@ function makeRequest(opts: {
101
121
  });
102
122
  }
103
123
 
104
- /** A correctly-signed POST of `body` under `secret`. */
124
+ /** A correctly-signed POST of `body` under `secret`, timestamped now. */
105
125
  function signedRequest(
106
126
  body: unknown,
107
127
  secret: string,
108
128
  event = "publish",
129
+ timestamp = String(nowSeconds()),
109
130
  ): Request {
110
131
  const rawBody = JSON.stringify(body);
111
- return makeRequest({ rawBody, signature: sign(rawBody, secret), event });
132
+ return makeRequest({
133
+ rawBody,
134
+ signature: sign(rawBody, secret, timestamp, event),
135
+ event,
136
+ timestamp,
137
+ });
138
+ }
139
+
140
+ /** A request signed over `rawBody` exactly — for bodies JSON.stringify would not produce. */
141
+ function signedRaw(rawBody: string, secret = SECRET): Request {
142
+ const timestamp = String(nowSeconds());
143
+ return makeRequest({
144
+ rawBody,
145
+ signature: sign(rawBody, secret, timestamp),
146
+ event: "publish",
147
+ timestamp,
148
+ });
112
149
  }
113
150
 
114
151
  describe("POST /api/revalidate", () => {
@@ -162,7 +199,7 @@ describe("POST /api/revalidate", () => {
162
199
  it("purges tags built from the body's own type/slug/locale", async () => {
163
200
  const body = {
164
201
  workspaceId: "ws_other",
165
- type: "Article",
202
+ type: "BlogPosting",
166
203
  slug: "another-slug",
167
204
  locale: "fr",
168
205
  };
@@ -170,11 +207,15 @@ describe("POST /api/revalidate", () => {
170
207
  await POST(signedRequest(body, SECRET));
171
208
 
172
209
  expect(mockRevalidateTag).toHaveBeenCalledWith(
173
- contentPageTag({ type: "Article", slug: "another-slug", locale: "fr" }),
210
+ contentPageTag({
211
+ type: "BlogPosting",
212
+ slug: "another-slug",
213
+ locale: "fr",
214
+ }),
174
215
  { expire: 0 },
175
216
  );
176
217
  expect(mockRevalidateTag).toHaveBeenCalledWith(
177
- contentListTag({ type: "Article" }),
218
+ contentListTag({ type: "BlogPosting" }),
178
219
  { expire: 0 },
179
220
  );
180
221
  });
@@ -193,18 +234,6 @@ describe("POST /api/revalidate", () => {
193
234
  expect(mockRevalidateTag.mock.calls).toEqual(publishCalls);
194
235
  });
195
236
 
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
237
  it("is idempotent under retry — a second identical delivery purges the same tags (NFR3)", async () => {
209
238
  await POST(signedRequest(VALID_BODY, SECRET));
210
239
  const firstCalls = mockRevalidateTag.mock.calls;
@@ -218,11 +247,13 @@ describe("POST /api/revalidate", () => {
218
247
 
219
248
  it("logs one line prefixed [revalidate] carrying the correlation id (FR8, D8)", async () => {
220
249
  const rawBody = JSON.stringify(VALID_BODY);
250
+ const timestamp = String(nowSeconds());
221
251
  await POST(
222
252
  makeRequest({
223
253
  rawBody,
224
- signature: sign(rawBody, SECRET),
254
+ signature: sign(rawBody, SECRET, timestamp),
225
255
  event: "publish",
256
+ timestamp,
226
257
  requestId: "req-abc-123",
227
258
  }),
228
259
  );
@@ -235,9 +266,12 @@ describe("POST /api/revalidate", () => {
235
266
 
236
267
  it("never logs the secret or the signature header value (FR9)", async () => {
237
268
  const rawBody = JSON.stringify(VALID_BODY);
238
- const signature = sign(rawBody, SECRET);
269
+ const timestamp = String(nowSeconds());
270
+ const signature = sign(rawBody, SECRET, timestamp);
239
271
 
240
- await POST(makeRequest({ rawBody, signature, event: "publish" }));
272
+ await POST(
273
+ makeRequest({ rawBody, signature, event: "publish", timestamp }),
274
+ );
241
275
 
242
276
  const logged = [
243
277
  ...(console.info as jest.Mock).mock.calls,
@@ -396,6 +430,7 @@ describe("POST /api/revalidate", () => {
396
430
  makeRequest({
397
431
  rawBody: JSON.stringify(VALID_BODY),
398
432
  event: "publish",
433
+ timestamp: String(nowSeconds()),
399
434
  }),
400
435
  );
401
436
 
@@ -408,18 +443,24 @@ describe("POST /api/revalidate", () => {
408
443
 
409
444
  it("returns 401 when the signature header is malformed", async () => {
410
445
  const rawBody = JSON.stringify(VALID_BODY);
446
+ const timestamp = String(nowSeconds());
411
447
 
412
448
  for (const malformed of [
413
449
  "not-a-signature",
414
450
  "sha256=short",
415
451
  `sha1=${"a".repeat(64)}`,
416
452
  `sha256=${"A".repeat(64)}`, // uppercase hex is not the wire encoding
417
- sign(rawBody, SECRET).slice("sha256=".length), // prefix stripped
453
+ sign(rawBody, SECRET, timestamp).slice("sha256=".length), // prefix stripped
418
454
  ]) {
419
455
  mockRevalidateTag.mockClear();
420
456
 
421
457
  const response = await POST(
422
- makeRequest({ rawBody, signature: malformed, event: "publish" }),
458
+ makeRequest({
459
+ rawBody,
460
+ signature: malformed,
461
+ event: "publish",
462
+ timestamp,
463
+ }),
423
464
  );
424
465
 
425
466
  expect(response.status).toBe(401);
@@ -437,12 +478,14 @@ describe("POST /api/revalidate", () => {
437
478
  it("returns 401 when the body was altered after signing", async () => {
438
479
  const signedOver = JSON.stringify(VALID_BODY);
439
480
  const delivered = JSON.stringify({ ...VALID_BODY, slug: "tampered" });
481
+ const timestamp = String(nowSeconds());
440
482
 
441
483
  const response = await POST(
442
484
  makeRequest({
443
485
  rawBody: delivered,
444
- signature: sign(signedOver, SECRET),
486
+ signature: sign(signedOver, SECRET, timestamp),
445
487
  event: "publish",
488
+ timestamp,
446
489
  }),
447
490
  );
448
491
 
@@ -461,13 +504,7 @@ describe("POST /api/revalidate", () => {
461
504
  ]) {
462
505
  mockRevalidateTag.mockClear();
463
506
 
464
- const response = await POST(
465
- makeRequest({
466
- rawBody,
467
- signature: sign(rawBody, SECRET),
468
- event: "publish",
469
- }),
470
- );
507
+ const response = await POST(signedRaw(rawBody));
471
508
 
472
509
  expect(response.status).toBe(400);
473
510
  await expect(response.json()).resolves.toEqual({
@@ -478,15 +515,7 @@ describe("POST /api/revalidate", () => {
478
515
  });
479
516
 
480
517
  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
- );
518
+ const response = await POST(signedRaw("not json at all", WRONG_SECRET));
490
519
 
491
520
  expect(response.status).toBe(401);
492
521
  await expect(response.json()).resolves.toEqual({
@@ -506,6 +535,169 @@ describe("POST /api/revalidate", () => {
506
535
  expect((await POST(signedRequest(VALID_BODY, SECRET))).status).toBe(200);
507
536
  });
508
537
  });
538
+
539
+ // --- #5902: replay window, signed event, bounded fields ------------------
540
+
541
+ describe("#5902 — the timestamp and event are signed, and the fields are bounded", () => {
542
+ it("accepts a timestamp just inside the 5-minute tolerance, either side", async () => {
543
+ for (const offset of [-(TOLERANCE_SECONDS - 5), TOLERANCE_SECONDS - 5]) {
544
+ const response = await POST(
545
+ signedRequest(
546
+ VALID_BODY,
547
+ SECRET,
548
+ "publish",
549
+ String(nowSeconds() + offset),
550
+ ),
551
+ );
552
+ expect(response.status).toBe(200);
553
+ }
554
+ });
555
+
556
+ it("rejects a correctly-signed replay older than the tolerance with 401", async () => {
557
+ const stale = String(nowSeconds() - TOLERANCE_SECONDS - 1);
558
+
559
+ const response = await POST(
560
+ signedRequest(VALID_BODY, SECRET, "publish", stale),
561
+ );
562
+
563
+ expect(response.status).toBe(401);
564
+ await expect(response.json()).resolves.toEqual({
565
+ error: "invalid_signature",
566
+ });
567
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
568
+ });
569
+
570
+ it("rejects a correctly-signed timestamp too far in the future with 401", async () => {
571
+ const future = String(nowSeconds() + TOLERANCE_SECONDS + 1);
572
+
573
+ const response = await POST(
574
+ signedRequest(VALID_BODY, SECRET, "publish", future),
575
+ );
576
+
577
+ expect(response.status).toBe(401);
578
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
579
+ });
580
+
581
+ it("rejects a missing or non-numeric timestamp header with 401", async () => {
582
+ const rawBody = JSON.stringify(VALID_BODY);
583
+
584
+ for (const timestamp of [undefined, "", "abc", "1e9", "-1", "12.5"]) {
585
+ mockRevalidateTag.mockClear();
586
+
587
+ const response = await POST(
588
+ makeRequest({
589
+ rawBody,
590
+ signature: sign(rawBody, SECRET, timestamp ?? ""),
591
+ event: "publish",
592
+ timestamp,
593
+ }),
594
+ );
595
+
596
+ expect(response.status).toBe(401);
597
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
598
+ }
599
+ });
600
+
601
+ it("rejects a timestamp altered after signing with 401", async () => {
602
+ const rawBody = JSON.stringify(VALID_BODY);
603
+ const signedAt = String(nowSeconds() - 60);
604
+
605
+ const response = await POST(
606
+ makeRequest({
607
+ rawBody,
608
+ signature: sign(rawBody, SECRET, signedAt),
609
+ event: "publish",
610
+ timestamp: String(nowSeconds()),
611
+ }),
612
+ );
613
+
614
+ expect(response.status).toBe(401);
615
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
616
+ });
617
+
618
+ it("rejects an event header flipped after signing with 401", async () => {
619
+ const rawBody = JSON.stringify(VALID_BODY);
620
+ const timestamp = String(nowSeconds());
621
+
622
+ const response = await POST(
623
+ makeRequest({
624
+ rawBody,
625
+ signature: sign(rawBody, SECRET, timestamp, "publish"),
626
+ event: "unpublish",
627
+ timestamp,
628
+ }),
629
+ );
630
+
631
+ expect(response.status).toBe(401);
632
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
633
+ });
634
+
635
+ it("rejects a missing or unknown event header with 401, logging it as unknown", async () => {
636
+ const rawBody = JSON.stringify(VALID_BODY);
637
+ const timestamp = String(nowSeconds());
638
+
639
+ for (const event of [undefined, "", "delete"]) {
640
+ mockRevalidateTag.mockClear();
641
+ (console.warn as jest.Mock).mockClear();
642
+
643
+ const response = await POST(
644
+ makeRequest({
645
+ rawBody,
646
+ signature: sign(rawBody, SECRET, timestamp, event ?? ""),
647
+ event,
648
+ timestamp,
649
+ }),
650
+ );
651
+
652
+ expect(response.status).toBe(401);
653
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
654
+ expect((console.warn as jest.Mock).mock.calls[0][0]).toContain(
655
+ "unknown",
656
+ );
657
+ }
658
+ });
659
+
660
+ it("rejects the pre-#5902 body-only signature with 401", async () => {
661
+ const rawBody = JSON.stringify(VALID_BODY);
662
+ const bodyOnly = `sha256=${createHmac("sha256", SECRET).update(rawBody, "utf8").digest("hex")}`;
663
+
664
+ const response = await POST(
665
+ makeRequest({
666
+ rawBody,
667
+ signature: bodyOnly,
668
+ event: "publish",
669
+ timestamp: String(nowSeconds()),
670
+ }),
671
+ );
672
+
673
+ expect(response.status).toBe(401);
674
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
675
+ });
676
+
677
+ it.each([
678
+ ["type", "page"], // not a registered page type
679
+ ["type", "Service:some-slug:en"], // collides with another page's tag (#6005 M3)
680
+ ["type", "S".repeat(300)], // would exceed Next's 256-character tag limit
681
+ ["slug", "Bad Slug"],
682
+ ["slug", "a:b"],
683
+ ["slug", "a".repeat(260)], // page tag over Next's 256-character limit
684
+ ["locale", "english"],
685
+ ["locale", "en:x"],
686
+ ])(
687
+ "returns 400 for a correctly-signed body with an out-of-bounds %s (%j)",
688
+ async (field, value) => {
689
+ const response = await POST(
690
+ signedRequest({ ...VALID_BODY, [field]: value }, SECRET),
691
+ );
692
+
693
+ expect(response.status).toBe(400);
694
+ await expect(response.json()).resolves.toEqual({
695
+ error: "invalid_body",
696
+ });
697
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
698
+ },
699
+ );
700
+ });
509
701
  });
510
702
 
511
703
  // --- AC-3: the AC-WCE-02 publish-to-live harness (D12) --------------------
@@ -10,37 +10,75 @@
10
10
  * database handle; it purges cache keys for a read path that goes through
11
11
  * apps/api (ADR 0002, headless-first).
12
12
  *
13
- * The wire contract is #5851's, consumed verbatim (D1): POST; body the
14
- * four-key JSON { workspaceId, type, slug, locale }; headers
13
+ * The wire contract is #5851's as amended by #5902 (its spec's D1/D2): POST;
14
+ * body the four-key JSON { workspaceId, type, slug, locale }; headers
15
+ * `x-ailk-revalidate-timestamp: <unix seconds>`,
16
+ * `x-ailk-revalidate-event: publish | unpublish` and
15
17
  * `x-ailk-revalidate-signature: sha256=<lowercase hex>` — HMAC-SHA256 over
16
- * the exact body bytes — and `x-ailk-revalidate-event: publish | unpublish`.
18
+ * `v0:{timestamp}:{event}:{rawBody}`.
17
19
  *
18
20
  * The order is 503 → 401 → 400, and the signature is verified over the RAW
19
21
  * body before it is parsed (D2). Re-serialising a parsed object changes the
20
22
  * bytes, and answering 400 before 401 would hand an unauthenticated caller a
21
- * JSON-parser oracle.
23
+ * JSON-parser oracle. A timestamp more than 5 minutes from this server's
24
+ * clock, or an event other than publish/unpublish, is a 401 like a bad
25
+ * signature: both are inside the signed material.
22
26
  *
23
27
  * Both events take one code path (D6): purging the page tag is what makes an
24
28
  * unpublished page disappear, and purging the list tag drops it from the
25
- * index — the same two purges a publish needs. The event header is logged and
26
- * never branched on.
29
+ * index — the same two purges a publish needs. The event header is signed,
30
+ * logged and otherwise never branched on.
27
31
  *
28
- * No replay window, by design and on the wire (D7): #5851 signs the body
29
- * bytes alone, so there is no timestamp to check a window against, and the
30
- * only action a replay causes — a second revalidateTag on an already-purged
31
- * tag — is a no-op. #5902 is the open amendment that would give this receiver
32
- * a timestamp; D7 is reopened with it.
32
+ * Replay window (D7, reopened and closed by #5902): a captured delivery
33
+ * verifies for 5 minutes and no longer, the Slack receiver's tolerance
34
+ * (apps/api/src/routes/slack/actions.ts). Within the window a replay is a
35
+ * second revalidateTag on an already-purged tag — a no-op — so the receiver
36
+ * still keeps no seen-set.
37
+ *
38
+ * `type`, `slug` and `locale` are held to the sender's bounded shapes before
39
+ * they become cache tags (#5902, #6005 M3): none can carry a `:`, so no body
40
+ * can forge another page's tag. A page tag over Next's 256-character limit is
41
+ * a 400 too — the read path cannot cache under such a tag either.
33
42
  *
34
43
  * PII / secret boundary (FR9): no log line carries the secret, the signature
35
44
  * header value, the request body, or page content.
36
45
  */
37
46
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
38
47
 
48
+ import {
49
+ SCHEMA_ORG_TYPES,
50
+ SLUG_PATTERN,
51
+ } from "@working-theory/content-adapters";
39
52
  import { contentListTag, contentPageTag } from "@working-theory/page-renderer";
53
+ import { localeSchema } from "@working-theory/validation";
40
54
  import { revalidateTag } from "next/cache";
55
+ import { z } from "zod";
41
56
 
42
57
  const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
43
58
  const EVENT_HEADER = "x-ailk-revalidate-event";
59
+ const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
60
+
61
+ /** How far the signed timestamp may sit from this server's clock, either way. */
62
+ const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
63
+
64
+ /** Unix seconds as the sender writes them: digits only, no sign or exponent. */
65
+ const TIMESTAMP_SHAPE = /^[0-9]{1,12}$/;
66
+
67
+ /**
68
+ * The sender's bounds (apps/api/src/services/revalidation.ts), applied again
69
+ * here because this route is unauthenticated until the MAC verifies and the
70
+ * values become cache tags. `workspaceId` is logged, never tagged, so it stays
71
+ * a plain string.
72
+ */
73
+ const bodySchema = z.object({
74
+ workspaceId: z.string(),
75
+ type: z.enum(SCHEMA_ORG_TYPES),
76
+ slug: z.string().regex(SLUG_PATTERN),
77
+ locale: localeSchema,
78
+ });
79
+
80
+ /** next@16.3.3 `NEXT_CACHE_TAG_MAX_LENGTH` (dist/lib/constants.js). */
81
+ const CACHE_TAG_MAX_LENGTH = 256;
44
82
 
45
83
  /**
46
84
  * A FORMAT guard, not a secret-dependent one — it compares the header against
@@ -48,34 +86,39 @@ const EVENT_HEADER = "x-ailk-revalidate-event";
48
86
  */
49
87
  const SIGNATURE_SHAPE = /^sha256=[0-9a-f]{64}$/;
50
88
 
51
- type RevalidateBody = {
52
- workspaceId: string;
53
- type: string;
54
- slug: string;
55
- locale: string;
56
- };
89
+ type RevalidateBody = z.infer<typeof bodySchema>;
57
90
 
58
91
  /**
59
92
  * Line-for-line the repo's one shipped verifier
60
- * (apps/api/src/routes/slack/actions.ts:50-62), with the base string changed
61
- * from Slack's `v0:<ts>:<body>` to the raw body. The length guard is required,
93
+ * (apps/api/src/routes/slack/actions.ts:50-62), with Slack's `v0:<ts>:<body>`
94
+ * base string extended by the event (#5902). The length guard is required,
62
95
  * not decorative: timingSafeEqual throws on unequal lengths. The `sha256=`
63
96
  * prefix stays inside the compared material so a same-length `sha512=` header
64
97
  * is rejected outright rather than compared against a sha256 digest.
65
98
  */
66
99
  function isValidSignature(
67
100
  rawBody: string,
101
+ timestamp: string,
102
+ event: string,
68
103
  provided: string,
69
104
  secret: string,
70
105
  ): boolean {
71
- const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
106
+ const base = `v0:${timestamp}:${event}:${rawBody}`;
107
+ const expected = `sha256=${createHmac("sha256", secret).update(base, "utf8").digest("hex")}`;
72
108
  const expectedBuf = Buffer.from(expected, "utf8");
73
109
  const providedBuf = Buffer.from(provided, "utf8");
74
110
  if (expectedBuf.length !== providedBuf.length) return false;
75
111
  return timingSafeEqual(expectedBuf, providedBuf);
76
112
  }
77
113
 
78
- /** The parsed four-key body, or null if the payload is not one (FR5). */
114
+ /** True when `timestamp` is unix seconds within the tolerance of now. */
115
+ function isFreshTimestamp(timestamp: string): boolean {
116
+ if (!TIMESTAMP_SHAPE.test(timestamp)) return false;
117
+ const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
118
+ return ageSeconds <= SIGNATURE_TOLERANCE_SECONDS;
119
+ }
120
+
121
+ /** The parsed, bounded four-key body, or null if the payload is not one (FR5). */
79
122
  function parseBody(rawBody: string): RevalidateBody | null {
80
123
  let parsed: unknown;
81
124
  try {
@@ -83,22 +126,17 @@ function parseBody(rawBody: string): RevalidateBody | null {
83
126
  } catch {
84
127
  return null;
85
128
  }
86
- // The array clause is belt-and-braces: the four string checks below already
87
- // reject `[]`. It stays because FR5 says "a non-object" and an
88
- // unauthenticated path is the wrong place to make a reader derive that.
89
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
90
- return null;
91
- }
92
- const { workspaceId, type, slug, locale } = parsed as Record<string, unknown>;
93
- if (
94
- typeof workspaceId !== "string" ||
95
- typeof type !== "string" ||
96
- typeof slug !== "string" ||
97
- typeof locale !== "string"
98
- ) {
129
+ // z.object rejects `[]`, `null` and every non-object; the four field
130
+ // shapes do the rest.
131
+ const result = bodySchema.safeParse(parsed);
132
+ if (!result.success) return null;
133
+ // The field shapes bound type and locale but not slug's length; a page tag
134
+ // over Next's limit could not have been cached under, so it is not a body.
135
+ const { type, slug, locale } = result.data;
136
+ if (contentPageTag({ type, slug, locale }).length > CACHE_TAG_MAX_LENGTH) {
99
137
  return null;
100
138
  }
101
- return { workspaceId, type, slug, locale };
139
+ return result.data;
102
140
  }
103
141
 
104
142
  export async function POST(request: Request): Promise<Response> {
@@ -145,11 +183,15 @@ export async function POST(request: Request): Promise<Response> {
145
183
  // (FR3).
146
184
  const rawBody = await request.text();
147
185
 
186
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
148
187
  const provided = request.headers.get(SIGNATURE_HEADER);
149
188
  if (
189
+ event === "unknown" ||
190
+ timestamp === null ||
191
+ !isFreshTimestamp(timestamp) ||
150
192
  provided === null ||
151
193
  !SIGNATURE_SHAPE.test(provided) ||
152
- !isValidSignature(rawBody, provided, secret)
194
+ !isValidSignature(rawBody, timestamp, event, provided, secret)
153
195
  ) {
154
196
  log("warn", { status: 401 });
155
197
  return Response.json({ error: "invalid_signature" }, { status: 401 });
@@ -117,3 +117,82 @@ describe("SITE_NAME / SITE_BASE_URL (#4157)", () => {
117
117
  expect(SITE_NAME).toBe(FALLBACK_SITE_NAME);
118
118
  });
119
119
  });
120
+
121
+ /**
122
+ * `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
123
+ * exactly today's element; a declared mark is the one rendered, with its dark
124
+ * source kept when it differs; a malformed mark throws naming the field on
125
+ * the render, not at module init.
126
+ */
127
+ describe("siteLogo() — brand.logomark (#6820)", () => {
128
+ /** `site-logo.tsx` re-imported alongside a fresh `site-brand`. */
129
+ async function loadSiteLogo(): Promise<
130
+ typeof import("../site-logo") & typeof import("../site-brand")
131
+ > {
132
+ const brand = await loadSiteBrand();
133
+ return { ...brand, ...(await import("../site-logo")) };
134
+ }
135
+
136
+ function writeRaw(frontmatter: string): void {
137
+ writeFileSync(
138
+ join(tmpRoot, "_site.mdx"),
139
+ `---\nsiteName: Fixture Co\n${frontmatter}---\n`,
140
+ );
141
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
142
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
143
+ }
144
+
145
+ it("with no brand: renders today's /logomark.svg element", async () => {
146
+ writeRaw("");
147
+ const { siteLogo, SITE_NAME } = await loadSiteLogo();
148
+ const logo = siteLogo();
149
+
150
+ expect(logo.type).toBe("img");
151
+ expect(logo.props).toStrictEqual({
152
+ src: "/logomark.svg",
153
+ alt: SITE_NAME,
154
+ width: 28,
155
+ height: 28,
156
+ className: "block",
157
+ });
158
+ });
159
+
160
+ it("with light === dark: renders an img with the declared src and alt", async () => {
161
+ writeRaw("brand:\n logomark: { light: /acme.svg, dark: /acme.svg, alt: Acme }\n");
162
+ const { siteLogo } = await loadSiteLogo();
163
+ const logo = siteLogo();
164
+
165
+ expect(logo.type).toBe("img");
166
+ expect(logo.props).toStrictEqual({
167
+ src: "/acme.svg",
168
+ alt: "Acme",
169
+ width: 28,
170
+ height: 28,
171
+ className: "block",
172
+ });
173
+ });
174
+
175
+ it("with light !== dark: renders the Image primitive with darkSrc", async () => {
176
+ writeRaw("brand:\n logomark: { light: /acme.svg, dark: /acme-dark.svg, alt: Acme }\n");
177
+ const { siteLogo } = await loadSiteLogo();
178
+ const { Image } = await import("@working-theory/ui/primitives");
179
+ const logo = siteLogo();
180
+
181
+ expect(logo.type).toBe(Image);
182
+ expect(logo.props).toStrictEqual({
183
+ src: "/acme.svg",
184
+ darkSrc: "/acme-dark.svg",
185
+ alt: "Acme",
186
+ width: 28,
187
+ height: 28,
188
+ });
189
+ });
190
+
191
+ it("a logomark missing alt throws naming alt, while SITE_NAME still resolves", async () => {
192
+ writeRaw("brand:\n logomark: { light: /acme.svg, dark: /acme.svg }\n");
193
+ const { siteLogo, SITE_NAME } = await loadSiteLogo();
194
+
195
+ expect(SITE_NAME).toBe("Fixture Co");
196
+ expect(() => siteLogo()).toThrow(/alt/);
197
+ });
198
+ });