create-ailk 0.26.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 (224) hide show
  1. package/README.md +86 -9
  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 +25 -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 +112 -11
  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 +90 -6
  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 +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -0,0 +1,152 @@
1
+ /**
2
+ * /[locale]/account/connect — the hub asks a person once per spoke before it
3
+ * tells that spoke who they are (#9966 D2, #7978).
4
+ *
5
+ * The authorize step (`app/api/auth/hub/authorize/route.ts`) sends a signed-in
6
+ * person here when the API answers `consent_required`, with the six validated
7
+ * authorize keys sealed as `ref`. This page opens the ref, names the spoke
8
+ * from the registry (`hubSignIn.clientInfo`) — never from the query — and
9
+ * offers one Continue: a same-origin form POST of `ref` back to the authorize
10
+ * route, which alone sends `consent: true`. Cancel goes to `/account`.
11
+ *
12
+ * The form renders only when the ref's `redirect_uri` is on the origin the
13
+ * registry names, so the page never names one site while the code goes to
14
+ * another. Anything that does not hold — a ref that does not open, a client
15
+ * the registry does not answer for — renders `hubConnect.unavailable` and no
16
+ * form. A spoke carries no hub routes, so a spoke build is a 404. `noindex`,
17
+ * and `referrer: same-origin`, which keeps the ref out of any cross-site
18
+ * Referer while the browser still sends the real `Origin` on Continue.
19
+ */
20
+
21
+ import { hubSignIn } from "@working-theory/api-client";
22
+ import { localeHref } from "@working-theory/ui";
23
+ import { Button, Heading, Input, Link, Text } from "@working-theory/ui/primitives";
24
+ import { hubAuthorizeQuerySchema, type HubAuthorizeQuery } from "@working-theory/validation";
25
+ import type { Metadata } from "next";
26
+ import { notFound } from "next/navigation";
27
+ import { getTranslations } from "next-intl/server";
28
+ import type { ReactElement } from "react";
29
+
30
+ import { resolveSecret } from "../../../../../auth";
31
+ import { TOPOLOGY } from "../../../../../lib/topology.generated";
32
+ import { openContinuation } from "../../../../api/auth/hub/continuation";
33
+ import { accountSession } from "../account-data";
34
+
35
+ /** The authorize route's POST — the one request that says yes. */
36
+ const AUTHORIZE_PATH = "/api/auth/hub/authorize";
37
+
38
+ const QUERY_KEYS = [
39
+ "response_type",
40
+ "client_id",
41
+ "redirect_uri",
42
+ "state",
43
+ "code_challenge",
44
+ "code_challenge_method",
45
+ ] as const;
46
+
47
+ interface Props {
48
+ params: Promise<{ locale: string }>;
49
+ searchParams: Promise<{ ref?: string | string[] }>;
50
+ }
51
+
52
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
53
+ const { locale } = await params;
54
+ const t = await getTranslations({ locale, namespace: "hubConnect" });
55
+ return {
56
+ title: t("metaTitle"),
57
+ robots: { index: false, follow: false },
58
+ referrer: "same-origin",
59
+ };
60
+ }
61
+
62
+ /** The sealed authorize query, or `null` when the ref does not open or parse. */
63
+ function openQuery(ref: string | string[] | undefined): HubAuthorizeQuery | null {
64
+ if (typeof ref !== "string") return null;
65
+ const search = openContinuation(ref, resolveSecret());
66
+ if (search === null) return null;
67
+ const params = new URLSearchParams(search);
68
+ const raw: Record<string, string | undefined> = {};
69
+ for (const key of QUERY_KEYS) raw[key] = params.get(key) ?? undefined;
70
+ const parsed = hubAuthorizeQuerySchema.safeParse(raw);
71
+ return parsed.success ? parsed.data : null;
72
+ }
73
+
74
+ function originOf(url: string): string | null {
75
+ try {
76
+ return new URL(url).origin;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** The spoke's registry name and origin, or `null` when it cannot be read. */
83
+ async function readClient(clientId: string): Promise<{ name: string; origin: string } | null> {
84
+ try {
85
+ const res = await hubSignIn.clientInfo(clientId);
86
+ return res.ok ? { name: res.body.name, origin: res.body.origin } : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export default async function HubConnectPage({ params, searchParams }: Props): Promise<ReactElement> {
93
+ if (TOPOLOGY === "spoke") notFound();
94
+ const { locale } = await params;
95
+ const { ref } = await searchParams;
96
+ const t = await getTranslations({ locale, namespace: "hubConnect" });
97
+
98
+ const query = openQuery(ref);
99
+ const client = query ? await readClient(query.client_id) : null;
100
+ const session = await accountSession(locale);
101
+ const ready =
102
+ typeof ref === "string" &&
103
+ query !== null &&
104
+ client !== null &&
105
+ session !== null &&
106
+ originOf(query.redirect_uri) === client.origin;
107
+
108
+ if (!ready) {
109
+ return (
110
+ <div className="flex max-w-3xl flex-col gap-6 py-8" data-testid="hub-connect-unavailable">
111
+ <div>
112
+ <Heading level={1} visual={2}>
113
+ {t("unavailable.title")}
114
+ </Heading>
115
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
116
+ {t("unavailable.body")}
117
+ </Text>
118
+ </div>
119
+ <Link href={localeHref(locale, "/account")} data-testid="hub-connect-back">
120
+ {t("unavailable.back")}
121
+ </Link>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="hub-connect">
128
+ <div>
129
+ <Heading level={1} visual={2}>
130
+ {t("title", { name: client.name })}
131
+ </Heading>
132
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
133
+ {t("lead", { origin: client.origin, email: session.ctx.user.email })}
134
+ </Text>
135
+ </div>
136
+ <form
137
+ method="post"
138
+ action={AUTHORIZE_PATH}
139
+ className="flex flex-wrap items-center gap-4"
140
+ data-testid="hub-connect-form"
141
+ >
142
+ <Input type="hidden" name="ref" value={ref} />
143
+ <Button type="submit" size="big" data-testid="hub-connect-continue">
144
+ {t("continue")}
145
+ </Button>
146
+ <Link href={localeHref(locale, "/account")} data-testid="hub-connect-cancel">
147
+ {t("cancel")}
148
+ </Link>
149
+ </form>
150
+ </div>
151
+ );
152
+ }
@@ -329,7 +329,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
329
329
 
330
330
  it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
331
331
  const stubAdapter = makeStubAdapter();
332
- const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter });
332
+ const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
333
333
  const response = await GET();
334
334
  const text = await response.text();
335
335
  const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
@@ -341,7 +341,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
341
341
 
342
342
  it("llms.txt: names a URL ending in /about/sam-henry", async () => {
343
343
  const stubAdapter = makeStubAdapter();
344
- const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter });
344
+ const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
345
345
  const response = await GET();
346
346
  const text = await response.text();
347
347
  expect(text).toContain("URL: https://site.example/about/sam-henry");
@@ -21,6 +21,16 @@
21
21
  * are shared across requests for 15 seconds per server instance, as
22
22
  * /contact's are (#8731). Full-screen (`isMagnetWalkPath` matches the path),
23
23
  * so it draws its own back arrow, and `noindex, follow` like the walk.
24
+ *
25
+ * #10054 D4 — a posting may name its magnet (`applicationFlow`). The page
26
+ * reads it through the same shared read, keyed per flow (the keys are only
27
+ * slugs content names: the posting must exist before any read), and renders
28
+ * its step 1 when it maps; the submission then records the flow beside the
29
+ * posting (`apply.actions.ts` D6). A flow the hub answers absent — any non-ok
30
+ * answer below 500, as the walk's `readMagnet` reads it — or whose step 1
31
+ * does not map falls back to the Job application and is logged with the two
32
+ * slugs. A failed read (5xx, a throw) falls back silently, as a down hub
33
+ * always has.
24
34
  */
25
35
  import { flows, leads } from "@working-theory/api-client";
26
36
  import { flowRouteMetadata } from "@working-theory/metadata";
@@ -33,6 +43,7 @@ import {
33
43
  } from "@working-theory/ui";
34
44
  import { NavBackLink } from "@working-theory/ui/chrome";
35
45
  import {
46
+ flowSlugSchema,
36
47
  JOB_APPLICATION_FLOW_SLUG,
37
48
  type FlowConfig,
38
49
  } from "@working-theory/validation";
@@ -41,7 +52,7 @@ import { notFound } from "next/navigation";
41
52
  import { getTranslations } from "next-intl/server";
42
53
  import type { ReactElement } from "react";
43
54
 
44
- import { postingTitle } from "../../../../../lib/posting-title";
55
+ import { postingForApply, type ApplyPosting } from "../../../../../lib/posting-title";
45
56
  import { contactFormText } from "../../../contact/contact-form-text";
46
57
  import { readSiteContactEmail } from "../../../contact/site-contact-email";
47
58
  import { captureLeadAction } from "../apply.actions";
@@ -94,51 +105,91 @@ function readAccepting(): Promise<boolean> {
94
105
  });
95
106
  }
96
107
 
97
- /** The served Job application magnet's config, or `null` for any failed read. */
98
- function readJobApplicationMagnet(): Promise<FlowConfig | null> {
99
- return sharedHubRead("job-application-magnet", async () => {
108
+ type MagnetConfigRead =
109
+ | { kind: "ok"; config: FlowConfig }
110
+ | { kind: "absent" }
111
+ | { kind: "failed" };
112
+
113
+ /**
114
+ * A served magnet's config: `absent` for a non-ok answer below 500, `failed`
115
+ * for a 5xx or a throw. Never called with a slug content did not name.
116
+ */
117
+ function readMagnetConfig(flowSlug: string): Promise<MagnetConfigRead> {
118
+ return sharedHubRead(`magnet:${flowSlug}`, async () => {
100
119
  try {
101
- const res = await flows.get(JOB_APPLICATION_FLOW_SLUG);
102
- return res.ok ? res.body : null;
120
+ const res = await flows.get(flowSlug);
121
+ if (res.ok) return { kind: "ok", config: res.body };
122
+ return res.status >= 500 ? { kind: "failed" } : { kind: "absent" };
103
123
  } catch {
104
- return null;
124
+ return { kind: "failed" };
105
125
  }
106
126
  });
107
127
  }
108
128
 
109
- /** The posting's title, or `null` for a segment pair this page does not serve. */
110
- async function titleFor(
129
+ type ApplicationForm = ReturnType<typeof jobApplicationFormFallback>;
130
+
131
+ /**
132
+ * The form a posting's page draws (D4), and the flow it records when that is
133
+ * the posting's own; with none named, exactly the Job application path.
134
+ */
135
+ async function applicationForm(
136
+ slug: string,
137
+ { applicationFlow: named }: ApplyPosting,
138
+ locale: string,
139
+ ): Promise<{ view: ApplicationForm; applicationFlow?: string }> {
140
+ if (named !== undefined && named !== JOB_APPLICATION_FLOW_SLUG) {
141
+ const read: MagnetConfigRead = flowSlugSchema.safeParse(named).success
142
+ ? await readMagnetConfig(named)
143
+ : { kind: "absent" };
144
+ const view = read.kind === "ok" ? jobApplicationFormFromConfig(read.config, locale) : null;
145
+ if (view) return { view, applicationFlow: named };
146
+ if (read.kind !== "failed") {
147
+ console.warn("[careers] application flow unavailable — falling back to job-application", {
148
+ posting: slug,
149
+ flow: named,
150
+ reason: read.kind === "ok" ? "unmappable" : "absent",
151
+ });
152
+ }
153
+ }
154
+ const read = await readMagnetConfig(JOB_APPLICATION_FLOW_SLUG);
155
+ return {
156
+ view:
157
+ (read.kind === "ok" ? jobApplicationFormFromConfig(read.config, locale) : null) ??
158
+ jobApplicationFormFallback(locale),
159
+ };
160
+ }
161
+
162
+ /** The posting, or `null` for a segment pair this page does not serve. */
163
+ async function postingFor(
111
164
  slug: string,
112
165
  action: string,
113
166
  locale: string,
114
- ): Promise<string | null> {
167
+ ): Promise<ApplyPosting | null> {
115
168
  if (action !== "apply" || !POSTING_SLUG_RE.test(slug)) return null;
116
- return postingTitle(slug, locale);
169
+ return postingForApply(slug, locale);
117
170
  }
118
171
 
119
172
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
120
173
  const { locale, slug, action } = await params;
121
- const title = await titleFor(slug, action, locale);
122
- return title === null ? flowRouteMetadata() : flowRouteMetadata({ title });
174
+ const posting = await postingFor(slug, action, locale);
175
+ return posting === null ? flowRouteMetadata() : flowRouteMetadata({ title: posting.title });
123
176
  }
124
177
 
125
178
  export default async function Page({ params }: Props): Promise<ReactElement> {
126
179
  const { locale, slug, action } = await params;
127
- const title = await titleFor(slug, action, locale);
128
- if (title === null) notFound();
180
+ const posting = await postingFor(slug, action, locale);
181
+ if (posting === null) notFound();
182
+ const { title } = posting;
129
183
 
130
- const [accepting, t, tCareers, tFlow, magnet] = await Promise.all([
184
+ const [accepting, t, tCareers, tFlow, { view, applicationFlow }] = await Promise.all([
131
185
  readAccepting(),
132
186
  getTranslations({ locale, namespace: "contact" }),
133
187
  getTranslations({ locale, namespace: "careers" }),
134
188
  getTranslations({ locale, namespace: "flow" }),
135
- readJobApplicationMagnet(),
189
+ applicationForm(slug, posting, locale),
136
190
  ]);
137
191
  const text = contactFormText(t);
138
192
  const contactEmail = readSiteContactEmail();
139
- const view =
140
- (magnet ? jobApplicationFormFromConfig(magnet, locale) : null) ??
141
- jobApplicationFormFallback(locale);
142
193
 
143
194
  return (
144
195
  <main id="main" data-testid="job-apply">
@@ -155,7 +206,11 @@ export default async function Page({ params }: Props): Promise<ReactElement> {
155
206
  </Heading>
156
207
  {accepting ? (
157
208
  <LeadCaptureForm
158
- action={captureLeadAction.bind(null, slug)}
209
+ action={
210
+ applicationFlow === undefined
211
+ ? captureLeadAction.bind(null, slug)
212
+ : captureLeadAction.bind(null, { job: slug, flow: applicationFlow })
213
+ }
159
214
  text={
160
215
  view.consentStatement
161
216
  ? { ...text, consentStatement: view.consentStatement }
@@ -25,13 +25,19 @@
25
25
  * the API so (`consent: true`, #9130): the hub requires it of a
26
26
  * `flow:job-application` lead and records `payload.consent` itself.
27
27
  *
28
+ * #10054 D6 — a posting that names its own magnet binds `{ job, flow }`
29
+ * instead: the flow, slug-checked, is written as `payload.applicationFlow`
30
+ * beside `job`, and no form field overrides it. The source stays
31
+ * `flow:job-application`, so the hub still records consent.
32
+ *
28
33
  * Checks, before any write: the honeypot (a filled one answers success and
29
34
  * sends nothing); first name, last name and email required; a LinkedIn URL,
30
35
  * when given, an http(s) URL — a bare domain gains `https://`, the flow
31
36
  * engine's `url` idiom; the terms box ticked, as the form block requires; and
32
37
  * the per-visitor limit /contact runs, in /contact's own bucket so a visitor's
33
- * lead posts stay at 30 per 10 minutes however they are split (skipped when
34
- * no client address is readable). Outcomes map as /contact's do: the API's
38
+ * lead posts stay at 30 per 10 minutes however they are split (with no
39
+ * readable client address, /contact's shared, wider bucket — never skipped,
40
+ * #9967). Outcomes map as /contact's do: the API's
35
41
  * `lead_capture_not_configured` or an unreachable API is `unavailable`, any
36
42
  * other refusal the generic error.
37
43
  */
@@ -44,6 +50,7 @@ import {
44
50
  import {
45
51
  accountEmailHintSchema,
46
52
  flowLeadSource,
53
+ flowSlugSchema,
47
54
  JOB_APPLICATION_FLOW_SLUG,
48
55
  leadSubmitNextSchema,
49
56
  } from "@working-theory/validation";
@@ -52,15 +59,13 @@ import { getLocale, getTranslations } from "next-intl/server";
52
59
  import { z } from "zod";
53
60
 
54
61
  import { accountInviteHref } from "../../../../lib/account-invite";
62
+ import { runBotCheck } from "../../../../lib/bot-check";
55
63
  import {
56
64
  consentVisitorHeader,
57
65
  readConsentVisitor,
58
66
  } from "../../../../lib/consent-visitor";
59
67
  import { writeLead } from "../../../../src/lib/lead-write";
60
- import {
61
- checkRateLimit,
62
- clientIpFromHeaders,
63
- } from "../../../../src/lib/rate-limit";
68
+ import { takeVisitorLimit } from "../../../../src/lib/rate-limit";
64
69
 
65
70
  const GENERIC_ERROR = "Something went wrong, please try again.";
66
71
  const RATE_LIMITED =
@@ -69,6 +74,8 @@ const CONSENT_REQUIRED = "Please accept the terms to send your application.";
69
74
 
70
75
  const RATE_LIMIT_MAX_CALLS = 30;
71
76
  const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
77
+ /** Outside a request scope there is no address: the shared bucket. */
78
+ const NO_HEADERS = { get: () => null };
72
79
 
73
80
  const SOURCE = flowLeadSource(JOB_APPLICATION_FLOW_SLUG);
74
81
  const JOB_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
@@ -84,6 +91,7 @@ const RESERVED_FIELDS = new Set([
84
91
  "consent",
85
92
  "name",
86
93
  "job",
94
+ "applicationFlow",
87
95
  "source",
88
96
  LEAD_CAPTURE_HONEYPOT,
89
97
  ]);
@@ -191,8 +199,22 @@ async function successState(
191
199
  }
192
200
  }
193
201
 
202
+ /**
203
+ * The bound posting: today's string `job`, or `{ job, flow }` (D6). `null`
204
+ * for anything else — a bound argument comes back from the browser, so its
205
+ * type is checked, not only its text.
206
+ */
207
+ function readPosting(posting: unknown): { job: string; applicationFlow?: string } | null {
208
+ if (typeof posting === "string") return JOB_SLUG_RE.test(posting) ? { job: posting } : null;
209
+ if (typeof posting !== "object" || posting === null) return null;
210
+ const { job, flow } = posting as { job?: unknown; flow?: unknown };
211
+ if (typeof job !== "string" || !JOB_SLUG_RE.test(job)) return null;
212
+ if (!flowSlugSchema.safeParse(flow).success) return null;
213
+ return { job, applicationFlow: flow as string };
214
+ }
215
+
194
216
  export async function captureLeadAction(
195
- job: string,
217
+ posting: string | { job: string; flow: string },
196
218
  _prevState: LeadCaptureFormState,
197
219
  formData: FormData,
198
220
  ): Promise<LeadCaptureFormState> {
@@ -200,10 +222,11 @@ export async function captureLeadAction(
200
222
  if (typeof honeypot === "string" && honeypot !== "")
201
223
  return { kind: "success" };
202
224
 
203
- // A bound argument comes back from the browser: check its type, not only its text.
204
- if (typeof job !== "string" || !JOB_SLUG_RE.test(job)) {
225
+ const attribution = readPosting(posting);
226
+ if (attribution === null) {
205
227
  return { kind: "error", message: GENERIC_ERROR };
206
228
  }
229
+ const { job, applicationFlow } = attribution;
207
230
 
208
231
  const fieldErrors: Record<string, string> = {};
209
232
  if (formData.get("consent") !== "true")
@@ -238,6 +261,7 @@ export async function captureLeadAction(
238
261
  ...(phone !== undefined && { phone }),
239
262
  ...(linkedin_url !== undefined && { linkedin_url }),
240
263
  job,
264
+ ...(applicationFlow !== undefined && { applicationFlow }),
241
265
  };
242
266
 
243
267
  let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
@@ -247,16 +271,13 @@ export async function captureLeadAction(
247
271
  // No request scope — no forwarding, and the visitor is "unknown".
248
272
  }
249
273
 
250
- const visitor = requestHeaders
251
- ? clientIpFromHeaders(requestHeaders)
252
- : "unknown";
253
274
  if (
254
- visitor !== "unknown" &&
255
- !checkRateLimit(
256
- `contact:${visitor}`,
275
+ !takeVisitorLimit(
276
+ "contact",
277
+ requestHeaders ?? NO_HEADERS,
257
278
  RATE_LIMIT_MAX_CALLS,
258
279
  RATE_LIMIT_WINDOW_MS,
259
- )
280
+ ).allowed
260
281
  ) {
261
282
  return { kind: "error", message: RATE_LIMITED };
262
283
  }
@@ -282,6 +303,15 @@ export async function captureLeadAction(
282
303
  // No request scope — submit with no visitor-link header.
283
304
  }
284
305
 
306
+ // #9255 D4 — the spoke edge's bot check, immediately before the write.
307
+ const botCheck = await runBotCheck({
308
+ source: "bot-check",
309
+ target: "captureLeadAction",
310
+ scope: "write",
311
+ });
312
+ if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
313
+ if (botCheck === "unavailable") return { kind: "unavailable" };
314
+
285
315
  const res = await writeLead(
286
316
  { source: SOURCE, email, page: "careers", consent: true, payload },
287
317
  init,
@@ -1,5 +1,5 @@
1
1
  // JobPosting detail page. Reads the JobPosting MDX for the given slug, renders via
2
- // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
2
+ // @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
3
  // Careers list at /careers links here; each job posting lives at /careers/<slug>.
4
4
  //
5
5
  // Under the posting, inside <main>, an Apply button (#8849 D8) opens
@@ -9,7 +9,11 @@
9
9
  // form this page used to draw under `#apply`. The posting's JSON-LD
10
10
  // ApplyAction targets the same URL (#8849 D3). The page makes no hub read.
11
11
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
12
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
12
+ import {
13
+ getMdxSource as loadMdxSource,
14
+ renderPage,
15
+ renderPageMetadata,
16
+ } from "@working-theory/page-renderer";
13
17
  import { Button, Container, Link, localeHref } from "@working-theory/ui";
14
18
  import type { Metadata } from "next";
15
19
  import { getTranslations } from "next-intl/server";
@@ -32,12 +36,25 @@ export default async function Page({
32
36
  getMdxSource,
33
37
  }: Props): Promise<ReactElement> {
34
38
  const { locale, slug } = await params;
39
+ // A page that composes `sections[]` renders them (JSON-LD is built from
40
+ // the same sections); a prose page renders its MDX body — the rule
41
+ // /vs/[slug] applies (#10122).
42
+ const source = await (getMdxSource ?? loadMdxSource)(
43
+ "JobPosting",
44
+ slug,
45
+ locale,
46
+ );
47
+ const composed =
48
+ source !== null &&
49
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
50
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
51
+ 0;
35
52
  const t = await getTranslations({ locale, namespace: "careers" });
36
53
  return renderPage({
37
54
  type: "JobPosting",
38
55
  slug,
39
56
  locale,
40
- bodyMode: "mdx-body",
57
+ ...(composed ? {} : { bodyMode: "mdx-body" as const }),
41
58
  trailing: (
42
59
  <Container className="pb-section">
43
60
  <Button asChild size="big">