create-ailk 0.27.0 → 0.28.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 (210) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -79,7 +79,7 @@ describe("TestimonialsPage — snapshot regression", () => {
79
79
  });
80
80
  const { container } = render(element);
81
81
  expect(container.innerHTML).toMatchInlineSnapshot(
82
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Customer Testimonials","description":"What our customers say.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Review"}]},"aggregateRating":{"@context":"https://schema.org","@type":"AggregateRating","ratingValue":4.9,"reviewCount":0,"bestRating":5,"worstRating":1,"itemReviewed":{"@type":"SoftwareApplication"}}}</script><main id="main"><div><h1>Customer Testimonials</h1><p data-testid="testimonials-description" class="mt-block-tight text-lead">What our customers say.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
82
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Customer Testimonials","description":"What our customers say.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Review"}]},"aggregateRating":{"@context":"https://schema.org","@type":"AggregateRating","ratingValue":4.9,"reviewCount":0,"bestRating":5,"worstRating":1,"itemReviewed":{"@type":"SoftwareApplication"}}}</script><main id="main"><div><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
83
83
  );
84
84
  });
85
85
  });
@@ -1,7 +1,11 @@
1
1
  // Whitepaper detail page. Reads the Whitepaper MDX for the given slug, renders via
2
2
  // @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
3
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
4
+ import {
5
+ getMdxSource as loadMdxSource,
6
+ renderPage,
7
+ renderPageMetadata,
8
+ } from "@working-theory/page-renderer";
5
9
  import type { Metadata } from "next";
6
10
  import type { ReactElement } from "react";
7
11
 
@@ -22,12 +26,29 @@ export default async function Page({
22
26
  getMdxSource,
23
27
  }: Props): Promise<ReactElement> {
24
28
  const { locale, slug } = await params;
29
+ // A page that composes `sections[]` renders them (JSON-LD is built from
30
+ // the same sections); a prose page renders its MDX body — the rule
31
+ // /vs/[slug] applies (#10122).
32
+ const source = await (getMdxSource ?? loadMdxSource)(
33
+ "Whitepaper",
34
+ slug,
35
+ locale,
36
+ );
37
+ const composed =
38
+ source !== null &&
39
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
40
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
41
+ 0;
25
42
  return renderPage({
26
43
  type: "Whitepaper",
27
44
  slug,
28
45
  locale,
29
- bodyMode: "mdx-body",
30
- populatedDescriptionTestId: "whitepaper-description",
46
+ ...(composed
47
+ ? {}
48
+ : {
49
+ bodyMode: "mdx-body" as const,
50
+ populatedDescriptionTestId: "whitepaper-description",
51
+ }),
31
52
  ...(getMdxSource !== undefined && { getMdxSource }),
32
53
  });
33
54
  }
@@ -62,7 +62,7 @@ describe("whitepapers/[slug] — snapshot regression", () => {
62
62
  });
63
63
  const { container } = render(element);
64
64
  expect(container.innerHTML).toMatchInlineSnapshot(`
65
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
65
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
66
66
 
67
67
  Body content.</div></div></article></div></div></div></main>"
68
68
  `);
@@ -18,6 +18,10 @@
18
18
  * #9912 D3 (AC-1): a single `prompt=create` sends a visitor with no session to
19
19
  * `/sign-up` with the same continuation; any other value gives `/sign-in`, a
20
20
  * session ignores it, and it never enters the ref or the code request.
21
+ *
22
+ * #9966 D2 (AC-4): a person who has not said yes to the spoke is sent to the
23
+ * hub's consent page with a sealed ref; only that page's same-origin POST
24
+ * sends `consent: true` — the GET a spoke can trigger on page load never does.
21
25
  */
22
26
 
23
27
  // --- Mocks set up BEFORE imports ---
@@ -40,8 +44,8 @@ jest.mock("@working-theory/api-client", () => ({
40
44
  }));
41
45
 
42
46
  import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
43
- import { openContinuation } from "../../continuation";
44
- import { GET } from "../route";
47
+ import { mintContinuation, openContinuation } from "../../continuation";
48
+ import { GET, POST } from "../route";
45
49
 
46
50
  const HUB = "https://hub.example.com";
47
51
  const CONTINUE = "/api/auth/hub/continue";
@@ -577,3 +581,201 @@ describe("the sign-up hint, prompt=create (#9912 D3)", () => {
577
581
  expect(mockCreateCode.mock.calls[0]).toEqual(mockCreateCode.mock.calls[1]);
578
582
  });
579
583
  });
584
+
585
+ describe("the consent step (#9966 D2, AC-4)", () => {
586
+ const SESSION_COOKIE = "better-auth.session_token=tok";
587
+ const CONSENT_REQUIRED = {
588
+ ok: false,
589
+ status: 403,
590
+ body: {
591
+ ok: false,
592
+ error: { code: "consent_required", message: "Consent required" },
593
+ },
594
+ };
595
+
596
+ /** A same-origin form post of `ref` fields from the hub's consent page. */
597
+ function post(
598
+ refs: string[],
599
+ headers: Record<string, string | undefined> = {},
600
+ ): Request {
601
+ const body = new URLSearchParams();
602
+ for (const ref of refs) body.append("ref", ref);
603
+ const sent: Record<string, string> = {
604
+ "content-type": "application/x-www-form-urlencoded",
605
+ cookie: SESSION_COOKIE,
606
+ "sec-fetch-site": "same-origin",
607
+ origin: HUB,
608
+ };
609
+ for (const [key, value] of Object.entries(headers)) {
610
+ if (value === undefined) delete sent[key];
611
+ else sent[key] = value;
612
+ }
613
+ return new Request(`${HUB}/api/auth/hub/authorize`, {
614
+ method: "POST",
615
+ headers: sent,
616
+ body: body.toString(),
617
+ });
618
+ }
619
+
620
+ const ref = (search = query(), now?: number) =>
621
+ mintContinuation(search, mockSecret, now);
622
+
623
+ it("GET: a session the API answers consent_required goes to /account/connect with a sealed ref, and sends no consent", async () => {
624
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
625
+ mockCreateCode.mockResolvedValue(CONSENT_REQUIRED);
626
+
627
+ const res = await GET(request(query(), { cookie: SESSION_COOKIE }));
628
+
629
+ expect(res.status).toBe(303);
630
+ const location = new URL(res.headers.get("location") ?? "", HUB);
631
+ expect(location.pathname).toBe("/account/connect");
632
+ const sealed = location.searchParams.get("ref") ?? "";
633
+ expect(openContinuation(sealed, mockSecret)).toBe(query());
634
+ expect(mockCreateCode).toHaveBeenCalledTimes(1);
635
+ const [body, init] = mockCreateCode.mock.calls[0] as [
636
+ Record<string, unknown>,
637
+ { headers: Record<string, string> },
638
+ ];
639
+ expect("consent" in body).toBe(false);
640
+ expect("x-internal-auth" in init.headers).toBe(false);
641
+ });
642
+
643
+ it("POST: a same-origin Continue mints with consent: true and 303s to the registered URI with code and state", async () => {
644
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
645
+ mockCreateCode.mockResolvedValue({
646
+ ok: true,
647
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
648
+ });
649
+
650
+ const res = await POST(post([ref()]));
651
+
652
+ expect(res.status).toBe(303);
653
+ expect(res.headers.get("location")).toBe(
654
+ `${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`,
655
+ );
656
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
657
+ expect(mockCreateCode).toHaveBeenCalledWith(
658
+ {
659
+ clientId: "spoke_abc",
660
+ redirectUri: SPOKE_CALLBACK,
661
+ codeChallenge: CHALLENGE,
662
+ consent: true,
663
+ },
664
+ { headers: { cookie: SESSION_COOKIE, origin: HUB } },
665
+ );
666
+ });
667
+
668
+ it("POST: carries x-internal-auth when the secret is set and the API is https:", async () => {
669
+ process.env = {
670
+ ...process.env,
671
+ OSS_API_INTERNAL_SECRET: "internal-secret",
672
+ AILK_API_URL: "https://api.example.com",
673
+ };
674
+ delete process.env.AILK_SPOKE_KEY;
675
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
676
+ mockCreateCode.mockResolvedValue({
677
+ ok: true,
678
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
679
+ });
680
+
681
+ await POST(post([ref()]));
682
+
683
+ const [, init] = mockCreateCode.mock.calls[0] as [
684
+ unknown,
685
+ { headers: Record<string, string> },
686
+ ];
687
+ expect(init.headers["x-internal-auth"]).toBe("internal-secret");
688
+ });
689
+
690
+ it("POST: withholds x-internal-auth from a plain-http API", async () => {
691
+ process.env = {
692
+ ...process.env,
693
+ OSS_API_INTERNAL_SECRET: "internal-secret",
694
+ AILK_API_URL: "http://api.example.com",
695
+ };
696
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
697
+ mockCreateCode.mockResolvedValue({
698
+ ok: true,
699
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
700
+ });
701
+
702
+ await POST(post([ref()]));
703
+
704
+ const [, init] = mockCreateCode.mock.calls[0] as [
705
+ unknown,
706
+ { headers: Record<string, string> },
707
+ ];
708
+ expect("x-internal-auth" in init.headers).toBe(false);
709
+ });
710
+
711
+ it.each([
712
+ ["no Sec-Fetch-Site", { "sec-fetch-site": undefined }],
713
+ ["Sec-Fetch-Site cross-site", { "sec-fetch-site": "cross-site" }],
714
+ ["Sec-Fetch-Site same-site", { "sec-fetch-site": "same-site" }],
715
+ ["a foreign Origin", { origin: "https://spoke.example.com" }],
716
+ ["no Origin", { origin: undefined }],
717
+ ["Origin: null", { origin: "null" }],
718
+ ])(
719
+ "POST: %s is 403 with no session read and no code",
720
+ async (_name, headers) => {
721
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
722
+
723
+ const res = await POST(post([ref()], headers));
724
+
725
+ expect(res.status).toBe(403);
726
+ expect(res.headers.get("location")).toBeNull();
727
+ expect(await res.json()).toEqual({ error: "invalid_request" });
728
+ expect(mockGetSession).not.toHaveBeenCalled();
729
+ expect(mockCreateCode).not.toHaveBeenCalled();
730
+ },
731
+ );
732
+
733
+ it.each([
734
+ ["a forged ref", () => ["x".repeat(80)]],
735
+ ["an expired ref", () => [ref(query(), Date.now() - 3_600_000)]],
736
+ ["two ref fields", () => [ref(), ref()]],
737
+ ["no ref field", () => []],
738
+ [
739
+ "a ref sealing a query that fails the schema",
740
+ () => [ref(query({ code_challenge_method: "plain" }))],
741
+ ],
742
+ ])("POST: %s is 400 with no code", async (_name, refs) => {
743
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
744
+
745
+ const res = await POST(post(refs()));
746
+
747
+ expect(res.status).toBe(400);
748
+ expect(res.headers.get("location")).toBeNull();
749
+ expect(mockCreateCode).not.toHaveBeenCalled();
750
+ });
751
+
752
+ it("POST: no session sends the visitor to sign in, back through the same continuation", async () => {
753
+ mockGetSession.mockResolvedValue(null);
754
+ const sealed = ref();
755
+
756
+ const res = await POST(post([sealed]));
757
+
758
+ expect(res.status).toBe(303);
759
+ expect(res.headers.get("location")).toBe(
760
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${sealed}`)}`,
761
+ );
762
+ expect(mockCreateCode).not.toHaveBeenCalled();
763
+ });
764
+
765
+ it("POST: an unverified email goes to /account/settings; any other refusal is a 400", async () => {
766
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
767
+ mockCreateCode.mockResolvedValueOnce({
768
+ ok: false,
769
+ status: 403,
770
+ body: { ok: false, error: { code: "email_unverified", message: "x" } },
771
+ });
772
+ const unverified = await POST(post([ref()]));
773
+ expect(unverified.status).toBe(303);
774
+ expect(unverified.headers.get("location")).toBe("/account/settings");
775
+
776
+ mockCreateCode.mockResolvedValueOnce(CONSENT_REQUIRED);
777
+ const refused = await POST(post([ref()]));
778
+ expect(refused.status).toBe(400);
779
+ expect(refused.headers.get("location")).toBeNull();
780
+ });
781
+ });
@@ -28,6 +28,11 @@
28
28
  * the REGISTERED redirect URI the API echoes back, with `code` and the
29
29
  * same `state` — never to the query's own `redirect_uri` until the API has
30
30
  * confirmed it is the client's;
31
+ * - a person who has not said yes to this spoke (403 `consent_required`,
32
+ * #9966 D2) goes to the hub's own consent page, `/account/connect?ref=…`,
33
+ * with the six keys sealed as the no-session branch seals them. The GET
34
+ * never sends `consent`: a spoke can send a visitor here on page load, so
35
+ * a GET must never be the yes;
31
36
  * - a person whose email is not verified (403 `email_unverified`) goes to the
32
37
  * hub's own `/account/settings`, whose For-you item says how to verify —
33
38
  * never to the spoke;
@@ -51,6 +56,15 @@
51
56
  * continuation to `/sign-in` — nor a code or a cookie, and with a session it
52
57
  * is ignored: the person has an account, and the spoke signs them in.
53
58
  *
59
+ * POST /api/auth/hub/authorize (form field `ref`, #9966 D2)
60
+ *
61
+ * - the consent page's Continue. Accepted only with `Sec-Fetch-Site:
62
+ * same-origin` and an `Origin` equal to the pinned origin (a missing or
63
+ * `null` Origin is refused), before any session read; the ref is opened
64
+ * and its query re-validated, and the session branch runs as GET's does,
65
+ * with `consent: true` — and `x-internal-auth` where the API requires it —
66
+ * on the code request. This is the one call site that sends consent.
67
+ *
54
68
  * `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
55
69
  * (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
56
70
  * origin, exactly as the emailed link does.
@@ -64,13 +78,17 @@ import { hubSignIn } from "@working-theory/api-client";
64
78
  import {
65
79
  accountEmailHintSchema,
66
80
  hubAuthorizeQuerySchema,
81
+ type HubAuthorizeQuery,
67
82
  } from "@working-theory/validation";
68
83
 
69
84
  import { auth, resolveSecret } from "../../../../../auth";
85
+ import { browserApiOrigin } from "../../../../../lib/api-origin.generated";
86
+ import { TOPOLOGY } from "../../../../../lib/topology.generated";
70
87
  import {
71
88
  CONTINUATION_REF_MAX,
72
89
  CONTINUE_PATH,
73
90
  mintContinuation,
91
+ openContinuation,
74
92
  } from "../continuation";
75
93
 
76
94
  /** The six query keys, each read exactly once. */
@@ -86,6 +104,11 @@ const QUERY_KEYS = [
86
104
  /** Where an unverified person is sent to verify: the hub's own account page. */
87
105
  const VERIFY_EMAIL_PATH = "/account/settings";
88
106
 
107
+ /** The hub's consent page, where a person says yes to one spoke (#9966 D2). */
108
+ const CONSENT_PATH = "/account/connect";
109
+
110
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1"]);
111
+
89
112
  function json(status: number, error: string): Response {
90
113
  return new Response(JSON.stringify({ error }), {
91
114
  status,
@@ -120,42 +143,50 @@ function pinnedOrigin(): string | null {
120
143
  }
121
144
  }
122
145
 
123
- // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
124
- export async function GET(request: Request): Promise<Response> {
125
- const url = new URL(request.url);
126
-
127
- // Each declared key exactly once: a repeated key is ambiguous, so 400.
128
- const raw: Record<string, string | undefined> = {};
129
- for (const key of QUERY_KEYS) {
130
- const values = url.searchParams.getAll(key);
131
- if (values.length > 1) return json(400, "invalid_request");
132
- raw[key] = values[0];
133
- }
134
- // #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
135
- const hints = url.searchParams.getAll("login_hint");
136
- if (hints.length > 1) return json(400, "invalid_request");
137
- // #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
138
- const prompts = url.searchParams.getAll("prompt");
139
- if (prompts.length > 1) return json(400, "invalid_request");
140
- const parsed = hubAuthorizeQuerySchema.safeParse(raw);
141
- if (!parsed.success) return json(400, "invalid_request");
142
- const query = parsed.data;
143
- const hint = accountEmailHintSchema.safeParse(hints[0]);
146
+ /** The six validated keys as a sealed continuation ref, or `null` past the cap. */
147
+ function sealQuery(query: HubAuthorizeQuery): string | null {
148
+ // Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
149
+ // that did not pass the schema, and extra or reordered keys are dropped.
150
+ const canonical = new URLSearchParams();
151
+ for (const key of QUERY_KEYS) canonical.set(key, query[key]);
152
+ const ref = mintContinuation(canonical.toString(), resolveSecret());
153
+ return ref.length > CONTINUATION_REF_MAX ? null : ref;
154
+ }
144
155
 
145
- const session = await auth.api.getSession({ headers: request.headers });
146
- if (!session) {
147
- // Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
148
- // that did not pass the schema, and extra or reordered keys are dropped.
149
- const canonical = new URLSearchParams();
150
- for (const key of QUERY_KEYS) canonical.set(key, query[key]);
151
- const ref = mintContinuation(canonical.toString(), resolveSecret());
152
- if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
153
- const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
154
- if (prompts[0] === "create") return see(`/sign-up?${signIn.toString()}`);
155
- if (hint.success) signIn.set("email", hint.data);
156
- return see(`/sign-in?${signIn.toString()}`);
156
+ /**
157
+ * `x-internal-auth` for the consent-bearing code request, under the Offerings
158
+ * buy route's `startHeaders` rule: only from a site that is not a spoke, and
159
+ * only to an `https:` or loopback API, so the secret never crosses a plain
160
+ * network hop. The API honours `consent: true` only beside it where it is set.
161
+ */
162
+ function internalAuthHeader(): Record<string, string> {
163
+ const secret = process.env.OSS_API_INTERNAL_SECRET;
164
+ const spoke = TOPOLOGY === "spoke" || !!process.env.AILK_SPOKE_KEY?.trim();
165
+ if (!secret || spoke) return {};
166
+ try {
167
+ const api = new URL(
168
+ process.env.AILK_API_URL ||
169
+ browserApiOrigin() ||
170
+ process.env.NEXT_PUBLIC_API_URL ||
171
+ "http://localhost:3001",
172
+ );
173
+ const safe = api.protocol === "https:" || LOOPBACK_HOSTS.has(api.hostname);
174
+ return safe ? { "x-internal-auth": secret } : {};
175
+ } catch {
176
+ return {};
157
177
  }
178
+ }
158
179
 
180
+ /**
181
+ * The session branch, shared by GET and the consent POST: ask the API for a
182
+ * code and answer with where the visitor goes next. Only the POST passes
183
+ * `consent` (#9966 D2).
184
+ */
185
+ async function codeRedirect(
186
+ request: Request,
187
+ query: HubAuthorizeQuery,
188
+ consent: boolean,
189
+ ): Promise<Response> {
159
190
  const origin = pinnedOrigin();
160
191
  if (origin === null) return json(503, "temporarily_unavailable");
161
192
 
@@ -166,11 +197,13 @@ export async function GET(request: Request): Promise<Response> {
166
197
  clientId: query.client_id,
167
198
  redirectUri: query.redirect_uri,
168
199
  codeChallenge: query.code_challenge,
200
+ ...(consent ? { consent: true as const } : {}),
169
201
  },
170
202
  {
171
203
  headers: {
172
204
  cookie: request.headers.get("cookie") ?? "",
173
205
  origin,
206
+ ...(consent ? internalAuthHeader() : {}),
174
207
  },
175
208
  },
176
209
  );
@@ -198,5 +231,90 @@ export async function GET(request: Request): Promise<Response> {
198
231
  if (result.status === 403 && code === "email_unverified") {
199
232
  return see(VERIFY_EMAIL_PATH);
200
233
  }
234
+ // Not yet said yes to this spoke: ask, on the hub's own page. The POST that
235
+ // page sends already carries consent, so it never comes back here.
236
+ if (!consent && result.status === 403 && code === "consent_required") {
237
+ const ref = sealQuery(query);
238
+ if (ref === null) return json(400, "invalid_request");
239
+ return see(`${CONSENT_PATH}?${new URLSearchParams({ ref }).toString()}`);
240
+ }
201
241
  return json(400, "invalid_request");
202
242
  }
243
+
244
+ // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
245
+ export async function GET(request: Request): Promise<Response> {
246
+ const url = new URL(request.url);
247
+
248
+ // Each declared key exactly once: a repeated key is ambiguous, so 400.
249
+ const raw: Record<string, string | undefined> = {};
250
+ for (const key of QUERY_KEYS) {
251
+ const values = url.searchParams.getAll(key);
252
+ if (values.length > 1) return json(400, "invalid_request");
253
+ raw[key] = values[0];
254
+ }
255
+ // #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
256
+ const hints = url.searchParams.getAll("login_hint");
257
+ if (hints.length > 1) return json(400, "invalid_request");
258
+ // #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
259
+ const prompts = url.searchParams.getAll("prompt");
260
+ if (prompts.length > 1) return json(400, "invalid_request");
261
+ const parsed = hubAuthorizeQuerySchema.safeParse(raw);
262
+ if (!parsed.success) return json(400, "invalid_request");
263
+ const query = parsed.data;
264
+ const hint = accountEmailHintSchema.safeParse(hints[0]);
265
+
266
+ const session = await auth.api.getSession({ headers: request.headers });
267
+ if (!session) {
268
+ const ref = sealQuery(query);
269
+ if (ref === null) return json(400, "invalid_request");
270
+ const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
271
+ if (prompts[0] === "create") return see(`/sign-up?${signIn.toString()}`);
272
+ if (hint.success) signIn.set("email", hint.data);
273
+ return see(`/sign-in?${signIn.toString()}`);
274
+ }
275
+
276
+ return codeRedirect(request, query, false);
277
+ }
278
+
279
+ /**
280
+ * The consent page's Continue (#9966 D2): the one request that says yes to a
281
+ * spoke. Same-origin only, decided before any session read, so neither a
282
+ * cross-site form nor a sandboxed frame (`Origin: null`) can post it.
283
+ */
284
+ export async function POST(request: Request): Promise<Response> {
285
+ const origin = pinnedOrigin();
286
+ if (origin === null) return json(503, "temporarily_unavailable");
287
+ const sent = request.headers.get("origin");
288
+ if (
289
+ request.headers.get("sec-fetch-site") !== "same-origin" ||
290
+ sent === null ||
291
+ sent !== origin
292
+ ) {
293
+ return json(403, "invalid_request");
294
+ }
295
+
296
+ let refs: FormDataEntryValue[];
297
+ try {
298
+ refs = (await request.formData()).getAll("ref");
299
+ } catch {
300
+ return json(400, "invalid_request");
301
+ }
302
+ const [ref] = refs;
303
+ if (refs.length !== 1 || typeof ref !== "string") {
304
+ return json(400, "invalid_request");
305
+ }
306
+ const search = openContinuation(ref, resolveSecret());
307
+ if (search === null) return json(400, "invalid_request");
308
+ const params = new URLSearchParams(search);
309
+ const raw: Record<string, string | undefined> = {};
310
+ for (const key of QUERY_KEYS) raw[key] = params.get(key) ?? undefined;
311
+ const parsed = hubAuthorizeQuerySchema.safeParse(raw);
312
+ if (!parsed.success) return json(400, "invalid_request");
313
+
314
+ const session = await auth.api.getSession({ headers: request.headers });
315
+ if (!session) {
316
+ const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
317
+ return see(`/sign-in?${signIn.toString()}`);
318
+ }
319
+ return codeRedirect(request, parsed.data, true);
320
+ }