create-ailk 0.19.1 → 0.21.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 (199) hide show
  1. package/README.md +84 -7
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/cli.js +35 -0
  7. package/dist/copy.d.ts +41 -0
  8. package/dist/copy.js +87 -21
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/lib/design-checks.d.ts +6 -1
  12. package/dist/lib/design-checks.js +23 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  14. package/dist/lib/fetch-template-snapshot.js +205 -0
  15. package/dist/lib/merge-three-way.d.ts +31 -0
  16. package/dist/lib/merge-three-way.js +65 -0
  17. package/dist/module-architecture.js +55 -2
  18. package/dist/programmatic.d.ts +7 -1
  19. package/dist/programmatic.js +76 -22
  20. package/dist/site-roster.d.ts +4 -2
  21. package/dist/site-roster.js +7 -5
  22. package/dist/spoke-checkout.d.ts +7 -0
  23. package/dist/spoke-checkout.js +8 -1
  24. package/dist/starter-grid.js +16 -0
  25. package/dist/surfaces.js +78 -2
  26. package/dist/sync-routes.d.ts +28 -0
  27. package/dist/sync-routes.js +123 -1
  28. package/dist/template-record.d.ts +59 -0
  29. package/dist/template-record.js +183 -0
  30. package/dist/update.d.ts +53 -0
  31. package/dist/update.js +606 -0
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  51. package/templates/.claude/hooks/architecture-check.sh +8 -0
  52. package/templates/.claude/rules/loop-contract.md +129 -0
  53. package/templates/.claude/settings.json +3 -2
  54. package/templates/.env.example +13 -0
  55. package/templates/CLAUDE.md +1 -1
  56. package/templates/CONVENTIONS.md +3 -2
  57. package/templates/README.md +26 -25
  58. package/templates/_gitignore +162 -0
  59. package/templates/apps/api/CLAUDE.md +18 -10
  60. package/templates/apps/api/jest.config.cjs +1 -0
  61. package/templates/apps/api/package.json +2 -1
  62. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  63. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  64. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  65. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  66. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  67. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  68. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  69. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  70. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  71. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  72. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  74. package/templates/apps/api/src/middleware/auth.ts +8 -4
  75. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  76. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  77. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  78. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  79. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  80. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  81. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  82. package/templates/apps/api/src/server.ts +14 -2
  83. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  84. package/templates/apps/api/src/services/audit-log.ts +72 -10
  85. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  86. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  87. package/templates/apps/api/src/services/project-listings.ts +6 -1
  88. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  89. package/templates/apps/api/vercel.json +1 -1
  90. package/templates/apps/mcp/CLAUDE.md +19 -7
  91. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
  92. package/templates/apps/mcp/jest.config.cjs +1 -0
  93. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  94. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  95. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  96. package/templates/apps/web/CLAUDE.md +26 -1
  97. package/templates/apps/web/_gitignore +6 -0
  98. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  99. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  100. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  111. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  115. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  118. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  124. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  125. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  129. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  132. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  133. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  134. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  135. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  137. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  138. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  139. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
  148. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  149. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  150. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  151. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/layout.tsx +46 -4
  154. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  155. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  156. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  157. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  158. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  160. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  161. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  162. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  163. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  164. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  165. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  166. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  167. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  168. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  169. package/templates/apps/web/jest.config.cjs +4 -0
  170. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  171. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  172. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  173. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
  174. package/templates/apps/web/lib/account-config.ts +31 -16
  175. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  176. package/templates/apps/web/lib/deck-page.ts +186 -0
  177. package/templates/apps/web/lib/relay.ts +20 -0
  178. package/templates/apps/web/messages/en.json +83 -9
  179. package/templates/apps/web/next.config.mjs +8 -2
  180. package/templates/database/CHANGELOG.md +48 -0
  181. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  182. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/__tests__/seed.test.ts +39 -2
  186. package/templates/database/_gitignore +8 -0
  187. package/templates/database/inbox/schema.prisma +38 -12
  188. package/templates/database/jest.config.cjs +1 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/database/seed.ts +29 -0
  196. package/templates/project.yaml +6 -3
  197. package/templates/tools/jest/pin-timezone.cjs +34 -0
  198. package/versions.json +35 -35
  199. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Tests for consent-visitor.ts (the `ailk-vid` cookie helpers) and for
3
+ * `acceptCookiesAction`/`declineCookiesAction` (#8438 D12,
4
+ * `app/[locale]/consent.actions.ts`) — the banner's one write path.
5
+ */
6
+ import {
7
+ consentVisitorHeader,
8
+ ensureConsentVisitor,
9
+ isValidConsentVisitorId,
10
+ readConsentVisitor,
11
+ readConsentVisitorFromCookieHeader,
12
+ } from "../consent-visitor";
13
+
14
+ const VALID_ID = "11111111-2222-4333-8444-555555555555";
15
+
16
+ describe("isValidConsentVisitorId", () => {
17
+ it.each([VALID_ID, "aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee"])("%s is a valid v4 UUID", (id) => {
18
+ expect(isValidConsentVisitorId(id)).toBe(true);
19
+ });
20
+
21
+ it.each([
22
+ "",
23
+ "not-a-uuid",
24
+ "11111111-2222-3333-8444-555555555555", // version nibble is 3, not 4
25
+ "11111111-2222-4333-c444-555555555555", // variant nibble is c, not 8/9/a/b
26
+ `${VALID_ID} `,
27
+ ])("%j is not", (value) => {
28
+ expect(isValidConsentVisitorId(value)).toBe(false);
29
+ });
30
+ });
31
+
32
+ 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);
36
+ });
37
+
38
+ it("returns undefined with no cookie, or an invalid value", () => {
39
+ expect(readConsentVisitor({ get: () => undefined })).toBeUndefined();
40
+ expect(readConsentVisitor({ get: () => ({ value: "nope" }) })).toBeUndefined();
41
+ });
42
+ });
43
+
44
+ describe("ensureConsentVisitor", () => {
45
+ it("keeps an existing valid id and sets nothing", () => {
46
+ const set = jest.fn();
47
+ const cookies = { get: () => ({ value: VALID_ID }), set };
48
+ expect(ensureConsentVisitor(cookies)).toBe(VALID_ID);
49
+ expect(set).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("mints and sets a fresh v4 UUID when absent", () => {
53
+ const set = jest.fn();
54
+ const cookies = { get: () => undefined, set };
55
+ const id = ensureConsentVisitor(cookies);
56
+ expect(isValidConsentVisitorId(id)).toBe(true);
57
+ expect(set).toHaveBeenCalledTimes(1);
58
+ const [name, value, options] = set.mock.calls[0] as [string, string, Record<string, unknown>];
59
+ expect(name).toBe("ailk-vid");
60
+ expect(value).toBe(id);
61
+ expect(options).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/" });
62
+ });
63
+
64
+ it("mints a fresh id when the stored value fails the UUID shape", () => {
65
+ const set = jest.fn();
66
+ const cookies = { get: () => ({ value: "garbage" }), set };
67
+ const id = ensureConsentVisitor(cookies);
68
+ expect(id).not.toBe("garbage");
69
+ expect(set).toHaveBeenCalledTimes(1);
70
+ });
71
+ });
72
+
73
+ describe("consentVisitorHeader", () => {
74
+ it("returns the one header, exactly", () => {
75
+ expect(consentVisitorHeader(VALID_ID)).toEqual({ "x-ailk-consent-visitor": VALID_ID });
76
+ });
77
+ });
78
+
79
+ describe("readConsentVisitorFromCookieHeader", () => {
80
+ 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);
83
+ });
84
+
85
+ it("returns undefined with no header, no matching cookie, or an invalid value", () => {
86
+ expect(readConsentVisitorFromCookieHeader(null)).toBeUndefined();
87
+ expect(readConsentVisitorFromCookieHeader(undefined)).toBeUndefined();
88
+ expect(readConsentVisitorFromCookieHeader("session=abc")).toBeUndefined();
89
+ expect(readConsentVisitorFromCookieHeader("ailk-vid=garbage")).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ // ── acceptCookiesAction / declineCookiesAction (D12) ──────────────────────
94
+
95
+ const mockRecordCookieChoice = jest.fn();
96
+ jest.mock("@working-theory/api-client", () => ({
97
+ __esModule: true,
98
+ consent: { recordCookieChoice: (...args: unknown[]) => mockRecordCookieChoice(...args) },
99
+ visitorForwardHeaders: () => undefined,
100
+ }));
101
+
102
+ const mockCookieGet = jest.fn();
103
+ const mockCookieSet = jest.fn();
104
+ jest.mock("next/headers", () => ({
105
+ __esModule: true,
106
+ cookies: jest.fn().mockImplementation(async () => ({
107
+ get: (...args: unknown[]) => mockCookieGet(...args),
108
+ set: (...args: unknown[]) => mockCookieSet(...args),
109
+ })),
110
+ headers: jest.fn().mockResolvedValue(new Headers()),
111
+ }));
112
+
113
+ jest.mock("next-intl/server", () => ({
114
+ __esModule: true,
115
+ getLocale: jest.fn().mockResolvedValue("en"),
116
+ }));
117
+
118
+ describe("acceptCookiesAction / declineCookiesAction", () => {
119
+ beforeEach(() => {
120
+ mockRecordCookieChoice.mockReset().mockResolvedValue({ ok: true, body: { recorded: true } });
121
+ mockCookieGet.mockReset().mockReturnValue(undefined);
122
+ mockCookieSet.mockReset();
123
+ });
124
+
125
+ it("mints a visitor id when absent and calls recordCookieChoice once, accepted", async () => {
126
+ const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
127
+ await acceptCookiesAction();
128
+ expect(mockCookieSet).toHaveBeenCalledTimes(1);
129
+ expect(mockRecordCookieChoice).toHaveBeenCalledTimes(1);
130
+ const [body] = mockRecordCookieChoice.mock.calls[0] as [{ visitorId: string; choice: string; locale: string }];
131
+ expect(isValidConsentVisitorId(body.visitorId)).toBe(true);
132
+ expect(body.choice).toBe("accepted");
133
+ expect(body.locale).toBe("en");
134
+ });
135
+
136
+ it("keeps an existing visitor id and declines", async () => {
137
+ mockCookieGet.mockReturnValue({ value: VALID_ID });
138
+ const { declineCookiesAction } = await import("../../app/[locale]/consent.actions");
139
+ await declineCookiesAction();
140
+ expect(mockCookieSet).not.toHaveBeenCalled();
141
+ const [body] = mockRecordCookieChoice.mock.calls[0] as [{ visitorId: string; choice: string }];
142
+ expect(body.visitorId).toBe(VALID_ID);
143
+ expect(body.choice).toBe("declined");
144
+ });
145
+
146
+ it("resolves even when the client rejects", async () => {
147
+ mockRecordCookieChoice.mockRejectedValue(new TypeError("fetch failed"));
148
+ const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
149
+ await expect(acceptCookiesAction()).resolves.toBeUndefined();
150
+ });
151
+
152
+ it("resolves even when the write is refused", async () => {
153
+ mockRecordCookieChoice.mockResolvedValue({ ok: false, status: 503, body: null });
154
+ const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
155
+ await expect(acceptCookiesAction()).resolves.toBeUndefined();
156
+ });
157
+
158
+ it("never writes the ailk-consent cookie", async () => {
159
+ const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
160
+ await acceptCookiesAction();
161
+ expect(mockCookieSet).not.toHaveBeenCalledWith("ailk-consent", expect.anything(), expect.anything());
162
+ });
163
+ });
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The deck-page resolver (#8687 D3, AC-2).
3
+ *
4
+ * `deckCandidates` and the `DECK_ROUTES` parity are pure functions over the
5
+ * real routes map. `resolveDeckPage` and `inputsFor` (captured off the
6
+ * `setDeckPages` registration call) are exercised with `getMdxSource` and
7
+ * `next/headers` mocked, in `renderPage-extension.test.tsx`'s idiom.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ import { ROUTES } from "@working-theory/templates/routes";
13
+
14
+ const mockGetMdxSource = jest.fn();
15
+ const mockSetDeckPages = jest.fn();
16
+
17
+ jest.mock("@working-theory/page-renderer", () => {
18
+ const actual = jest.requireActual("@working-theory/page-renderer");
19
+ return {
20
+ ...actual,
21
+ getMdxSource: (...args: unknown[]) => mockGetMdxSource(...args),
22
+ setDeckPages: (...args: unknown[]) => mockSetDeckPages(...args),
23
+ };
24
+ });
25
+
26
+ const mockHeaders = jest.fn();
27
+ jest.mock("next/headers", () => ({ headers: () => mockHeaders() }));
28
+
29
+ const savedLocales = process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
30
+
31
+ beforeAll(() => {
32
+ process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = "en,es";
33
+ });
34
+ afterAll(() => {
35
+ if (savedLocales === undefined) delete process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
36
+ else process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = savedLocales;
37
+ });
38
+
39
+ import { DECK_ROUTES, deckCandidates, resolveDeckPage } from "../deck-page";
40
+ import { siteChrome } from "../site-chrome";
41
+
42
+ describe("deckCandidates (#8687 D3, AC-2)", () => {
43
+ const LOCALES = ["en", "es"];
44
+
45
+ it("/pricing resolves the route row, then the [slug] fallback", () => {
46
+ expect(deckCandidates("/pricing", LOCALES)).toEqual([
47
+ { type: "Pricing", slug: "pricing" },
48
+ { type: "LandingPage", slug: "pricing" },
49
+ { type: "BlogList", slug: "pricing" },
50
+ ]);
51
+ });
52
+
53
+ it("/about (core, always scaffolded) resolves the route row only", () => {
54
+ expect(deckCandidates("/about", LOCALES)).toEqual([
55
+ { type: "AboutPage", slug: "about" },
56
+ ]);
57
+ });
58
+
59
+ it("/es/offer/ (no route match) falls through to [slug] alone", () => {
60
+ expect(deckCandidates("/es/offer/", LOCALES)).toEqual([
61
+ { type: "LandingPage", slug: "offer" },
62
+ { type: "BlogList", slug: "offer" },
63
+ ]);
64
+ });
65
+
66
+ it("/projects/apollo resolves the detail row's directory type", () => {
67
+ expect(deckCandidates("/projects/apollo", LOCALES)).toEqual([
68
+ { type: "Project", slug: "apollo" },
69
+ ]);
70
+ });
71
+
72
+ it("/careers (gateable, no DECK_ROUTES entry) falls through to [slug]", () => {
73
+ expect(deckCandidates("/careers", LOCALES)).toEqual([
74
+ { type: "LandingPage", slug: "careers" },
75
+ { type: "BlogList", slug: "careers" },
76
+ ]);
77
+ });
78
+
79
+ it.each(["/", "/software", "/sign-in", "/blog/post", "/edit/LandingPage/x", "/a/b"])(
80
+ "%s resolves no candidates",
81
+ (pathname) => {
82
+ expect(deckCandidates(pathname, LOCALES)).toEqual([]);
83
+ },
84
+ );
85
+ });
86
+
87
+ describe("DECK_ROUTES parity (#8687 D3)", () => {
88
+ it("contains every routes-map row with seed content whose page.tsx renders sections, minus /", () => {
89
+ // DECK_ROUTES is a fixed superset, not a per-scaffold exact set: a shape
90
+ // that doesn't ship one of these routes (e.g. a Free scaffold without
91
+ // /alternatives) simply has no page.tsx there, so deckCandidates/
92
+ // resolveDeckPage never get a request for it (harmless). What would be a
93
+ // real bug is the OTHER direction — an actually-shipped sections route
94
+ // this build's own tree carries that DECK_ROUTES fails to name.
95
+ const appRoot = path.join(__dirname, "..", "..", "app", "[locale]");
96
+ const sectionsRoutes = new Set<string>();
97
+ for (const row of ROUTES) {
98
+ if (row.route === "/" || !row.contentPath?.startsWith("content/en/")) continue;
99
+ const dir = row.route
100
+ .split("/")
101
+ .filter(Boolean)
102
+ .map((segment) => (segment.startsWith(":") ? "[slug]" : segment))
103
+ .join("/");
104
+ let pageSource: string;
105
+ try {
106
+ pageSource = readFileSync(path.join(appRoot, dir, "page.tsx"), "utf8");
107
+ } catch {
108
+ continue;
109
+ }
110
+ if (!pageSource.includes('bodyMode: "mdx-body"')) {
111
+ sectionsRoutes.add(row.route);
112
+ }
113
+ }
114
+ const deckRoutesSet = new Set<string>(DECK_ROUTES);
115
+ const missing = [...sectionsRoutes].filter((route) => !deckRoutesSet.has(route));
116
+ expect(missing).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe("resolveDeckPage (#8687 D3, AC-2)", () => {
121
+ beforeEach(() => {
122
+ mockGetMdxSource.mockReset();
123
+ });
124
+
125
+ it("returns the first candidate with a source", async () => {
126
+ mockGetMdxSource.mockImplementation((type: string) =>
127
+ Promise.resolve(
128
+ type === "LandingPage"
129
+ ? null
130
+ : type === "BlogList"
131
+ ? { frontmatter: { type: "BlogList", slug: "offer" }, body: "" }
132
+ : null,
133
+ ),
134
+ );
135
+
136
+ const result = await resolveDeckPage("/es/offer/", "en");
137
+
138
+ expect(result).toEqual({
139
+ type: "BlogList",
140
+ slug: "offer",
141
+ frontmatter: { type: "BlogList", slug: "offer" },
142
+ });
143
+ });
144
+
145
+ it("returns null when a read throws", async () => {
146
+ mockGetMdxSource.mockRejectedValue(new Error("read failed"));
147
+
148
+ expect(await resolveDeckPage("/pricing", "en")).toBeNull();
149
+ });
150
+
151
+ it("returns null for a path with no candidates, with zero getMdxSource calls (NFR 2)", async () => {
152
+ expect(await resolveDeckPage("/software", "en")).toBeNull();
153
+ expect(mockGetMdxSource).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe("the registered inputsFor (#8687 D3, AC-2)", () => {
158
+ beforeEach(() => {
159
+ mockGetMdxSource.mockReset();
160
+ mockHeaders.mockReset();
161
+ });
162
+
163
+ function registeredInputsFor(): (ctx: {
164
+ type: string;
165
+ slug: string;
166
+ locale: string;
167
+ }) => Promise<unknown> {
168
+ const call = mockSetDeckPages.mock.calls[mockSetDeckPages.mock.calls.length - 1];
169
+ return (
170
+ call?.[0] as {
171
+ inputsFor: (ctx: { type: string; slug: string; locale: string }) => Promise<unknown>;
172
+ }
173
+ ).inputsFor;
174
+ }
175
+
176
+ it("resolves the design's inputs when the resolved page matches and opts in", async () => {
177
+ mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
178
+ mockGetMdxSource.mockImplementation((type: string) =>
179
+ Promise.resolve(
180
+ type === "Pricing"
181
+ ? null
182
+ : type === "LandingPage"
183
+ ? { frontmatter: { type: "LandingPage", slug: "pricing", layout: "deck" }, body: "" }
184
+ : null,
185
+ ),
186
+ );
187
+
188
+ const result = await registeredInputsFor()({
189
+ type: "LandingPage",
190
+ slug: "pricing",
191
+ locale: "en",
192
+ });
193
+
194
+ // The scaffold's generated site-chrome carries the DESIGN's values — minimalism
195
+ // presents the deck (deckPresentation true), standard does not — so the expected
196
+ // inputs are read from it rather than hard-coded to one design.
197
+ const chrome = siteChrome as typeof siteChrome & {
198
+ deckGrounds?: unknown;
199
+ deckPresentation?: true;
200
+ };
201
+ expect(result).toEqual({
202
+ ...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
203
+ deckPresentation: chrome.deckPresentation === true,
204
+ });
205
+ });
206
+
207
+ it("returns null when the resolved page does not match this render", async () => {
208
+ mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
209
+ mockGetMdxSource.mockResolvedValue(
210
+ { frontmatter: { type: "Pricing", slug: "pricing" }, body: "" },
211
+ );
212
+
213
+ const result = await registeredInputsFor()({
214
+ type: "Pricing",
215
+ slug: "pricing",
216
+ locale: "en",
217
+ });
218
+
219
+ expect(result).toBeNull();
220
+ });
221
+
222
+ it("returns null for a resolved page without the field", async () => {
223
+ mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
224
+ mockGetMdxSource.mockImplementation((type: string) =>
225
+ Promise.resolve(
226
+ type === "LandingPage"
227
+ ? { frontmatter: { type: "LandingPage", slug: "pricing" }, body: "" }
228
+ : null,
229
+ ),
230
+ );
231
+
232
+ expect(
233
+ await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
234
+ ).toBeNull();
235
+ });
236
+
237
+ it("returns null with no x-pathname header", async () => {
238
+ mockHeaders.mockResolvedValue(new Map());
239
+
240
+ expect(
241
+ await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
242
+ ).toBeNull();
243
+ });
244
+ });
@@ -456,6 +456,55 @@ describe("AC-4 — the visitor, unchanged; a cross-site post, refused", () => {
456
456
  });
457
457
  });
458
458
 
459
+ describe("the banner's visitor id (#8438 D12)", () => {
460
+ const VALID_ID = "11111111-2222-4333-8444-555555555555";
461
+ const LEAD_PATHS = ["/v1/leads/submit", "/v1/leads/subscribe", "/v1/leads/callback"];
462
+
463
+ it.each(LEAD_PATHS)(
464
+ "%s carries x-ailk-consent-visitor from the request's own ailk-vid cookie",
465
+ async (path) => {
466
+ await handleRelay(relayRequest(path, { headers: { cookie: `ailk-vid=${VALID_ID}` } }));
467
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBe(VALID_ID);
468
+ },
469
+ );
470
+
471
+ it("sends no header when the request carries no ailk-vid cookie", async () => {
472
+ await handleRelay(relayRequest("/v1/leads/submit"));
473
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
474
+ });
475
+
476
+ it("sends no header when the cookie value fails the UUID shape", async () => {
477
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: "ailk-vid=not-a-uuid" } }));
478
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
479
+ });
480
+
481
+ it("the schedule route carries no consent-visitor header", async () => {
482
+ await handleRelay(
483
+ relayRequest("/v1/schedule/availability", {
484
+ method: "GET",
485
+ headers: { cookie: `ailk-vid=${VALID_ID}` },
486
+ }),
487
+ );
488
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
489
+ });
490
+
491
+ it("the session route carries no consent-visitor header", async () => {
492
+ await handleRelay(
493
+ relayRequest("/v1/commerce/invoices", { headers: { cookie: `ailk-vid=${VALID_ID}` } }),
494
+ );
495
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
496
+ });
497
+
498
+ it("a client-sent x-ailk-consent-visitor header is stripped — only the cookie counts", async () => {
499
+ await handleRelay(
500
+ relayRequest("/v1/leads/submit", {
501
+ headers: { "x-ailk-consent-visitor": "22222222-2222-4222-8222-222222222222" },
502
+ }),
503
+ );
504
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
505
+ });
506
+ });
507
+
459
508
  describe("hardening — what the site serves, and a missing hub address", () => {
460
509
  const mediaType = (res: Response) =>
461
510
  res.headers.get("content-type")?.split(";")[0]?.trim();
@@ -240,10 +240,12 @@ describe("structured data — every content page builds valid JSON-LD", () => {
240
240
  "content/$locale/$typeDir — every page validates as its type",
241
241
  async ({ locale, typeDir }) => {
242
242
  if (!(SCHEMA_ORG_TYPES as readonly string[]).includes(typeDir)) {
243
- // Identity + sub-entity types (Organization, Person, BreadcrumbList,
243
+ // Identity + sub-entity types (Organization, BreadcrumbList,
244
244
  // ContactPoint) hold sub-objects other pages' builders embed — never
245
245
  // rendered as pages, and skipped by tools/content-compile the same
246
- // way. Anything else is a directory nothing will ever render.
246
+ // way. Anything else is a directory nothing will ever render. Person
247
+ // (#8650) is a registered page type (`/about/<slug>`), so it is
248
+ // covered by the SCHEMA_ORG_TYPES branch above, not this one.
247
249
  if (!knownSchemaOrgTypes.has(typeDir)) {
248
250
  throw new Error(
249
251
  `content/${locale}/${typeDir}: "${typeDir}" is not a registered page type — no route or builder will ever render it (see packages/content-adapters/src/types.ts SCHEMA_ORG_TYPES)`,
@@ -295,10 +297,25 @@ describe("structured data — every content page builds valid JSON-LD", () => {
295
297
  * exempt route whose page.tsx does render through `renderPage` fails too,
296
298
  * because the reason given for it is false.
297
299
  */
300
+ // Literal, not imported: apps/web does not depend on create-ailk (the import
301
+ // boundary). Mirrors packages/create-ailk/src/spoke-checkout.ts's
302
+ // SPOKE_CHECKOUT_PAGE_MARKER; packages/create-ailk/__tests__/
303
+ // spoke-checkout.test.ts pins the two in sync (#8513 AC-5).
304
+ const CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
305
+ const CHECKOUT_PAGE_NOINDEX_LINE = "robots: { index: false, follow: false }";
306
+
298
307
  const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
299
308
  route: string;
300
309
  reason: string;
301
310
  subtree?: true;
311
+ /**
312
+ * Strings the page file must carry for this entry to apply (#8513 D1). An
313
+ * entry naming no strings applies to its route unconditionally, as every
314
+ * entry did before. Keys the exemption on the file, not the topology seam:
315
+ * a spoke can carry a hand-written page at this route, and a site can
316
+ * edit the generated one — either is checked like any other route.
317
+ */
318
+ requires?: readonly string[];
302
319
  }> = [
303
320
  // Fumadocs routes: JSON-LD is emitted by @working-theory/fumadocs-schema
304
321
  // from the fumadocs `.source` collection, which is not built in this
@@ -349,6 +366,10 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
349
366
  subtree: true,
350
367
  },
351
368
  { route: "meet", reason: "booking surface, API-backed", subtree: true },
369
+ {
370
+ route: "unsubscribe",
371
+ reason: "app-shaped landing, API-backed; no content page behind it",
372
+ },
352
373
  // #6084 — both purchase return pages read a Checkout Session (or read
353
374
  // nothing, for cancel) and render via SectionRenderer directly; neither
354
375
  // is a content-adapter page, and the paid render's own schema.org node
@@ -367,6 +388,23 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
367
388
  reason: "auth surfaces, not content pages",
368
389
  subtree: true,
369
390
  },
391
+ // #8513 — a spoke carrying commerce gets two generated checkout return
392
+ // pages (create-ailk #7838): noindex, rendered through SectionRenderer
393
+ // directly, no JSON-LD. Exempt only when the page on disk still carries
394
+ // both the generated marker and the noindex line (see `requires` above) —
395
+ // a hand-written or edited page at either route is checked like any other.
396
+ {
397
+ route: "checkout/thank-you",
398
+ reason:
399
+ "spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
400
+ requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
401
+ },
402
+ {
403
+ route: "checkout/unavailable",
404
+ reason:
405
+ "spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
406
+ requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
407
+ },
370
408
  ];
371
409
 
372
410
  interface RouteEntry {
@@ -392,12 +430,20 @@ function walkRoutes(dir: string, out: RouteEntry[] = []): RouteEntry[] {
392
430
  return out;
393
431
  }
394
432
 
395
- function exemption(route: string): string | undefined {
396
- return NO_STRUCTURED_DATA_ROUTES.find(
433
+ function exemption(route: string, src: string): string | undefined {
434
+ const entry = NO_STRUCTURED_DATA_ROUTES.find(
397
435
  (e) =>
398
436
  route === e.route ||
399
437
  (e.subtree === true && route.startsWith(e.route + "/")),
400
- )?.reason;
438
+ );
439
+ if (entry === undefined) return undefined;
440
+ if (
441
+ entry.requires !== undefined &&
442
+ !entry.requires.every((s) => src.includes(s))
443
+ ) {
444
+ return undefined;
445
+ }
446
+ return entry.reason;
401
447
  }
402
448
 
403
449
  // The page identity a route renders — the three shapes
@@ -432,9 +478,8 @@ const RENDER_PAGE_DYNAMIC_RE =
432
478
  const GET_MDX_SOURCE_RE = /getMdxSource\s*\(\s*["']([A-Za-z]+)["']/s;
433
479
 
434
480
  function routeIdentity(
435
- file: string,
481
+ src: string,
436
482
  ): { type: string; slug: string | null } | null {
437
- const src = readFileSync(file, "utf8");
438
483
  const exact = RENDER_PAGE_EXACT_RE.exec(src);
439
484
  if (exact) return { type: exact[1] as string, slug: exact[2] as string };
440
485
  const dynamic = RENDER_PAGE_DYNAMIC_RE.exec(src);
@@ -487,8 +532,9 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
487
532
 
488
533
  it.each(routes)("$route", async ({ route, file }) => {
489
534
  const label = `/${DEFAULT_LOCALE}${route === "" ? "" : "/" + route}`;
490
- const exempt = exemption(route);
491
- const identity = routeIdentity(file);
535
+ const src = readFileSync(file, "utf8");
536
+ const exempt = exemption(route, src);
537
+ const identity = routeIdentity(src);
492
538
  if (exempt !== undefined) {
493
539
  // Declared, with a reason, in NO_STRUCTURED_DATA_ROUTES above. The
494
540
  // reason is false for a route that does render through renderPage —
@@ -570,6 +616,51 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
570
616
  });
571
617
  });
572
618
 
619
+ // ── 2a. exemption(route, src) — the generated-checkout routes (#8513) ──────
620
+ //
621
+ // Proof that the two conditional entries above key on the FILE, not the
622
+ // route alone, and that every pre-existing entry is unaffected. The
623
+ // monorepo's own app/[locale]/ has no checkout route (§1 of the spec), so
624
+ // the route walk above never meets one — these cases are what exercise D1 on
625
+ // every PR.
626
+
627
+ describe("structured data — exemption(route, src) keys the generated checkout routes on the file (#8513)", () => {
628
+ const GENERATED_SRC = `// The checkout thank-you page — ${CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
629
+ const NO_MARKER_SRC = `// hand-written\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
630
+ const NO_NOINDEX_SRC = `// The checkout thank-you page — ${CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return {};\n}\n`;
631
+
632
+ it("exempts checkout/thank-you only when the file carries both the marker and the noindex line", () => {
633
+ expect(exemption("checkout/thank-you", GENERATED_SRC)).toContain(
634
+ "noindex",
635
+ );
636
+ expect(exemption("checkout/thank-you", NO_MARKER_SRC)).toBeUndefined();
637
+ expect(exemption("checkout/thank-you", NO_NOINDEX_SRC)).toBeUndefined();
638
+ });
639
+
640
+ it("exempts checkout/unavailable only when the file carries both the marker and the noindex line", () => {
641
+ expect(exemption("checkout/unavailable", GENERATED_SRC)).toContain(
642
+ "noindex",
643
+ );
644
+ expect(exemption("checkout/unavailable", NO_MARKER_SRC)).toBeUndefined();
645
+ expect(exemption("checkout/unavailable", NO_NOINDEX_SRC)).toBeUndefined();
646
+ });
647
+
648
+ it("does not exempt a neighboring checkout route or an unrelated route", () => {
649
+ expect(exemption("checkout/other", GENERATED_SRC)).toBeUndefined();
650
+ expect(exemption("about", GENERATED_SRC)).toBeUndefined();
651
+ });
652
+
653
+ it("every pre-existing entry still returns its reason for its own route with any src, subtree entries included", () => {
654
+ for (const entry of NO_STRUCTURED_DATA_ROUTES) {
655
+ if (entry.requires !== undefined) continue; // the two conditional entries above, checked above
656
+ expect(exemption(entry.route, "")).toBe(entry.reason);
657
+ if (entry.subtree === true) {
658
+ expect(exemption(`${entry.route}/child`, "")).toBe(entry.reason);
659
+ }
660
+ }
661
+ });
662
+ });
663
+
573
664
  // ── 3. The two defect shapes that shipped, pinned ───────────────────────────
574
665
  //
575
666
  // These prove the checks above reach the validator rule that rejects each