create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  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/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -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/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. 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
+ });
@@ -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();
@@ -295,10 +295,25 @@ describe("structured data — every content page builds valid JSON-LD", () => {
295
295
  * exempt route whose page.tsx does render through `renderPage` fails too,
296
296
  * because the reason given for it is false.
297
297
  */
298
+ // Literal, not imported: apps/web does not depend on create-ailk (the import
299
+ // boundary). Mirrors packages/create-ailk/src/spoke-checkout.ts's
300
+ // SPOKE_CHECKOUT_PAGE_MARKER; packages/create-ailk/__tests__/
301
+ // spoke-checkout.test.ts pins the two in sync (#8513 AC-5).
302
+ const CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
303
+ const CHECKOUT_PAGE_NOINDEX_LINE = "robots: { index: false, follow: false }";
304
+
298
305
  const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
299
306
  route: string;
300
307
  reason: string;
301
308
  subtree?: true;
309
+ /**
310
+ * Strings the page file must carry for this entry to apply (#8513 D1). An
311
+ * entry naming no strings applies to its route unconditionally, as every
312
+ * entry did before. Keys the exemption on the file, not the topology seam:
313
+ * a spoke can carry a hand-written page at this route, and a site can
314
+ * edit the generated one — either is checked like any other route.
315
+ */
316
+ requires?: readonly string[];
302
317
  }> = [
303
318
  // Fumadocs routes: JSON-LD is emitted by @working-theory/fumadocs-schema
304
319
  // from the fumadocs `.source` collection, which is not built in this
@@ -349,6 +364,10 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
349
364
  subtree: true,
350
365
  },
351
366
  { route: "meet", reason: "booking surface, API-backed", subtree: true },
367
+ {
368
+ route: "unsubscribe",
369
+ reason: "app-shaped landing, API-backed; no content page behind it",
370
+ },
352
371
  // #6084 — both purchase return pages read a Checkout Session (or read
353
372
  // nothing, for cancel) and render via SectionRenderer directly; neither
354
373
  // is a content-adapter page, and the paid render's own schema.org node
@@ -367,6 +386,23 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
367
386
  reason: "auth surfaces, not content pages",
368
387
  subtree: true,
369
388
  },
389
+ // #8513 — a spoke carrying commerce gets two generated checkout return
390
+ // pages (create-ailk #7838): noindex, rendered through SectionRenderer
391
+ // directly, no JSON-LD. Exempt only when the page on disk still carries
392
+ // both the generated marker and the noindex line (see `requires` above) —
393
+ // a hand-written or edited page at either route is checked like any other.
394
+ {
395
+ route: "checkout/thank-you",
396
+ reason:
397
+ "spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
398
+ requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
399
+ },
400
+ {
401
+ route: "checkout/unavailable",
402
+ reason:
403
+ "spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
404
+ requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
405
+ },
370
406
  ];
371
407
 
372
408
  interface RouteEntry {
@@ -392,12 +428,20 @@ function walkRoutes(dir: string, out: RouteEntry[] = []): RouteEntry[] {
392
428
  return out;
393
429
  }
394
430
 
395
- function exemption(route: string): string | undefined {
396
- return NO_STRUCTURED_DATA_ROUTES.find(
431
+ function exemption(route: string, src: string): string | undefined {
432
+ const entry = NO_STRUCTURED_DATA_ROUTES.find(
397
433
  (e) =>
398
434
  route === e.route ||
399
435
  (e.subtree === true && route.startsWith(e.route + "/")),
400
- )?.reason;
436
+ );
437
+ if (entry === undefined) return undefined;
438
+ if (
439
+ entry.requires !== undefined &&
440
+ !entry.requires.every((s) => src.includes(s))
441
+ ) {
442
+ return undefined;
443
+ }
444
+ return entry.reason;
401
445
  }
402
446
 
403
447
  // The page identity a route renders — the three shapes
@@ -432,9 +476,8 @@ const RENDER_PAGE_DYNAMIC_RE =
432
476
  const GET_MDX_SOURCE_RE = /getMdxSource\s*\(\s*["']([A-Za-z]+)["']/s;
433
477
 
434
478
  function routeIdentity(
435
- file: string,
479
+ src: string,
436
480
  ): { type: string; slug: string | null } | null {
437
- const src = readFileSync(file, "utf8");
438
481
  const exact = RENDER_PAGE_EXACT_RE.exec(src);
439
482
  if (exact) return { type: exact[1] as string, slug: exact[2] as string };
440
483
  const dynamic = RENDER_PAGE_DYNAMIC_RE.exec(src);
@@ -487,8 +530,9 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
487
530
 
488
531
  it.each(routes)("$route", async ({ route, file }) => {
489
532
  const label = `/${DEFAULT_LOCALE}${route === "" ? "" : "/" + route}`;
490
- const exempt = exemption(route);
491
- const identity = routeIdentity(file);
533
+ const src = readFileSync(file, "utf8");
534
+ const exempt = exemption(route, src);
535
+ const identity = routeIdentity(src);
492
536
  if (exempt !== undefined) {
493
537
  // Declared, with a reason, in NO_STRUCTURED_DATA_ROUTES above. The
494
538
  // reason is false for a route that does render through renderPage —
@@ -570,6 +614,51 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
570
614
  });
571
615
  });
572
616
 
617
+ // ── 2a. exemption(route, src) — the generated-checkout routes (#8513) ──────
618
+ //
619
+ // Proof that the two conditional entries above key on the FILE, not the
620
+ // route alone, and that every pre-existing entry is unaffected. The
621
+ // monorepo's own app/[locale]/ has no checkout route (§1 of the spec), so
622
+ // the route walk above never meets one — these cases are what exercise D1 on
623
+ // every PR.
624
+
625
+ describe("structured data — exemption(route, src) keys the generated checkout routes on the file (#8513)", () => {
626
+ const GENERATED_SRC = `// The checkout thank-you page — ${CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
627
+ const NO_MARKER_SRC = `// hand-written\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
628
+ const NO_NOINDEX_SRC = `// The checkout thank-you page — ${CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return {};\n}\n`;
629
+
630
+ it("exempts checkout/thank-you only when the file carries both the marker and the noindex line", () => {
631
+ expect(exemption("checkout/thank-you", GENERATED_SRC)).toContain(
632
+ "noindex",
633
+ );
634
+ expect(exemption("checkout/thank-you", NO_MARKER_SRC)).toBeUndefined();
635
+ expect(exemption("checkout/thank-you", NO_NOINDEX_SRC)).toBeUndefined();
636
+ });
637
+
638
+ it("exempts checkout/unavailable only when the file carries both the marker and the noindex line", () => {
639
+ expect(exemption("checkout/unavailable", GENERATED_SRC)).toContain(
640
+ "noindex",
641
+ );
642
+ expect(exemption("checkout/unavailable", NO_MARKER_SRC)).toBeUndefined();
643
+ expect(exemption("checkout/unavailable", NO_NOINDEX_SRC)).toBeUndefined();
644
+ });
645
+
646
+ it("does not exempt a neighboring checkout route or an unrelated route", () => {
647
+ expect(exemption("checkout/other", GENERATED_SRC)).toBeUndefined();
648
+ expect(exemption("about", GENERATED_SRC)).toBeUndefined();
649
+ });
650
+
651
+ it("every pre-existing entry still returns its reason for its own route with any src, subtree entries included", () => {
652
+ for (const entry of NO_STRUCTURED_DATA_ROUTES) {
653
+ if (entry.requires !== undefined) continue; // the two conditional entries above, checked above
654
+ expect(exemption(entry.route, "")).toBe(entry.reason);
655
+ if (entry.subtree === true) {
656
+ expect(exemption(`${entry.route}/child`, "")).toBe(entry.reason);
657
+ }
658
+ }
659
+ });
660
+ });
661
+
573
662
  // ── 3. The two defect shapes that shipped, pinned ───────────────────────────
574
663
  //
575
664
  // These prove the checks above reach the validator rule that rejects each
@@ -48,7 +48,9 @@
48
48
  // (`GET /v1/member/messages`) — rows of All. A message with a file has a
49
49
  // file action to this site's own member file read,
50
50
  // `account/inbox/message/[id]/file`, which re-serves the bytes — the
51
- // browser holds no hub credential.
51
+ // browser holds no hub credential. A message whose `action` is
52
+ // `onboarding` (#8515 D6) has an Open onboarding action to this site's
53
+ // own `/account/onboarding`.
52
54
  // - For-you source `piece-needs-client` — each piece waiting on the client,
53
55
  // with an Open action to its Inbox row. It shares the pieces read.
54
56
  //
@@ -283,23 +285,36 @@ const message: AccountInboxKind<MemberMessage> = {
283
285
  }));
284
286
  },
285
287
  renderDetail({ data }, { locale, t }) {
288
+ // An onboarding message (#8515 D6) links to this site's own onboarding
289
+ // page, before the file action when both apply; the body stays plain text.
290
+ const actions = [
291
+ ...(data.action === "onboarding"
292
+ ? [
293
+ {
294
+ key: "onboarding",
295
+ label: t("messages.openOnboarding"),
296
+ variant: "default" as const,
297
+ href: localeHref(locale, "/account/onboarding"),
298
+ },
299
+ ]
300
+ : []),
301
+ ...(data.file
302
+ ? [
303
+ {
304
+ key: "file",
305
+ label: t("messages.download", { filename: data.file.filename }),
306
+ variant: "default" as const,
307
+ href: localeHref(
308
+ locale,
309
+ `/account/inbox/message/${encodeURIComponent(data.id)}/file`,
310
+ ),
311
+ },
312
+ ]
313
+ : []),
314
+ ];
286
315
  return {
287
316
  lead: data.body,
288
- ...(data.file
289
- ? {
290
- actions: [
291
- {
292
- key: "file",
293
- label: t("messages.download", { filename: data.file.filename }),
294
- variant: "default" as const,
295
- href: localeHref(
296
- locale,
297
- `/account/inbox/message/${encodeURIComponent(data.id)}/file`,
298
- ),
299
- },
300
- ],
301
- }
302
- : {}),
317
+ ...(actions.length > 0 ? { actions } : {}),
303
318
  fields: {
304
319
  heading: t("messages.heading"),
305
320
  items: [
@@ -200,6 +200,12 @@ async function dispatch(
200
200
 
201
201
  export interface ResetPasswordDeps extends DispatchDeps {
202
202
  prisma: AuthEmailPrisma;
203
+ /**
204
+ * The id of the person signed in on `request`, or `null` (#8431 D4). Set,
205
+ * it lets a signed-in person ask for their OWN first password — the
206
+ * Security tab's Set password. Unset, the § D14 guard is unchanged.
207
+ */
208
+ sessionUserId?: (request: Request | undefined) => Promise<string | null>;
203
209
  }
204
210
 
205
211
  /**
@@ -217,17 +223,29 @@ export interface ResetPasswordDeps extends DispatchDeps {
217
223
  * address does not exist": the second is the enumeration AC-1 forbids and the
218
224
  * first is nearly as useful to an attacker, since it maps a site's users to
219
225
  * their identity provider.
226
+ *
227
+ * THE ONE CARVE-OUT (#8431 D4). A person with no password who is SIGNED IN
228
+ * may set one from their Security tab, by the same emailed link: the mail
229
+ * goes out when `deps.sessionUserId(request)` is that same person's id. The
230
+ * concern above is an ANONYMOUS request adding a password path around
231
+ * someone's identity provider; a request carrying that person's own live
232
+ * session is that person. The anonymous path is unchanged and silent, the
233
+ * link still goes only to the account's own address, and a failed session
234
+ * lookup counts as no session.
220
235
  */
221
236
  export function makeSendResetPassword(deps: ResetPasswordDeps) {
222
237
  const { prisma, send } = deps;
223
238
  const log = deps.log ?? defaultLog;
224
- return async function sendResetPassword({
225
- user,
226
- url,
227
- }: {
228
- user: { id: string; email: string };
229
- url: string;
230
- }): Promise<void> {
239
+ return async function sendResetPassword(
240
+ {
241
+ user,
242
+ url,
243
+ }: {
244
+ user: { id: string; email: string };
245
+ url: string;
246
+ },
247
+ request?: Request,
248
+ ): Promise<void> {
231
249
  let hasPassword: { id: string } | null;
232
250
  try {
233
251
  hasPassword = await prisma.account.findFirst({
@@ -245,12 +263,29 @@ export function makeSendResetPassword(deps: ResetPasswordDeps) {
245
263
  }
246
264
 
247
265
  if (!hasPassword) {
248
- log.warn({
266
+ let sessionUserId: string | null = null;
267
+ let lookupFailed = false;
268
+ if (deps.sessionUserId) {
269
+ try {
270
+ sessionUserId = await deps.sessionUserId(request);
271
+ } catch {
272
+ lookupFailed = true;
273
+ }
274
+ }
275
+ if (sessionUserId !== user.id) {
276
+ log.warn({
277
+ userId: user.id,
278
+ kind: "password-reset",
279
+ reason: "no_credential_account",
280
+ ...(lookupFailed ? { errorKind: "session_lookup_failed" } : {}),
281
+ });
282
+ return; // no send; the endpoint's 200 body is unchanged
283
+ }
284
+ log.info({
249
285
  userId: user.id,
250
286
  kind: "password-reset",
251
- reason: "no_credential_account",
287
+ reason: "self_service_set",
252
288
  });
253
- return; // no send; the endpoint's 200 body is unchanged
254
289
  }
255
290
 
256
291
  await dispatch(
@@ -0,0 +1,103 @@
1
+ /**
2
+ * consent-visitor.ts — the banner's visitor id (#8438 D12, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md).
4
+ *
5
+ * Owns `ailk-vid`, a random version-4 UUID the site's own server mints for a
6
+ * visitor who has not signed in — it is the subject a cookie-banner choice is
7
+ * logged against (`POST /v1/consent/cookie`), and it is NEVER the cookie that
8
+ * gates anything (`ailk-consent`, §1.4/C3 of the spec, stays the operative
9
+ * switch; this id only lets a later form or sign-in link the two).
10
+ *
11
+ * httpOnly, `Secure` in production, `SameSite=Lax`, path `/`, one year — a
12
+ * Server Action can read and mint it; client script never sees its value.
13
+ */
14
+
15
+ const CONSENT_VISITOR_COOKIE_NAME = "ailk-vid";
16
+ const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
17
+
18
+ const V4_UUID =
19
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
20
+
21
+ /** Whether `value` is a well-formed version-4 UUID. */
22
+ export function isValidConsentVisitorId(value: string): boolean {
23
+ return V4_UUID.test(value);
24
+ }
25
+
26
+ /** The subset of Next's `cookies()` jar a read needs. */
27
+ export interface ConsentVisitorCookieReader {
28
+ get(name: string): { value: string } | undefined;
29
+ }
30
+
31
+ /** The cookie attributes `ensureConsentVisitor` sets — a subset of Next's `ResponseCookie`. */
32
+ export interface ConsentVisitorCookieOptions {
33
+ httpOnly?: boolean;
34
+ secure?: boolean;
35
+ sameSite?: "lax" | "strict" | "none";
36
+ path?: string;
37
+ maxAge?: number;
38
+ }
39
+
40
+ /**
41
+ * The subset of Next's `cookies()` jar `ensureConsentVisitor` needs. The
42
+ * options type is narrow (not `Record<string, unknown>`) on purpose: Next's
43
+ * real `cookies().set` is itself a rest-tuple overload
44
+ * (`ResponseCookies.set`), and TypeScript checks a rest-tuple signature's
45
+ * parameters contravariantly with no method-bivariance leniency — a wider
46
+ * options type here fails that check even though every real call site passes
47
+ * only these five attributes.
48
+ */
49
+ export interface ConsentVisitorCookieWriter extends ConsentVisitorCookieReader {
50
+ set(name: string, value: string, options: ConsentVisitorCookieOptions): void;
51
+ }
52
+
53
+ /** The existing visitor id, or `undefined` with none set or an invalid value. */
54
+ export function readConsentVisitor(
55
+ cookies: ConsentVisitorCookieReader,
56
+ ): string | undefined {
57
+ const value = cookies.get(CONSENT_VISITOR_COOKIE_NAME)?.value;
58
+ return value !== undefined && isValidConsentVisitorId(value) ? value : undefined;
59
+ }
60
+
61
+ /**
62
+ * The visitor id, minting and setting a fresh one when absent or invalid.
63
+ * Server Actions only — Next refuses a cookie write outside one (the same
64
+ * posture `hub-session/reseal.ts` documents).
65
+ */
66
+ export function ensureConsentVisitor(cookies: ConsentVisitorCookieWriter): string {
67
+ const existing = readConsentVisitor(cookies);
68
+ if (existing !== undefined) return existing;
69
+ const id = crypto.randomUUID();
70
+ cookies.set(CONSENT_VISITOR_COOKIE_NAME, id, {
71
+ httpOnly: true,
72
+ secure: process.env.NODE_ENV === "production",
73
+ sameSite: "lax",
74
+ path: "/",
75
+ maxAge: ONE_YEAR_SECONDS,
76
+ });
77
+ return id;
78
+ }
79
+
80
+ /** The one header a visitor-id-bearing request carries (D11). */
81
+ export function consentVisitorHeader(id: string): Record<string, string> {
82
+ return { "x-ailk-consent-visitor": id };
83
+ }
84
+
85
+ /**
86
+ * The visitor id out of a raw `Cookie:` header string (the relay's own
87
+ * `Request`, which carries no Next `cookies()` jar). `undefined` with no
88
+ * header, no matching cookie, or a value that fails the UUID shape.
89
+ */
90
+ export function readConsentVisitorFromCookieHeader(
91
+ header: string | null | undefined,
92
+ ): string | undefined {
93
+ if (!header) return undefined;
94
+ for (const part of header.split(";")) {
95
+ const eq = part.indexOf("=");
96
+ if (eq === -1) continue;
97
+ const name = part.slice(0, eq).trim();
98
+ if (name !== CONSENT_VISITOR_COOKIE_NAME) continue;
99
+ const value = part.slice(eq + 1).trim();
100
+ return isValidConsentVisitorId(value) ? value : undefined;
101
+ }
102
+ return undefined;
103
+ }
@@ -43,6 +43,7 @@ import { isIP } from "node:net";
43
43
  import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
44
44
 
45
45
  import { browserApiOrigin } from "./api-origin.generated";
46
+ import { readConsentVisitorFromCookieHeader } from "./consent-visitor";
46
47
 
47
48
  type RelayMethod = "GET" | "POST";
48
49
 
@@ -77,6 +78,16 @@ export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
77
78
  SESSION_ROUTE,
78
79
  ]);
79
80
 
81
+ /**
82
+ * The three lead routes (#8438 D11, D12): a visitor's banner choice is
83
+ * linked to a Person only through these — a booking has no consent writer.
84
+ */
85
+ const LEAD_ROUTE_PATHS = new Set([
86
+ "/v1/leads/submit",
87
+ "/v1/leads/subscribe",
88
+ "/v1/leads/callback",
89
+ ]);
90
+
80
91
  /** Fastify's default `bodyLimit`; the hub sets none (NFR4). */
81
92
  const RELAY_BODY_LIMIT = 1_048_576;
82
93
 
@@ -395,6 +406,15 @@ function relayHeaders(
395
406
  out,
396
407
  visitorForwardHeaders(incoming, process.env.AILK_VISITOR_FORWARD_KEY),
397
408
  );
409
+ // #8438 D12 — the banner's visitor id, read from THIS request's own
410
+ // `ailk-vid` cookie, never from a client-sent `x-ailk-*` header (the
411
+ // allow-list above already strips those). Only the three lead routes:
412
+ // the link between a banner choice and a client record is made there
413
+ // (D11), and the schedule routes have no consent writer to link.
414
+ if (LEAD_ROUTE_PATHS.has(entry.path)) {
415
+ const visitorId = readConsentVisitorFromCookieHeader(incoming.get("cookie"));
416
+ if (visitorId) out["x-ailk-consent-visitor"] = visitorId;
417
+ }
398
418
  }
399
419
  return out;
400
420
  }