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
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Tests for consent-visitor.ts (the `ailk-vid` cookie helpers) and for
3
3
  * `acceptCookiesAction`/`declineCookiesAction` (#8438 D12,
4
- * `app/[locale]/consent.actions.ts`) — the banner's one write path.
4
+ * `app/[locale]/consent.actions.ts`) — the banner's one write path. The
5
+ * cookie is signed by the site (#8556, spec 9965 D7): a read returns the UUID
6
+ * only when the signature verifies.
5
7
  */
6
8
  import {
7
9
  consentVisitorHeader,
@@ -13,6 +15,72 @@ import {
13
15
 
14
16
  const VALID_ID = "11111111-2222-4333-8444-555555555555";
15
17
 
18
+ /** The cookie value `ensureConsentVisitor` writes, captured from a real mint. */
19
+ function mintedCookie(): { id: string; value: string } {
20
+ const set = jest.fn();
21
+ const id = ensureConsentVisitor({ get: () => undefined, set });
22
+ return { id, value: set.mock.calls[0][1] as string };
23
+ }
24
+
25
+ /** `value` with the signature's first character changed (the last carries padding bits). */
26
+ function tampered(value: string): string {
27
+ const at = value.indexOf(".") + 1;
28
+ return `${value.slice(0, at)}${value[at] === "A" ? "B" : "A"}${value.slice(at + 1)}`;
29
+ }
30
+
31
+ /** The env keys the signing secret is chosen from, and the two that decide whether a missing one throws. */
32
+ const SECRET_ENV_KEYS = [
33
+ "NODE_ENV",
34
+ "NEXT_PHASE",
35
+ "BETTER_AUTH_SECRET",
36
+ "AILK_HUB_SESSION_SECRET",
37
+ "AILK_SPOKE_KEY",
38
+ ] as const;
39
+
40
+ /**
41
+ * Run `fn` with exactly `vars` set among `SECRET_ENV_KEYS` (the rest unset),
42
+ * restoring every one of them after.
43
+ */
44
+ function withEnv<T>(
45
+ vars: Partial<Record<(typeof SECRET_ENV_KEYS)[number], string>>,
46
+ fn: () => T,
47
+ ): T {
48
+ const saved = Object.fromEntries(SECRET_ENV_KEYS.map((key) => [key, process.env[key]]));
49
+ const restore = () => {
50
+ for (const [key, value] of Object.entries(saved)) {
51
+ if (value === undefined) delete process.env[key];
52
+ else process.env[key] = value;
53
+ }
54
+ };
55
+ for (const key of SECRET_ENV_KEYS) delete process.env[key];
56
+ Object.assign(process.env, vars);
57
+ let result: T;
58
+ try {
59
+ result = fn();
60
+ } catch (err) {
61
+ restore();
62
+ throw err;
63
+ }
64
+ if (result instanceof Promise) return result.finally(restore) as T;
65
+ restore();
66
+ return result;
67
+ }
68
+
69
+ /** Run `fn` as a production process holding none of the three secrets. */
70
+ function withoutSecret<T>(fn: () => T): T {
71
+ return withEnv({ NODE_ENV: "production" }, fn);
72
+ }
73
+
74
+ /** A cookie value minted under `vars`, read back under `readVars`. */
75
+ function mintThenRead(
76
+ vars: Parameters<typeof withEnv>[0],
77
+ readVars: Parameters<typeof withEnv>[0],
78
+ ): { id: string; read: string | undefined } {
79
+ const { id, value } = withEnv(vars, mintedCookie);
80
+ const read = withEnv(readVars, () => readConsentVisitor({ get: () => ({ value }) }));
81
+ return { id, read };
82
+ }
83
+
16
84
  describe("isValidConsentVisitorId", () => {
17
85
  it.each([VALID_ID, "aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee"])("%s is a valid v4 UUID", (id) => {
18
86
  expect(isValidConsentVisitorId(id)).toBe(true);
@@ -30,26 +98,121 @@ describe("isValidConsentVisitorId", () => {
30
98
  });
31
99
 
32
100
  describe("readConsentVisitor", () => {
33
- it("returns the cookie's value when it is a valid id", () => {
34
- const cookies = { get: () => ({ value: VALID_ID }) };
35
- expect(readConsentVisitor(cookies)).toBe(VALID_ID);
101
+ it("returns the UUID of a value ensureConsentVisitor signed", () => {
102
+ const { id, value } = mintedCookie();
103
+ expect(readConsentVisitor({ get: () => ({ value }) })).toBe(id);
36
104
  });
37
105
 
38
106
  it("returns undefined with no cookie, or an invalid value", () => {
39
107
  expect(readConsentVisitor({ get: () => undefined })).toBeUndefined();
40
108
  expect(readConsentVisitor({ get: () => ({ value: "nope" }) })).toBeUndefined();
41
109
  });
110
+
111
+ // #8556: a well-formed id the browser chose is not a visitor id the site minted.
112
+ it("returns undefined for a bare valid v4 UUID", () => {
113
+ expect(readConsentVisitor({ get: () => ({ value: VALID_ID }) })).toBeUndefined();
114
+ });
115
+
116
+ it("returns undefined, without throwing, for a changed or truncated signature", () => {
117
+ const { value } = mintedCookie();
118
+ expect(readConsentVisitor({ get: () => ({ value: tampered(value) }) })).toBeUndefined();
119
+ expect(() => readConsentVisitor({ get: () => ({ value: value.slice(0, -3) }) })).not.toThrow();
120
+ expect(readConsentVisitor({ get: () => ({ value: value.slice(0, -3) }) })).toBeUndefined();
121
+ expect(readConsentVisitor({ get: () => ({ value: `${VALID_ID}.` }) })).toBeUndefined();
122
+ });
123
+
124
+ it("returns undefined for another id under a valid signature", () => {
125
+ const { id, value } = mintedCookie();
126
+ const swapped = value.replace(id, VALID_ID);
127
+ expect(readConsentVisitor({ get: () => ({ value: swapped }) })).toBeUndefined();
128
+ });
129
+
130
+ it("returns undefined, without throwing, when the secret cannot be resolved", () => {
131
+ const { value } = mintedCookie();
132
+ withoutSecret(() => expect(readConsentVisitor({ get: () => ({ value }) })).toBeUndefined());
133
+ });
134
+ });
135
+
136
+ // #8556, operator decision option (b): the site signs with a secret it
137
+ // already holds — BETTER_AUTH_SECRET, else AILK_HUB_SESSION_SECRET, else
138
+ // AILK_SPOKE_KEY — and only with none of them does a production mint fail.
139
+ describe("the signing secret, in its fixed order (production)", () => {
140
+ const PROD = { NODE_ENV: "production" } as const;
141
+ const AUTH = "auth-secret-of-this-hub-0123456789abcdef";
142
+ const SESSION = "hub-session-secret-of-this-spoke-0123456789";
143
+ const SPOKE = "spoke-key-of-this-spoke-0123456789";
144
+
145
+ it("a hub signs with BETTER_AUTH_SECRET, ahead of the other two", () => {
146
+ const all = { ...PROD, BETTER_AUTH_SECRET: AUTH, AILK_HUB_SESSION_SECRET: SESSION, AILK_SPOKE_KEY: SPOKE };
147
+ const kept = mintThenRead(all, { ...PROD, BETTER_AUTH_SECRET: AUTH });
148
+ expect(kept.read).toBe(kept.id);
149
+ expect(mintThenRead(all, { ...PROD, AILK_HUB_SESSION_SECRET: SESSION }).read).toBeUndefined();
150
+ expect(mintThenRead(all, { ...PROD, AILK_SPOKE_KEY: SPOKE }).read).toBeUndefined();
151
+ });
152
+
153
+ it("a hub-sign-in spoke signs with AILK_HUB_SESSION_SECRET, ahead of AILK_SPOKE_KEY", () => {
154
+ const spoke = { ...PROD, AILK_HUB_SESSION_SECRET: SESSION, AILK_SPOKE_KEY: SPOKE };
155
+ const kept = mintThenRead(spoke, { ...PROD, AILK_HUB_SESSION_SECRET: SESSION });
156
+ expect(kept.read).toBe(kept.id);
157
+ expect(mintThenRead(spoke, { ...PROD, AILK_SPOKE_KEY: SPOKE }).read).toBeUndefined();
158
+ });
159
+
160
+ it("a spoke holding only AILK_SPOKE_KEY signs and verifies its own id", () => {
161
+ const spoke = { ...PROD, AILK_SPOKE_KEY: SPOKE };
162
+ const set = jest.fn();
163
+ const id = withEnv(spoke, () => ensureConsentVisitor({ get: () => undefined, set }));
164
+ const value = set.mock.calls[0][1] as string;
165
+ expect(value).toMatch(new RegExp(`^${id}\\.[A-Za-z0-9_-]{43}$`));
166
+ expect(withEnv(spoke, () => readConsentVisitor({ get: () => ({ value }) }))).toBe(id);
167
+ expect(withEnv(spoke, () => readConsentVisitorFromCookieHeader(`ailk-vid=${value}`))).toBe(id);
168
+ });
169
+
170
+ it("a verifier accepts only what its own site signed", () => {
171
+ const other = (key: string) => `${key}-of-another-site`;
172
+ expect(mintThenRead({ ...PROD, AILK_SPOKE_KEY: SPOKE }, { ...PROD, AILK_SPOKE_KEY: other(SPOKE) }).read).toBeUndefined();
173
+ expect(
174
+ mintThenRead(
175
+ { ...PROD, AILK_HUB_SESSION_SECRET: SESSION },
176
+ { ...PROD, AILK_HUB_SESSION_SECRET: other(SESSION) },
177
+ ).read,
178
+ ).toBeUndefined();
179
+ expect(
180
+ mintThenRead({ ...PROD, BETTER_AUTH_SECRET: AUTH }, { ...PROD, BETTER_AUTH_SECRET: other(AUTH) }).read,
181
+ ).toBeUndefined();
182
+ });
183
+
184
+ it("a whitespace-only or short spoke secret is none: with nothing else set the mint throws", () => {
185
+ const set = jest.fn();
186
+ for (const vars of [
187
+ { AILK_HUB_SESSION_SECRET: " ", AILK_SPOKE_KEY: " " },
188
+ { AILK_HUB_SESSION_SECRET: "x".repeat(31), AILK_SPOKE_KEY: ` ${"y".repeat(31)} ` },
189
+ ]) {
190
+ withEnv({ ...PROD, ...vars }, () =>
191
+ expect(() => ensureConsentVisitor({ get: () => undefined, set })).toThrow(/BETTER_AUTH_SECRET/),
192
+ );
193
+ }
194
+ expect(set).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("a short AILK_HUB_SESSION_SECRET is skipped for AILK_SPOKE_KEY", () => {
198
+ const kept = mintThenRead(
199
+ { ...PROD, AILK_HUB_SESSION_SECRET: "too-short", AILK_SPOKE_KEY: SPOKE },
200
+ { ...PROD, AILK_SPOKE_KEY: SPOKE },
201
+ );
202
+ expect(kept.read).toBe(kept.id);
203
+ });
42
204
  });
43
205
 
44
206
  describe("ensureConsentVisitor", () => {
45
- it("keeps an existing valid id and sets nothing", () => {
207
+ it("keeps an existing signed id and sets nothing", () => {
208
+ const { id, value } = mintedCookie();
46
209
  const set = jest.fn();
47
- const cookies = { get: () => ({ value: VALID_ID }), set };
48
- expect(ensureConsentVisitor(cookies)).toBe(VALID_ID);
210
+ const cookies = { get: () => ({ value }), set };
211
+ expect(ensureConsentVisitor(cookies)).toBe(id);
49
212
  expect(set).not.toHaveBeenCalled();
50
213
  });
51
214
 
52
- it("mints and sets a fresh v4 UUID when absent", () => {
215
+ it("mints and sets a fresh signed v4 UUID when absent", () => {
53
216
  const set = jest.fn();
54
217
  const cookies = { get: () => undefined, set };
55
218
  const id = ensureConsentVisitor(cookies);
@@ -57,10 +220,25 @@ describe("ensureConsentVisitor", () => {
57
220
  expect(set).toHaveBeenCalledTimes(1);
58
221
  const [name, value, options] = set.mock.calls[0] as [string, string, Record<string, unknown>];
59
222
  expect(name).toBe("ailk-vid");
60
- expect(value).toBe(id);
223
+ expect(value).toMatch(new RegExp(`^${id}\\.[A-Za-z0-9_-]{43}$`));
61
224
  expect(options).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/" });
62
225
  });
63
226
 
227
+ it("re-mints over a bare UUID, an unsigned cookie from before #8556", () => {
228
+ const set = jest.fn();
229
+ const id = ensureConsentVisitor({ get: () => ({ value: VALID_ID }), set });
230
+ expect(id).not.toBe(VALID_ID);
231
+ expect(set).toHaveBeenCalledTimes(1);
232
+ });
233
+
234
+ it("throws and sets nothing when the secret cannot be resolved", () => {
235
+ const set = jest.fn();
236
+ withoutSecret(() =>
237
+ expect(() => ensureConsentVisitor({ get: () => undefined, set })).toThrow(/BETTER_AUTH_SECRET/),
238
+ );
239
+ expect(set).not.toHaveBeenCalled();
240
+ });
241
+
64
242
  it("mints a fresh id when the stored value fails the UUID shape", () => {
65
243
  const set = jest.fn();
66
244
  const cookies = { get: () => ({ value: "garbage" }), set };
@@ -78,8 +256,9 @@ describe("consentVisitorHeader", () => {
78
256
 
79
257
  describe("readConsentVisitorFromCookieHeader", () => {
80
258
  it("reads the id out of a raw Cookie header", () => {
81
- expect(readConsentVisitorFromCookieHeader(`ailk-vid=${VALID_ID}`)).toBe(VALID_ID);
82
- expect(readConsentVisitorFromCookieHeader(`session=abc; ailk-vid=${VALID_ID}; other=1`)).toBe(VALID_ID);
259
+ const { id, value } = mintedCookie();
260
+ expect(readConsentVisitorFromCookieHeader(`ailk-vid=${value}`)).toBe(id);
261
+ expect(readConsentVisitorFromCookieHeader(`session=abc; ailk-vid=${value}; other=1`)).toBe(id);
83
262
  });
84
263
 
85
264
  it("returns undefined with no header, no matching cookie, or an invalid value", () => {
@@ -88,6 +267,12 @@ describe("readConsentVisitorFromCookieHeader", () => {
88
267
  expect(readConsentVisitorFromCookieHeader("session=abc")).toBeUndefined();
89
268
  expect(readConsentVisitorFromCookieHeader("ailk-vid=garbage")).toBeUndefined();
90
269
  });
270
+
271
+ it("returns undefined for a bare UUID or a changed signature (#8556)", () => {
272
+ const { value } = mintedCookie();
273
+ expect(readConsentVisitorFromCookieHeader(`ailk-vid=${VALID_ID}`)).toBeUndefined();
274
+ expect(readConsentVisitorFromCookieHeader(`ailk-vid=${tampered(value)}`)).toBeUndefined();
275
+ });
91
276
  });
92
277
 
93
278
  // ── acceptCookiesAction / declineCookiesAction (D12) ──────────────────────
@@ -134,15 +319,29 @@ describe("acceptCookiesAction / declineCookiesAction", () => {
134
319
  });
135
320
 
136
321
  it("keeps an existing visitor id and declines", async () => {
137
- mockCookieGet.mockReturnValue({ value: VALID_ID });
322
+ const { id, value } = mintedCookie();
323
+ mockCookieGet.mockReturnValue({ value });
138
324
  const { declineCookiesAction } = await import("../../app/[locale]/consent.actions");
139
325
  await declineCookiesAction();
140
326
  expect(mockCookieSet).not.toHaveBeenCalled();
141
327
  const [body] = mockRecordCookieChoice.mock.calls[0] as [{ visitorId: string; choice: string }];
142
- expect(body.visitorId).toBe(VALID_ID);
328
+ expect(body.visitorId).toBe(id);
143
329
  expect(body.choice).toBe("declined");
144
330
  });
145
331
 
332
+ it("records nothing and resolves when the secret cannot be resolved (fail closed)", async () => {
333
+ const error = jest.spyOn(console, "error").mockImplementation(() => {});
334
+ const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
335
+ try {
336
+ await withoutSecret(() => expect(acceptCookiesAction()).resolves.toBeUndefined());
337
+ expect(error).toHaveBeenCalled();
338
+ } finally {
339
+ error.mockRestore();
340
+ }
341
+ expect(mockCookieSet).not.toHaveBeenCalled();
342
+ expect(mockRecordCookieChoice).not.toHaveBeenCalled();
343
+ });
344
+
146
345
  it("resolves even when the client rejects", async () => {
147
346
  mockRecordCookieChoice.mockRejectedValue(new TypeError("fetch failed"));
148
347
  const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
@@ -50,6 +50,12 @@ describe("deckCandidates (#8687 D3, AC-2)", () => {
50
50
  ]);
51
51
  });
52
52
 
53
+ it("/pricing/<offering> resolves the offering's Pricing page (#10044 D14)", () => {
54
+ expect(deckCandidates("/pricing/website-rebuild", LOCALES)).toEqual([
55
+ { type: "Pricing", slug: "website-rebuild" },
56
+ ]);
57
+ });
58
+
53
59
  it("/about (core, always scaffolded) resolves the route row only", () => {
54
60
  expect(deckCandidates("/about", LOCALES)).toEqual([
55
61
  { type: "AboutPage", slug: "about" },
@@ -108,7 +114,13 @@ describe("DECK_ROUTES parity (#8687 D3)", () => {
108
114
  } catch {
109
115
  continue;
110
116
  }
111
- if (!pageSource.includes('bodyMode: "mdx-body"')) {
117
+ // A content hub (#10121) names its body rule in its HubConfig; the
118
+ // "mdx-body" and "composed" rules can render the MDX body, as the
119
+ // inline `bodyMode` literal they replaced did. "sections" never does.
120
+ const rendersMdxBody =
121
+ pageSource.includes('bodyMode: "mdx-body"') ||
122
+ /\bbody: "(?:mdx-body|composed)"/.test(pageSource);
123
+ if (!rendersMdxBody) {
112
124
  sectionsRoutes.add(row.route);
113
125
  }
114
126
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * #10041 D8 / AC-2d — `listDocsPages` walks a docs directory and returns one
5
+ * entry per titled page at the docs' public path, by Fumadocs' slug rule.
6
+ */
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ type DocsIndexModule = typeof import("../docs-index");
12
+
13
+ function loadWith(docsPath: string | undefined): DocsIndexModule {
14
+ if (docsPath === undefined) delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
15
+ else process.env.NEXT_PUBLIC_AILK_DOCS_PATH = docsPath;
16
+ let mod: DocsIndexModule | undefined;
17
+ try {
18
+ jest.isolateModules(() => {
19
+ mod = jest.requireActual<DocsIndexModule>("../docs-index");
20
+ });
21
+ } finally {
22
+ delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
23
+ }
24
+ return mod!;
25
+ }
26
+
27
+ function page(title: string | null, description?: string): string {
28
+ const lines = ["---"];
29
+ if (title !== null) lines.push(`title: ${title}`);
30
+ if (description) lines.push(`description: ${description}`);
31
+ lines.push("---", "", "Body.", "");
32
+ return lines.join("\n");
33
+ }
34
+
35
+ let dir: string;
36
+
37
+ beforeAll(() => {
38
+ dir = mkdtempSync(join(tmpdir(), "docs-index-"));
39
+ mkdirSync(join(dir, "guides", "(setup)"), { recursive: true });
40
+ writeFileSync(join(dir, "index.mdx"), page("Docs", "Everything."));
41
+ writeFileSync(join(dir, "configure.mdx"), page("Configure", "Set it up."));
42
+ writeFileSync(join(dir, "guides", "(setup)", "dns.mdx"), page("DNS"));
43
+ writeFileSync(join(dir, "draft.mdx"), page(null, "No title yet."));
44
+ writeFileSync(join(dir, "notes.txt"), "not a doc");
45
+ });
46
+
47
+ afterAll(() => {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ });
50
+
51
+ describe("listDocsPages", () => {
52
+ it("lists every titled page at the configured path, by the slug rule", () => {
53
+ const { listDocsPages } = loadWith("/working-theory/docs");
54
+ expect(listDocsPages(dir)).toEqual([
55
+ {
56
+ url: "/working-theory/docs/configure",
57
+ title: "Configure",
58
+ description: "Set it up.",
59
+ },
60
+ { url: "/working-theory/docs/guides/dns", title: "DNS" },
61
+ {
62
+ url: "/working-theory/docs",
63
+ title: "Docs",
64
+ description: "Everything.",
65
+ },
66
+ ]);
67
+ });
68
+
69
+ it("lists at /docs with no setting", () => {
70
+ const { listDocsPages } = loadWith(undefined);
71
+ expect(listDocsPages(dir).map((p) => p.url)).toEqual([
72
+ "/docs/configure",
73
+ "/docs/guides/dns",
74
+ "/docs",
75
+ ]);
76
+ });
77
+
78
+ it("returns [] for an absent directory", () => {
79
+ const { listDocsPages } = loadWith("/working-theory/docs");
80
+ expect(listDocsPages(join(dir, "missing"))).toEqual([]);
81
+ });
82
+
83
+ it("maps an adapter docs row onto the docs path", () => {
84
+ const { adapterDocsUrl, isDocsType } = loadWith("/working-theory/docs");
85
+ expect(isDocsType("DocsDetail")).toBe(true);
86
+ expect(isDocsType("DocsList")).toBe(true);
87
+ expect(isDocsType("BlogPosting")).toBe(false);
88
+ expect(adapterDocsUrl("DocsDetail", "configure")).toBe(
89
+ "/working-theory/docs/configure",
90
+ );
91
+ expect(adapterDocsUrl("DocsList", "index")).toBe("/working-theory/docs");
92
+ });
93
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * #10041 D2 — the one docs-path reader. `DOCS_PATH` is read from
5
+ * `NEXT_PUBLIC_AILK_DOCS_PATH` at module load, so every setting loads the
6
+ * module afresh under `jest.isolateModules`.
7
+ */
8
+ import type { NavItem } from "@working-theory/ui";
9
+ import { Menu as Icon } from "lucide-react";
10
+
11
+ type DocsPathModule = typeof import("../docs-path");
12
+
13
+ function loadWith(docsPath: string | undefined): DocsPathModule {
14
+ const previous = process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
15
+ if (docsPath === undefined) delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
16
+ else process.env.NEXT_PUBLIC_AILK_DOCS_PATH = docsPath;
17
+ let mod: DocsPathModule | undefined;
18
+ jest.isolateModules(() => {
19
+ mod = jest.requireActual<DocsPathModule>("../docs-path");
20
+ });
21
+ if (previous === undefined) delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
22
+ else process.env.NEXT_PUBLIC_AILK_DOCS_PATH = previous;
23
+ return mod!;
24
+ }
25
+
26
+ describe("docs-path — no setting (AC-3)", () => {
27
+ const m = loadWith(undefined);
28
+
29
+ it("serves the docs at the internal route", () => {
30
+ expect(m.DOCS_ROUTE).toBe("/docs");
31
+ expect(m.DOCS_PATH).toBe("/docs");
32
+ });
33
+
34
+ it.each([
35
+ "/docs",
36
+ "/docs/configure",
37
+ "/docs?q=1",
38
+ "/docs#a",
39
+ "/about",
40
+ "https://example.com/docs",
41
+ ])("toPublicDocsHref is the identity for %s", (href) => {
42
+ expect(m.toPublicDocsHref(href)).toBe(href);
43
+ });
44
+
45
+ it("maps nothing to the internal route", () => {
46
+ expect(m.toInternalDocsPath("/docs")).toBeNull();
47
+ expect(m.toInternalDocsPath("/working-theory/docs")).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe("docs-path — docsPath /working-theory/docs (AC-2a, AC-2b)", () => {
52
+ const m = loadWith("/working-theory/docs");
53
+
54
+ it("reads the public base from the env", () => {
55
+ expect(m.DOCS_ROUTE).toBe("/docs");
56
+ expect(m.DOCS_PATH).toBe("/working-theory/docs");
57
+ });
58
+
59
+ it.each([
60
+ ["/docs", "/working-theory/docs"],
61
+ ["/docs/configure", "/working-theory/docs/configure"],
62
+ ["/docs/deploy#dns", "/working-theory/docs/deploy#dns"],
63
+ ["/docs?q=x", "/working-theory/docs?q=x"],
64
+ ["/docs#top", "/working-theory/docs#top"],
65
+ ])("rewrites %s to %s", (href, expected) => {
66
+ expect(m.toPublicDocsHref(href)).toBe(expected);
67
+ });
68
+
69
+ it.each([
70
+ "https://example.com/docs",
71
+ "/docsets",
72
+ "/docs-old",
73
+ "/pt-BR/docs",
74
+ "",
75
+ "#docs",
76
+ "mailto:a@b.co",
77
+ ])("leaves %s unchanged", (href) => {
78
+ expect(m.toPublicDocsHref(href)).toBe(href);
79
+ });
80
+
81
+ it.each([
82
+ ["/working-theory/docs", "/docs"],
83
+ ["/working-theory/docs/configure", "/docs/configure"],
84
+ ["/working-theory/docs/guides/dns", "/docs/guides/dns"],
85
+ ])("maps the public %s to the internal %s", (bare, internal) => {
86
+ expect(m.toInternalDocsPath(bare)).toBe(internal);
87
+ });
88
+
89
+ it.each([
90
+ "/docs",
91
+ "/docs/configure",
92
+ "/working-theory",
93
+ "/working-theory/docsets",
94
+ "/working-theory/docs-x",
95
+ "/",
96
+ ])("does not map %s", (bare) => {
97
+ expect(m.toInternalDocsPath(bare)).toBeNull();
98
+ });
99
+
100
+ it("maps every nav path at /docs and keeps everything else by reference (AC-2c)", () => {
101
+ const items: NavItem[] = [
102
+ {
103
+ labelKey: "product",
104
+ flyout: {
105
+ features: [
106
+ { labelKey: "f1", path: "/docs/configure", icon: Icon },
107
+ { labelKey: "f2", path: "/features/one" },
108
+ ],
109
+ groups: [
110
+ { headingKey: "h", items: [{ labelKey: "g1", path: "/docs" }] },
111
+ ],
112
+ actions: [{ labelKey: "a1", path: "/docs/start", icon: Icon }],
113
+ width: "panel",
114
+ },
115
+ },
116
+ { labelKey: "docs", path: "/docs" },
117
+ { labelKey: "pricing", path: "/pricing", emphasis: "brand" },
118
+ ];
119
+ const mapped = m.withPublicDocsPaths(items);
120
+
121
+ expect(mapped[0]!.flyout!.features.map((f) => f.path)).toEqual([
122
+ "/working-theory/docs/configure",
123
+ "/features/one",
124
+ ]);
125
+ expect(mapped[0]!.flyout!.groups![0]!.items[0]!.path).toBe(
126
+ "/working-theory/docs",
127
+ );
128
+ expect(mapped[0]!.flyout!.actions[0]!.path).toBe(
129
+ "/working-theory/docs/start",
130
+ );
131
+ expect(mapped[0]!.flyout!.actions[0]!.icon).toBe(Icon);
132
+ expect(mapped[0]!.flyout!.features[0]!.icon).toBe(Icon);
133
+ expect(mapped[0]!.flyout!.width).toBe("panel");
134
+ expect(mapped[1]).toEqual({
135
+ labelKey: "docs",
136
+ path: "/working-theory/docs",
137
+ });
138
+ expect(mapped[2]).toEqual({
139
+ labelKey: "pricing",
140
+ path: "/pricing",
141
+ emphasis: "brand",
142
+ });
143
+ // The source array is not mutated.
144
+ expect(items[1]!.path).toBe("/docs");
145
+ });
146
+
147
+ it("maps footer column items", () => {
148
+ const cols = [
149
+ {
150
+ headingKey: "h",
151
+ items: [
152
+ { labelKey: "docs", path: "/docs/configure" },
153
+ { labelKey: "a", path: "/about" },
154
+ ],
155
+ },
156
+ ];
157
+ expect(m.withPublicDocsColumns(cols)).toEqual([
158
+ {
159
+ headingKey: "h",
160
+ items: [
161
+ { labelKey: "docs", path: "/working-theory/docs/configure" },
162
+ { labelKey: "a", path: "/about" },
163
+ ],
164
+ },
165
+ ]);
166
+ expect(cols[0]!.items[0]!.path).toBe("/docs/configure");
167
+ });
168
+ });