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
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The site's header settings (#10046) — `readSiteHeader` over a fixture
3
+ * `_site.mdx` set through `AILK_CONTENT_ROOT` (AC-2), and the pure
4
+ * `signedOutHeaderActions` composition over the generated auth pair (AC-3).
5
+ */
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import type * as SiteHeaderModule from "../site-header";
11
+
12
+ let root: string;
13
+ const previousRoot = process.env.AILK_CONTENT_ROOT;
14
+ let mod: typeof SiteHeaderModule;
15
+ let warn: jest.SpyInstance;
16
+
17
+ beforeEach(() => {
18
+ root = mkdtempSync(join(tmpdir(), "site-header-"));
19
+ process.env.AILK_CONTENT_ROOT = root;
20
+ // A fresh module per test so the warn-once flag starts clear.
21
+ jest.isolateModules(() => {
22
+ mod = require("../site-header") as typeof SiteHeaderModule;
23
+ });
24
+ warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
25
+ });
26
+
27
+ afterEach(() => {
28
+ warn.mockRestore();
29
+ rmSync(root, { recursive: true, force: true });
30
+ if (previousRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
31
+ else process.env.AILK_CONTENT_ROOT = previousRoot;
32
+ });
33
+
34
+ function writeSite(frontmatter: string): void {
35
+ writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n`);
36
+ }
37
+
38
+ describe("readSiteHeader (#10046 AC-2)", () => {
39
+ it("returns the parsed header for a valid one", () => {
40
+ writeSite(
41
+ [
42
+ "siteName: Acme",
43
+ "header:",
44
+ " cta:",
45
+ " label: Start here",
46
+ " href: /diagnostic",
47
+ " signIn: hidden",
48
+ ].join("\n"),
49
+ );
50
+ expect(mod.readSiteHeader()).toEqual({
51
+ cta: { label: "Start here", href: "/diagnostic" },
52
+ signIn: "hidden",
53
+ });
54
+ expect(warn).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it("returns undefined silently when _site.mdx is absent", () => {
58
+ expect(mod.readSiteHeader()).toBeUndefined();
59
+ expect(warn).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("returns undefined silently when _site.mdx has no header", () => {
63
+ writeSite("siteName: Acme");
64
+ expect(mod.readSiteHeader()).toBeUndefined();
65
+ expect(warn).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("returns undefined and warns once, naming the field and never the value", () => {
69
+ writeSite(
70
+ ["siteName: Acme", "header:", " cta:", " label: Go", " href: javascript:x"].join("\n"),
71
+ );
72
+ expect(mod.readSiteHeader()).toBeUndefined();
73
+ expect(mod.readSiteHeader()).toBeUndefined();
74
+ expect(warn).toHaveBeenCalledTimes(1);
75
+ const message = String(warn.mock.calls[0]?.[0]);
76
+ expect(message).toContain("cta");
77
+ expect(message).not.toContain("javascript");
78
+ });
79
+
80
+ it("a bad header.style never drops the cta, and is not this reader's warning (#10049 AC-4)", () => {
81
+ writeSite(
82
+ [
83
+ "siteName: Acme",
84
+ "header:",
85
+ " style: minimal",
86
+ " cta:",
87
+ " label: Start here",
88
+ " href: /diagnostic",
89
+ ].join("\n"),
90
+ );
91
+ expect(mod.readSiteHeader()).toEqual({
92
+ cta: { label: "Start here", href: "/diagnostic" },
93
+ });
94
+ expect(warn).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ describe("signedOutHeaderActions (#10046 AC-3)", () => {
99
+ const cta = { label: "Start here", href: "/diagnostic" };
100
+ const hubPair = [
101
+ { labelKey: "signIn", path: "/sign-in", variant: "outline-strong" as const },
102
+ { labelKey: "signUp", path: "/sign-up", variant: "primary" as const },
103
+ ];
104
+ const ctaAction = { labelKey: "headerCta", path: "/diagnostic", variant: "primary" };
105
+
106
+ function frozenCopy<T>(value: T): T {
107
+ return JSON.parse(JSON.stringify(value)) as T;
108
+ }
109
+
110
+ it("with no header, or signIn without a cta, returns the pair unchanged", () => {
111
+ const before = frozenCopy(hubPair);
112
+ expect(mod.signedOutHeaderActions(undefined, hubPair)).toEqual(hubPair);
113
+ expect(mod.signedOutHeaderActions({ signIn: "hidden" }, hubPair)).toEqual(hubPair);
114
+ expect(hubPair).toEqual(before);
115
+ });
116
+
117
+ it("with a cta, returns a ghost Sign in then the cta, and drops Sign up", () => {
118
+ const before = frozenCopy(hubPair);
119
+ expect(mod.signedOutHeaderActions({ cta }, hubPair)).toEqual([
120
+ { labelKey: "signIn", path: "/sign-in", variant: "ghost" },
121
+ ctaAction,
122
+ ]);
123
+ expect(hubPair).toEqual(before);
124
+ });
125
+
126
+ it("with a cta and signIn hidden, returns only the cta", () => {
127
+ const before = frozenCopy(hubPair);
128
+ expect(mod.signedOutHeaderActions({ cta, signIn: "hidden" }, hubPair)).toEqual([ctaAction]);
129
+ expect(hubPair).toEqual(before);
130
+ });
131
+
132
+ it("on a hub spoke, restyles the /account Sign in link", () => {
133
+ const spokePair = [{ labelKey: "signIn", path: "/account", variant: "outline-strong" as const }];
134
+ const before = frozenCopy(spokePair);
135
+ expect(mod.signedOutHeaderActions({ cta }, spokePair)).toEqual([
136
+ { labelKey: "signIn", path: "/account", variant: "ghost" },
137
+ ctaAction,
138
+ ]);
139
+ expect(spokePair).toEqual(before);
140
+ });
141
+
142
+ it("on a spoke without sign-in, returns only the cta", () => {
143
+ const none: typeof hubPair = [];
144
+ expect(mod.signedOutHeaderActions({ cta }, none)).toEqual([ctaAction]);
145
+ expect(none).toEqual([]);
146
+ });
147
+
148
+ it("exports the label key the cta action carries", () => {
149
+ expect(mod.HEADER_CTA_LABEL_KEY).toBe("headerCta");
150
+ });
151
+ });
@@ -30,7 +30,17 @@
30
30
  * reads the same files with the same frontmatter schema the manifest is
31
31
  * compiled from. See docs/internal/decisions/scaffold-structured-data-check.md.
32
32
  */
33
- import { existsSync, readdirSync, readFileSync } from "node:fs";
33
+ import {
34
+ copyFileSync,
35
+ existsSync,
36
+ mkdirSync,
37
+ mkdtempSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ rmSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import { tmpdir } from "node:os";
34
44
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
35
45
 
36
46
  import { render } from "@testing-library/react";
@@ -61,6 +71,7 @@ import { parse as parseYaml } from "yaml";
61
71
 
62
72
  // Registers the site's `_site.mdx` identity at module load, as the `[locale]`
63
73
  // layout does in production (#6597).
74
+ import { MARKETING_PAGES } from "../modules.generated";
64
75
  import { readSiteIdentity } from "../site-identity";
65
76
 
66
77
  // Detail routes call notFound() on a missing page; a thrown error is how the
@@ -537,21 +548,51 @@ function routeIdentity(
537
548
  return null;
538
549
  }
539
550
 
551
+ /**
552
+ * The hub's own slug a `<dir>/[slug]` route must not render (#10044 D13):
553
+ * when `<dir>/page.tsx` renders a fixed `renderPage({ type, slug: "<literal>" })`
554
+ * of the SAME type as the dynamic route, that page is the hub's own, served at
555
+ * `<dir>` — `/pricing`'s `pricing.mdx`, never `/pricing/pricing`. Keyed on
556
+ * the parent directory, not the type alone: `launch-service` renders a
557
+ * `Service` that `/services/[slug]` must still render.
558
+ */
559
+ function hubOwnSlug(file: string, type: string): string | null {
560
+ if (basename(dirname(file)) !== "[slug]") return null;
561
+ const parent = join(dirname(dirname(file)), "page.tsx");
562
+ if (!existsSync(parent)) return null;
563
+ const identity = routeIdentity(readFileSync(parent, "utf8"));
564
+ return identity !== null && identity.type === type ? identity.slug : null;
565
+ }
566
+
540
567
  type PageModule = {
541
568
  default: (props: {
542
- params: Promise<{ locale: string; slug?: string; detail?: string }>;
569
+ params: Promise<{
570
+ locale: string;
571
+ slug?: string;
572
+ detail?: string;
573
+ child?: string;
574
+ }>;
543
575
  }) => Promise<ReactElement>;
544
576
  };
545
577
 
578
+ /** #10043 D7 — a Service under its category, `/services/<parent>/<slug>`. */
579
+ const NESTED_SERVICE_ROUTE = "services/[slug]/[child]";
580
+
546
581
  async function renderRoute(
547
582
  file: string,
548
583
  route: string,
549
584
  slug: string | undefined,
585
+ parent?: string,
550
586
  ): Promise<HTMLElement> {
551
587
  // Resolved at test time from the walk above — a static import would dangle
552
588
  // in a scaffold that gated the surface off.
553
589
  const mod = require(file) as PageModule;
554
- const params: { locale: string; slug?: string; detail?: string } = {
590
+ const params: {
591
+ locale: string;
592
+ slug?: string;
593
+ detail?: string;
594
+ child?: string;
595
+ } = {
555
596
  locale: DEFAULT_LOCALE,
556
597
  };
557
598
  // #6897 D2 — params are built from the route's OWN dynamic segments: the
@@ -561,6 +602,10 @@ async function renderRoute(
561
602
  if (route === "[slug]/[detail]") {
562
603
  params.slug = MEET_PLACEHOLDER_HUB;
563
604
  if (slug !== undefined) params.detail = slug;
605
+ } else if (route === NESTED_SERVICE_ROUTE) {
606
+ // #10043 D7 — the category rides `slug`, the service `child`.
607
+ if (parent !== undefined) params.slug = parent;
608
+ if (slug !== undefined) params.child = slug;
564
609
  } else if (slug !== undefined) {
565
610
  params.slug = slug;
566
611
  }
@@ -578,6 +623,14 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
578
623
  it.each(routes)("$route", async ({ route, file }) => {
579
624
  const label = `/${DEFAULT_LOCALE}${route === "" ? "" : "/" + route}`;
580
625
  const src = readFileSync(file, "utf8");
626
+ // #10040 decision 1 — without the marketing module `/` redirects to
627
+ // /account before any content: no HomePage seed, no JSON-LD, and its
628
+ // page.tsx still names renderPage, so it is skipped here rather than
629
+ // listed in NO_STRUCTURED_DATA_ROUTES (which refuses a renderPage route).
630
+ if (route === "" && !MARKETING_PAGES) {
631
+ console.info(`structured-data: ${label} not checked — no marketing module, / redirects to /account (#10040)`);
632
+ return;
633
+ }
581
634
  const exempt = exemption(route, src);
582
635
  const identity = routeIdentity(src);
583
636
  if (exempt !== undefined) {
@@ -608,6 +661,8 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
608
661
  // site, not a route with nothing to check. A `[slug]` route with no
609
662
  // pages of its type is simply empty — say so, do not fail.
610
663
  let slugs: string[];
664
+ // #10043 D7 — a Service's category, for the nested route's params.
665
+ const parents = new Map<string, string>();
611
666
  if (identity.slug !== null) {
612
667
  const page = await adapter.getPageSource(
613
668
  type,
@@ -621,9 +676,22 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
621
676
  }
622
677
  slugs = [identity.slug];
623
678
  } else {
624
- slugs = (await adapter.listDetail(type, DEFAULT_LOCALE)).map(
625
- (p: ContentPage) => p.slug,
679
+ // The hub's own page shares this route's type but is served at the hub
680
+ // (#10044 D13), so it is never rendered here.
681
+ const hubSlug = hubOwnSlug(file, type);
682
+ const pages = (await adapter.listDetail(type, DEFAULT_LOCALE)).filter(
683
+ (p: ContentPage) => p.slug !== hubSlug,
626
684
  );
685
+ for (const p of pages) if (p.parent !== undefined) parents.set(p.slug, p.parent);
686
+ // #10043 D7 — a Service with a `parent` is served at the nested route
687
+ // only (its flat URL redirects there); every other one at `[slug]`.
688
+ const served =
689
+ route === NESTED_SERVICE_ROUTE
690
+ ? pages.filter((p: ContentPage) => p.parent !== undefined)
691
+ : route === "services/[slug]"
692
+ ? pages.filter((p: ContentPage) => p.parent === undefined)
693
+ : pages;
694
+ slugs = served.map((p: ContentPage) => p.slug);
627
695
  if (slugs.length === 0) {
628
696
  console.info(
629
697
  `structured-data: ${label} has no ${type} content in ${DEFAULT_LOCALE}; every slug serves 404 in production — nothing to check`,
@@ -638,13 +706,16 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
638
706
  file,
639
707
  route,
640
708
  identity.slug === null ? slug : undefined,
709
+ parents.get(slug),
641
710
  );
642
711
  const blocks = extractJsonLdBlocks(container);
643
712
  const pageLabel =
644
713
  identity.slug === null
645
714
  ? route === "[slug]/[detail]"
646
715
  ? `/${DEFAULT_LOCALE}/${MEET_PLACEHOLDER_HUB}/${slug}`
647
- : `${label.replace(/\[slug\]$/, "")}${slug}`
716
+ : route === NESTED_SERVICE_ROUTE
717
+ ? `/${DEFAULT_LOCALE}/services/${parents.get(slug)}/${slug}`
718
+ : `${label.replace(/\[slug\]$/, "")}${slug}`
648
719
  : label;
649
720
  if (blocks.length === 0) {
650
721
  failures.push(
@@ -661,6 +732,77 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
661
732
  });
662
733
  });
663
734
 
735
+ // ── 1b. A hub whose own page shares its detail type (#10044 D13) ───────────
736
+
737
+ describe("structured data — a hub's own page is not rendered at its detail route (#10044 AC-6)", () => {
738
+ const dynamicRoutes = walkRoutes(ROUTE_ROOT).flatMap(({ route, file }) => {
739
+ const identity = routeIdentity(readFileSync(file, "utf8"));
740
+ return identity !== null && identity.slug === null
741
+ ? [{ route, file, type: identity.type }]
742
+ : [];
743
+ });
744
+
745
+ it("drops exactly Pricing/pricing, and only when the pricing route family is present", () => {
746
+ const dropped = dynamicRoutes.flatMap(({ file, type }) => {
747
+ const slug = hubOwnSlug(file, type);
748
+ return slug === null ? [] : [`${type}/${slug}`];
749
+ });
750
+ const hasPricingFamily = dynamicRoutes.some(
751
+ ({ route }) => route === "pricing/[slug]",
752
+ );
753
+ expect(dropped).toEqual(hasPricingFamily ? ["Pricing/pricing"] : []);
754
+ });
755
+
756
+ // Only where the route family ships (a scaffold may gate /pricing off).
757
+ const pricingDetail = dynamicRoutes.find(
758
+ ({ route }) => route === "pricing/[slug]",
759
+ );
760
+ (pricingDetail === undefined ? it.skip : it)(
761
+ "renders and validates an offering pricing page, never the hub's own file",
762
+ async () => {
763
+ const root = mkdtempSync(join(tmpdir(), "ailk-10044-sd-"));
764
+ const prior = process.env.AILK_CONTENT_ROOT;
765
+ try {
766
+ mkdirSync(join(root, DEFAULT_LOCALE, "Pricing"), { recursive: true });
767
+ copyFileSync(join(CONTENT_ROOT, "_site.mdx"), join(root, "_site.mdx"));
768
+ writeFileSync(
769
+ join(root, DEFAULT_LOCALE, "Pricing", "pricing.mdx"),
770
+ '---\ntype: "Pricing"\nslug: "pricing"\ntitle: "Pricing"\ndescription: "Plans."\n---\n',
771
+ );
772
+ writeFileSync(
773
+ join(root, DEFAULT_LOCALE, "Pricing", "website-rebuild.mdx"),
774
+ '---\ntype: "Pricing"\nslug: "website-rebuild"\ntitle: "Website Rebuild"\ndescription: "What a rebuild costs."\nplans: ["ss-website-rebuild"]\n---\n',
775
+ );
776
+ process.env.AILK_CONTENT_ROOT = root;
777
+ const fixtureAdapter = createMdxAdapter({
778
+ contentRoot: root,
779
+ defaultLocale: DEFAULT_LOCALE,
780
+ });
781
+ const hubSlug = hubOwnSlug(pricingDetail!.file, "Pricing");
782
+ const slugs = (await fixtureAdapter.listDetail("Pricing", DEFAULT_LOCALE))
783
+ .map((p: ContentPage) => p.slug)
784
+ .filter((slug: string) => slug !== hubSlug);
785
+ expect(slugs).toEqual(["website-rebuild"]);
786
+
787
+ const label = `/${DEFAULT_LOCALE}/pricing/website-rebuild`;
788
+ const container = await renderRoute(
789
+ pricingDetail!.file,
790
+ "pricing/[slug]",
791
+ "website-rebuild",
792
+ );
793
+ const blocks = extractJsonLdBlocks(container);
794
+ expect(blocks.length).toBeGreaterThan(0);
795
+ expect(validateFor(blocks, "Pricing", label)).toBeNull();
796
+ expect(identityFailure(blocks, label)).toBeNull();
797
+ } finally {
798
+ if (prior === undefined) delete process.env.AILK_CONTENT_ROOT;
799
+ else process.env.AILK_CONTENT_ROOT = prior;
800
+ rmSync(root, { recursive: true, force: true });
801
+ }
802
+ },
803
+ );
804
+ });
805
+
664
806
  // ── 2a. exemption(route, src) — the generated-checkout routes (#8513) ──────
665
807
  //
666
808
  // Proof that the two conditional entries above key on the FILE, not the
@@ -0,0 +1,84 @@
1
+ // #9162 (PR #9152's security review) — the deliverable grant id in the PATH of
2
+ // `<hub>/v1/deliverables/<grantId>`, which `deliverables.openGrant` fetches
3
+ // server-side, is redacted before a URL reaches Sentry. The query-param scrub
4
+ // has its own suite, `__tests__/telemetry-scrub.test.ts`.
5
+
6
+ import { scrubTelemetryUrl } from "../telemetry-scrub";
7
+
8
+ const GRANT = "dgrant_01920000-0000-7000-8000-000000000001";
9
+
10
+ describe("scrubTelemetryUrl — the /v1/deliverables/<grantId> path (#9162)", () => {
11
+ it("redacts the grant id of an absolute hub URL", () => {
12
+ expect(
13
+ scrubTelemetryUrl(`https://hub.example.com/v1/deliverables/${GRANT}`),
14
+ ).toBe("https://hub.example.com/v1/deliverables/:grantId");
15
+ });
16
+
17
+ it("redacts the grant id of a relative URL", () => {
18
+ expect(scrubTelemetryUrl(`/v1/deliverables/${GRANT}`)).toBe(
19
+ "/v1/deliverables/:grantId",
20
+ );
21
+ });
22
+
23
+ it("keeps the query string and fragment around the redacted id", () => {
24
+ expect(
25
+ scrubTelemetryUrl(
26
+ `https://hub.example.com/v1/deliverables/${GRANT}?x=1#top`,
27
+ ),
28
+ ).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1#top");
29
+ });
30
+
31
+ it("redacts the id and strips a token query param together", () => {
32
+ const out = scrubTelemetryUrl(
33
+ `https://hub.example.com/v1/deliverables/${GRANT}?g=${GRANT}&x=1`,
34
+ );
35
+ expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1");
36
+ expect(out).not.toContain(GRANT);
37
+ });
38
+
39
+ it("redacts an id the api-client percent-encoded", () => {
40
+ const out = scrubTelemetryUrl(
41
+ `https://hub.example.com/v1/deliverables/${encodeURIComponent("a b%c")}`,
42
+ );
43
+ expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId");
44
+ });
45
+
46
+ it("redacts the id under an API base path", () => {
47
+ expect(
48
+ scrubTelemetryUrl(`https://hub.example.com/api/v1/deliverables/${GRANT}`),
49
+ ).toBe("https://hub.example.com/api/v1/deliverables/:grantId");
50
+ });
51
+
52
+ it("redacts the id inside a span description (`METHOD url`)", () => {
53
+ expect(
54
+ scrubTelemetryUrl(`GET https://hub.example.com/v1/deliverables/${GRANT}`),
55
+ ).toBe("GET https://hub.example.com/v1/deliverables/:grantId");
56
+ });
57
+
58
+ it("leaves the ungated open read's slug alone — a slug is not a credential", () => {
59
+ for (const url of [
60
+ "https://hub.example.com/v1/deliverables/open/q3",
61
+ "/v1/deliverables/open/q3?x=1",
62
+ "GET https://hub.example.com/v1/deliverables/open/q3",
63
+ ]) {
64
+ expect(scrubTelemetryUrl(url)).toBe(url);
65
+ }
66
+ });
67
+
68
+ it("redacts a grant id that only starts with `open`", () => {
69
+ expect(scrubTelemetryUrl("/v1/deliverables/openly_a_grant")).toBe(
70
+ "/v1/deliverables/:grantId",
71
+ );
72
+ });
73
+
74
+ it("leaves other paths unchanged", () => {
75
+ for (const url of [
76
+ "https://hub.example.com/v1/deliverables",
77
+ "https://hub.example.com/v1/deliverables/",
78
+ "/v1/leads/submit",
79
+ `/reports/q3/file`,
80
+ ]) {
81
+ expect(scrubTelemetryUrl(url)).toBe(url);
82
+ }
83
+ });
84
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The template-listing mapping (#10051 D6, AC-6) — a TemplatePage's
3
+ * frontmatter to a hub card, an action label and the detail page's
4
+ * generated sections. Pure; no I/O.
5
+ */
6
+ import { PageSectionsSchema } from "@working-theory/validation";
7
+
8
+ import {
9
+ templateActionLabel,
10
+ templateCard,
11
+ templateDetailSections,
12
+ type TemplateLabels,
13
+ } from "../template-listing";
14
+
15
+ const labels: TemplateLabels = {
16
+ getIt: "Get it",
17
+ startFree: "Start free",
18
+ viewDemo: "View demo",
19
+ viewTemplate: "View template",
20
+ included: "What's included",
21
+ demoBadge: "Live demo",
22
+ moreCategories: (count) => `+${count} more`,
23
+ };
24
+
25
+ const full = {
26
+ slug: "a",
27
+ frontmatter: {
28
+ title: "Template A",
29
+ description: "Description A.",
30
+ summary: "Summary A.",
31
+ thumbnail: "/img/a.png",
32
+ categories: ["accounting", "not-a-category", "legal"],
33
+ demoUrl: "https://a.salessmyth.com",
34
+ getItUrl: "/contact",
35
+ price: 0,
36
+ includes: [
37
+ { title: "Base site", description: "Pages and structured data." },
38
+ ],
39
+ },
40
+ };
41
+
42
+ const bare = {
43
+ slug: "b",
44
+ frontmatter: {
45
+ title: "Template B",
46
+ description: "Description B.",
47
+ getItUrl: "https://buy.example.com/b",
48
+ includes: [
49
+ { title: "Base site", description: "Pages and structured data." },
50
+ ],
51
+ },
52
+ };
53
+
54
+ describe("templateCard", () => {
55
+ it("maps every present field", () => {
56
+ expect(templateCard(full, labels)).toEqual({
57
+ title: "Template A",
58
+ description: "Summary A.",
59
+ image: { src: "/img/a.png", alt: "Template A" },
60
+ eyebrow: "Accounting · Legal",
61
+ badge: "Live demo",
62
+ link: { label: "View template", href: "/templates/a" },
63
+ });
64
+ });
65
+
66
+ it("omits image, eyebrow and badge when their fields are absent, and falls back to description", () => {
67
+ expect(templateCard(bare, labels)).toEqual({
68
+ title: "Template B",
69
+ description: "Description B.",
70
+ link: { label: "View template", href: "/templates/b" },
71
+ });
72
+ });
73
+
74
+ it("omits the eyebrow when every category slug is unknown", () => {
75
+ const card = templateCard(
76
+ { slug: "c", frontmatter: { ...bare.frontmatter, categories: ["nope"] } },
77
+ labels,
78
+ );
79
+ expect(card).not.toHaveProperty("eyebrow");
80
+ });
81
+
82
+ it("shows at most three category names, then +N more", () => {
83
+ const card = templateCard(
84
+ {
85
+ slug: "d",
86
+ frontmatter: {
87
+ ...bare.frontmatter,
88
+ categories: ["accounting", "legal", "consulting", "hvac", "roofing"],
89
+ },
90
+ },
91
+ labels,
92
+ );
93
+ expect(card.eyebrow).toBe("Accounting · Legal · Consulting · +2 more");
94
+ });
95
+ });
96
+
97
+ describe("templateActionLabel", () => {
98
+ it("is Start free only for a price of exactly 0", () => {
99
+ expect(templateActionLabel(0, labels)).toBe("Start free");
100
+ expect(templateActionLabel(undefined, labels)).toBe("Get it");
101
+ expect(templateActionLabel(5, labels)).toBe("Get it");
102
+ });
103
+ });
104
+
105
+ describe("templateDetailSections", () => {
106
+ it("builds a hero and a What's included section", () => {
107
+ const [hero, included] = templateDetailSections(full.frontmatter, labels);
108
+ expect(hero).toMatchObject({
109
+ type: "hero",
110
+ data: {
111
+ headline: "Template A",
112
+ subheadline: "Summary A.",
113
+ eyebrow: "Accounting · Legal",
114
+ image: { src: "/img/a.png", alt: "Template A" },
115
+ primaryCta: { label: "Start free", href: "/contact" },
116
+ secondaryCta: {
117
+ label: "View demo",
118
+ href: "https://a.salessmyth.com",
119
+ variant: "outline",
120
+ },
121
+ },
122
+ });
123
+ expect(included).toEqual({
124
+ type: "items",
125
+ id: "template-included",
126
+ data: {
127
+ headline: "What's included",
128
+ variant: "minimal",
129
+ items: [
130
+ { title: "Base site", description: "Pages and structured data." },
131
+ ],
132
+ },
133
+ });
134
+ });
135
+
136
+ it("omits the demo link, image and eyebrow when absent", () => {
137
+ const [hero] = templateDetailSections(bare.frontmatter, labels);
138
+ const data = (hero as { data: Record<string, unknown> }).data;
139
+ expect(data.primaryCta).toEqual({
140
+ label: "Get it",
141
+ href: "https://buy.example.com/b",
142
+ });
143
+ expect(data).not.toHaveProperty("secondaryCta");
144
+ expect(data).not.toHaveProperty("image");
145
+ expect(data).not.toHaveProperty("eyebrow");
146
+ expect(data.subheadline).toBe("Description B.");
147
+ });
148
+
149
+ it.each([
150
+ ["full", full.frontmatter],
151
+ ["bare", bare.frontmatter],
152
+ ])("the %s result parses PageSectionsSchema", (_label, frontmatter) => {
153
+ expect(
154
+ PageSectionsSchema.safeParse(templateDetailSections(frontmatter, labels))
155
+ .success,
156
+ ).toBe(true);
157
+ });
158
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * auth-secret.ts — `resolveSecret()`, moved verbatim out of `auth.ts`
3
+ * (#8556, spec docs/specs/9965-sweep-a-trust-or-authz-spec.md D7) so a
4
+ * consumer that only needs the secret does not load Better Auth, which is
5
+ * ESM-only. `auth.ts` re-exports it, so every existing import is unchanged.
6
+ */
7
+
8
+ /**
9
+ * Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
10
+ * email-verification JWTs.
11
+ *
12
+ * THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
13
+ * own `validateSecret` throws only when the secret equals ITS default; this
14
+ * repo's literal is a different string, so it cleared that check and produced
15
+ * nothing but a low-entropy warning — while sitting in a repository whose
16
+ * `oss`-tier tree ships publicly. Anyone who can read this file could forge
17
+ * both a session cookie and a verification token on any deployment that
18
+ * omitted the variable.
19
+ *
20
+ * This changes boot behaviour: a production deployment with no
21
+ * BETTER_AUTH_SECRET boots today and does not after this. That is the correct
22
+ * failure — a site signing sessions with a published string is not usefully
23
+ * "up" — and `apps/web/.env.example` has always documented the variable as
24
+ * REQUIRED in production. The change makes the file honest rather than adding
25
+ * a requirement.
26
+ *
27
+ * Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
28
+ * same resolved secret instead of re-reading the variable: the second consumer
29
+ * after `betterAuth({ secret })`.
30
+ */
31
+ export function resolveSecret(): string {
32
+ const configured = process.env.BETTER_AUTH_SECRET;
33
+ if (configured) return configured;
34
+
35
+ // SERVING, not BUILDING. `next build` collects page data with
36
+ // NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
37
+ // which resolves the lazy `auth` proxy — so an unqualified production throw
38
+ // fails the BUILD of any tree that has not set the variable. That is much
39
+ // broader than the risk: a build produces an artifact, it does not serve a
40
+ // session, and it would break CI, preview builds, and `pnpm build` on a
41
+ // freshly scaffolded site that has not configured auth at all.
42
+ //
43
+ // Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
44
+ // for the duration of the build, so excluding that phase keeps the guard
45
+ // exactly where it was aimed: a production process that is about to answer
46
+ // requests. Such a process still throws on the first access to `auth`, which
47
+ // is the intended failure — a site signing sessions with a string published
48
+ // in this repository is not usefully "up".
49
+ const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
50
+ if (process.env.NODE_ENV === "production" && !isBuildPhase) {
51
+ throw new Error(
52
+ "BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
53
+ );
54
+ }
55
+
56
+ return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
57
+ }