create-ailk 0.19.1 → 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 (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,226 @@
1
+ /**
2
+ * /[locale]/account/privacy — User settings › Privacy (#8438 D14, NEW).
3
+ *
4
+ * RSC, outside the account layout's SignedInShell (#8430), in the member's
5
+ * settings frame (D7) — standalone, the Admin's shape. In DOM order:
6
+ *
7
+ * 1. account-privacy-summary — one line with a link to the site's
8
+ * privacy policy.
9
+ * 2. account-privacy-cookies — the current cookie-banner choice, read
10
+ * from the request's OWN `ailk-consent` cookie (never the ledger —
11
+ * the cookie stays the operative switch, C3), and a Change link
12
+ * (moved here from Notifications, #8438 D15) to
13
+ * `/privacy#cookie-settings`.
14
+ * 3. account-privacy-consents — one `ConsentSwitchList` row per
15
+ * `area: "data"` purpose, or the "no other data-use consents" line.
16
+ * 4. account-privacy-agreements — a read-only `AgreementList` of every
17
+ * agreement (`service_terms`, `account_terms`, …), or "None yet".
18
+ * 5. account-deletion — moved unchanged from General (#8438 D16).
19
+ *
20
+ * Sections 3 and 4 read `GET /v1/member/consents`; a failed read shows one
21
+ * line in their place and sections 1, 2 and 5 still render (D14).
22
+ */
23
+
24
+ import { member } from "@working-theory/api-client";
25
+ import {
26
+ AccountSettingsFrame,
27
+ AccountSettingsPane,
28
+ AgreementList,
29
+ ConsentSwitchList,
30
+ } from "@working-theory/templates/account";
31
+ import { localeHref } from "@working-theory/ui";
32
+ import { Heading, Link, Text } from "@working-theory/ui/primitives";
33
+ import type { Metadata } from "next";
34
+ import { cookies } from "next/headers";
35
+ import { getTranslations } from "next-intl/server";
36
+ import type { ReactElement } from "react";
37
+
38
+ import { accountSession } from "../account-data";
39
+ import { DeletionRequest } from "../general/deletion-request";
40
+ import { loadProfileSource } from "../general/profile-source";
41
+ import { memberSettingsFrame, type Translate } from "../settings-frame";
42
+
43
+ import { setConsentAction } from "./actions";
44
+
45
+ /** The cookie banner's own choice cookie (`layout.tsx`'s `CONSENT_COOKIE_NAME`, #5557). */
46
+ const CONSENT_COOKIE_NAME = "ailk-consent";
47
+
48
+ interface Props {
49
+ params: Promise<{ locale: string }>;
50
+ }
51
+
52
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
53
+ const { locale } = await params;
54
+ const t = await getTranslations({ locale, namespace: "account" });
55
+ return { title: t("settings.privacy.title"), description: t("settings.privacy.lead") };
56
+ }
57
+
58
+ export default async function AccountPrivacyPage({ params }: Props): Promise<ReactElement> {
59
+ const { locale } = await params;
60
+ const t = await getTranslations({ locale, namespace: "account" });
61
+ const session = await accountSession(locale);
62
+ const frame = memberSettingsFrame(locale, t as unknown as Translate, "privacy");
63
+
64
+ // Unreachable behind the (authed) gate; a session that expired between
65
+ // the layout and the page renders the pane's title alone.
66
+ if (!session) {
67
+ return (
68
+ <AccountSettingsFrame {...frame} name="">
69
+ <AccountSettingsPane
70
+ id="account-settings-privacy"
71
+ title={t("settings.privacy.title")}
72
+ lead={t("settings.privacy.lead")}
73
+ >
74
+ {null}
75
+ </AccountSettingsPane>
76
+ </AccountSettingsFrame>
77
+ );
78
+ }
79
+
80
+ const { ctx } = session;
81
+ const name = ctx.user.name || ctx.user.email;
82
+ const source = await loadProfileSource(ctx);
83
+
84
+ let cookieChoice: "accepted" | "declined" | null;
85
+ try {
86
+ const value = (await cookies()).get(CONSENT_COOKIE_NAME)?.value;
87
+ cookieChoice = value === "accepted" || value === "declined" ? value : null;
88
+ } catch {
89
+ cookieChoice = null;
90
+ }
91
+
92
+ let dataRows: { id: string; label: string; description?: string; granted: boolean; dateLine?: string }[] = [];
93
+ let agreementRows: { id: string; statement: string; version: number; acceptedLine?: string }[] = [];
94
+ let consentsAvailable = true;
95
+ try {
96
+ const res = await member.consents(ctx.init, { locale });
97
+ if (res.ok) {
98
+ dataRows = res.body.purposes
99
+ .filter((purpose) => purpose.area === "data")
100
+ .map((purpose) => ({
101
+ id: purpose.id,
102
+ label: purpose.statement,
103
+ granted: purpose.effective,
104
+ ...(purpose.description ? { description: purpose.description } : {}),
105
+ ...(purpose.state
106
+ ? {
107
+ dateLine: purpose.state.granted
108
+ ? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
109
+ : t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
110
+ }
111
+ : {}),
112
+ }));
113
+ agreementRows = res.body.purposes
114
+ .filter((purpose) => purpose.kind === "agreement")
115
+ .map((purpose) => ({
116
+ id: purpose.id,
117
+ statement: purpose.statement,
118
+ version: purpose.policyVersion,
119
+ ...(purpose.state?.granted
120
+ ? { acceptedLine: t("settings.privacy.accepted", { date: formatDate(purpose.state.at, locale) }) }
121
+ : {}),
122
+ }));
123
+ } else {
124
+ consentsAvailable = false;
125
+ }
126
+ } catch {
127
+ consentsAvailable = false;
128
+ }
129
+
130
+ return (
131
+ <AccountSettingsFrame {...frame} name={name}>
132
+ <AccountSettingsPane
133
+ id="account-settings-privacy"
134
+ title={t("settings.privacy.title")}
135
+ lead={t("settings.privacy.lead")}
136
+ >
137
+ <div data-testid="account-privacy-summary" className="flex flex-wrap items-baseline gap-1">
138
+ <Text as="span" variant="body-sm">
139
+ {t("settings.privacy.summary")}
140
+ </Text>
141
+ <Link href={localeHref(locale, "/privacy")} data-testid="account-privacy-policy">
142
+ {t("settings.privacy.policyLink")}
143
+ </Link>
144
+ </div>
145
+
146
+ <div data-testid="account-privacy-cookies" className="flex flex-col gap-1">
147
+ <Heading level={2} visual={4} className="!text-body-sm !font-medium">
148
+ {t("settings.privacy.cookiesTitle")}
149
+ </Heading>
150
+ <div className="flex flex-wrap items-baseline gap-3">
151
+ <Text as="span" variant="body-sm" tone="muted">
152
+ {cookieChoice === "accepted"
153
+ ? t("settings.privacy.cookiesAccepted")
154
+ : cookieChoice === "declined"
155
+ ? t("settings.privacy.cookiesDeclined")
156
+ : t("settings.privacy.cookiesUnset")}
157
+ </Text>
158
+ <Link
159
+ href={localeHref(locale, "/privacy#cookie-settings")}
160
+ className="inline-flex min-h-6 items-center"
161
+ data-testid="account-cookie-settings"
162
+ >
163
+ {t("settings.privacy.cookiesChange")}
164
+ </Link>
165
+ </div>
166
+ </div>
167
+
168
+ {consentsAvailable ? (
169
+ <>
170
+ <div data-testid="account-privacy-consents" className="flex flex-col gap-2">
171
+ <Heading level={2} visual={4} className="!text-body-sm !font-medium">
172
+ {t("settings.privacy.consentsTitle")}
173
+ </Heading>
174
+ <ConsentSwitchList
175
+ rows={dataRows}
176
+ onChange={setConsentAction}
177
+ emptyLine={t("settings.privacy.consentsEmpty")}
178
+ testIdPrefix="account-privacy-data-consent"
179
+ />
180
+ </div>
181
+
182
+ <div data-testid="account-privacy-agreements" className="flex flex-col gap-2">
183
+ <Heading level={2} visual={4} className="!text-body-sm !font-medium">
184
+ {t("settings.privacy.agreementsTitle")}
185
+ </Heading>
186
+ <AgreementList
187
+ rows={agreementRows}
188
+ notAcceptedLine={t("settings.privacy.agreementsEmpty")}
189
+ emptyLine={t("settings.privacy.agreementsEmpty")}
190
+ testIdPrefix="account-privacy-agreement"
191
+ />
192
+ </div>
193
+ </>
194
+ ) : (
195
+ <span data-testid="account-privacy-unavailable">
196
+ <Text as="p" variant="body-sm" tone="muted">
197
+ {t("settings.privacy.unavailable")}
198
+ </Text>
199
+ </span>
200
+ )}
201
+
202
+ <div data-testid="account-deletion">
203
+ <DeletionRequest
204
+ via={source.via}
205
+ supportHref={localeHref(locale, "/account/support")}
206
+ labels={{
207
+ lead: t("settings.privacy.deletionLead"),
208
+ action: t("settings.privacy.deletion"),
209
+ sentSite: t("settings.privacy.deletionSentSite"),
210
+ sentHub: t("settings.privacy.deletionSentHub"),
211
+ unverified: t("settings.privacy.deletionUnverified"),
212
+ unavailable: t("settings.privacy.deletionUnavailable"),
213
+ error: t("settings.privacy.deletionError"),
214
+ viaSupport: t("settings.privacy.deletionViaSupport"),
215
+ supportLink: t("settings.privacy.deletionSupportLink"),
216
+ }}
217
+ />
218
+ </div>
219
+ </AccountSettingsPane>
220
+ </AccountSettingsFrame>
221
+ );
222
+ }
223
+
224
+ function formatDate(iso: string, locale: string): string {
225
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
226
+ }
@@ -25,7 +25,7 @@
25
25
  import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
26
26
  import { localeHref } from "@working-theory/ui";
27
27
  import { Heading, Icon, Text } from "@working-theory/ui/primitives";
28
- import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
28
+ import { Bell, CreditCard, Lock, ShieldCheck, UserRound } from "lucide-react";
29
29
  import type { ReactElement, ReactNode } from "react";
30
30
 
31
31
  /** The `next-intl` translator General/Security/Notifications/Billing each
@@ -36,12 +36,14 @@ import type { ReactElement, ReactNode } from "react";
36
36
  * own `t` with `as unknown as Translate`. */
37
37
  export type Translate = (key: string, values?: Record<string, unknown>) => string;
38
38
 
39
- /** User settings' tabs, in the reference's order (#8339 D7) — each one a
40
- * route that renders outside the account shell (#8430, #8441). */
39
+ /** User settings' tabs, in the reference's order (#8339 D7; #8438 D14 adds
40
+ * Privacy before Billing) — each one a route that renders outside the
41
+ * account shell (#8430, #8441). */
41
42
  export const SETTINGS_ROUTES = [
42
43
  { key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
43
44
  { key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
44
45
  { key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
46
+ { key: "privacy", path: "/account/privacy", titleKey: "settings.privacy.title", icon: Lock },
45
47
  { key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
46
48
  ] as const;
47
49
 
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ConsentBanner — a thin client wrapper around `Banner` (#8438 D12).
5
+ *
6
+ * `layout.tsx` (a Server Component) cannot pass `acceptCookiesAction` /
7
+ * `declineCookiesAction` straight into `Banner`'s `onAccept`/`onReject`
8
+ * props: those are plain functions, not JSX, and a function crossing the
9
+ * RSC → Client boundary as a PROP (rather than being called from inside
10
+ * client code) is the exact class of crash `signed-in-nav-rsc-boundary.
11
+ * test.tsx` guards against (#7402's `renderFlowTrigger` idiom next door
12
+ * solves the same problem the same way). This component is itself
13
+ * "use client" and imports the two Server Actions directly, so nothing
14
+ * callable crosses from the server — only the serializable props below do.
15
+ */
16
+
17
+ import { Banner, type BannerProps } from "@working-theory/ui/chrome";
18
+ import type { ReactElement } from "react";
19
+
20
+ import { acceptCookiesAction, declineCookiesAction } from "./consent.actions";
21
+
22
+ export function ConsentBanner(
23
+ props: Omit<BannerProps, "onAccept" | "onReject">,
24
+ ): ReactElement {
25
+ return (
26
+ <Banner
27
+ {...props}
28
+ onAccept={() => void acceptCookiesAction()}
29
+ onReject={() => void declineCookiesAction()}
30
+ />
31
+ );
32
+ }
@@ -0,0 +1,71 @@
1
+ "use server";
2
+
3
+ /**
4
+ * acceptCookiesAction / declineCookiesAction — the cookie banner's two
5
+ * Server Actions (#8438 D12, spec
6
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md).
7
+ *
8
+ * Bound to Banner's `onAccept`/`onReject` in `layout.tsx`. Each ensures the
9
+ * visitor id (`consent-visitor.ts`), then calls `consent.recordCookieChoice`
10
+ * through `@working-theory/api-client` with the site's own spoke key (added
11
+ * by `http.ts`) and the visitor-forward headers a spoke attaches today.
12
+ *
13
+ * Neither ever throws to the client — Banner's `onAccept`/`onReject` are
14
+ * plain `() => void`, and the banner island already hides itself locally;
15
+ * a failed write here is logged and nothing more. Neither writes
16
+ * `ailk-consent` — the island owns that cookie, unchanged (C3).
17
+ */
18
+
19
+ import { consent, visitorForwardHeaders } from "@working-theory/api-client";
20
+ import { cookies, headers } from "next/headers";
21
+ import { getLocale } from "next-intl/server";
22
+
23
+ import { ensureConsentVisitor } from "../../lib/consent-visitor";
24
+
25
+ async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
26
+ try {
27
+ const cookieStore = await cookies();
28
+ const visitorId = ensureConsentVisitor(cookieStore);
29
+
30
+ let locale = "en";
31
+ try {
32
+ locale = await getLocale();
33
+ } catch {
34
+ // No request-scoped locale (e.g. outside next-intl's pipeline) — "en".
35
+ }
36
+
37
+ let init: { headers: Record<string, string> } | undefined;
38
+ try {
39
+ const forwarded = visitorForwardHeaders(
40
+ await headers(),
41
+ process.env.AILK_VISITOR_FORWARD_KEY,
42
+ );
43
+ if (forwarded) init = { headers: forwarded };
44
+ } catch {
45
+ // No request scope to read — record with no forwarding headers.
46
+ }
47
+
48
+ const body = { visitorId, choice, locale };
49
+ const res = init
50
+ ? await consent.recordCookieChoice(body, init)
51
+ : await consent.recordCookieChoice(body);
52
+ if (!res.ok) {
53
+ console.error("[consent.actions] record cookie choice refused", {
54
+ outcome: res.status,
55
+ });
56
+ }
57
+ } catch (err) {
58
+ // Never attach the visitor id or any request detail to the log line.
59
+ console.error("[consent.actions] record cookie choice failed", {
60
+ outcome: err instanceof Error ? err.name : "unknown",
61
+ });
62
+ }
63
+ }
64
+
65
+ export async function acceptCookiesAction(): Promise<void> {
66
+ await recordChoice("accepted");
67
+ }
68
+
69
+ export async function declineCookiesAction(): Promise<void> {
70
+ await recordChoice("declined");
71
+ }
@@ -48,7 +48,9 @@
48
48
  import { leads, visitorForwardHeaders } from "@working-theory/api-client";
49
49
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
50
50
  import { submitLeadInputSchema } from "@working-theory/validation";
51
- import { headers } from "next/headers";
51
+ import { cookies, headers } from "next/headers";
52
+
53
+ import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
52
54
 
53
55
  const GENERIC_ERROR = "Something went wrong, please try again.";
54
56
 
@@ -151,6 +153,16 @@ export async function captureLeadAction(
151
153
  // No request scope to read — submit without forwarding.
152
154
  }
153
155
 
156
+ // #8438 D12 — the banner's visitor id, so a later form submit links its
157
+ // cookie choices to the client record this submit creates or resolves
158
+ // (D11). Absent or invalid → no header, exactly as before this unit.
159
+ try {
160
+ const visitorId = readConsentVisitor(await cookies());
161
+ if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
162
+ } catch {
163
+ // No request scope to read — submit with no visitor-link header.
164
+ }
165
+
154
166
  try {
155
167
  const res = init ? await leads.submit(body, init) : await leads.submit(body);
156
168
  if (!res.ok) {
@@ -35,7 +35,9 @@ import {
35
35
  flowSlugSchema,
36
36
  submitFlowStepInputSchema,
37
37
  } from "@working-theory/validation";
38
- import { headers } from "next/headers";
38
+ import { cookies, headers } from "next/headers";
39
+
40
+ import { consentVisitorHeader, readConsentVisitor } from "../../../../lib/consent-visitor";
39
41
 
40
42
  /**
41
43
  * Reads the visitor's real IP from THIS action's own incoming request —
@@ -60,6 +62,22 @@ async function resolveVisitorIpInit(): Promise<
60
62
  }
61
63
  }
62
64
 
65
+ /**
66
+ * #8438 D12 — the banner's visitor id, so a magnet's step submit links its
67
+ * cookie choices to the Person lead-routing resolves (D11). `undefined`
68
+ * with no request scope, no cookie, or an invalid value.
69
+ */
70
+ async function resolveConsentVisitorHeader(): Promise<
71
+ Record<string, string> | undefined
72
+ > {
73
+ try {
74
+ const visitorId = readConsentVisitor(await cookies());
75
+ return visitorId ? consentVisitorHeader(visitorId) : undefined;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
63
81
  export async function submitFlowStepAction(
64
82
  slug: string,
65
83
  payload: {
@@ -102,12 +120,16 @@ export async function submitFlowStepAction(
102
120
  ...(locale !== undefined ? { locale } : {}),
103
121
  };
104
122
  const visitorIpInit = await resolveVisitorIpInit();
123
+ const consentVisitor = await resolveConsentVisitorHeader();
124
+ const mergedHeaders = { ...(visitorIpInit?.headers ?? {}), ...(consentVisitor ?? {}) };
125
+ const stepInit =
126
+ Object.keys(mergedHeaders).length > 0 ? { headers: mergedHeaders } : undefined;
105
127
  // Two call shapes, not one call + a conditional spread: this keeps the
106
128
  // call at EXACTLY 2 args when there's no header to forward (never an
107
129
  // explicit `undefined` 3rd arg) — collapsing it into a spread-args form
108
130
  // would silently change call arity for every existing caller/test.
109
- const res = visitorIpInit
110
- ? await flows.submitStep(parsedSlug.data, stepPayload, visitorIpInit)
131
+ const res = stepInit
132
+ ? await flows.submitStep(parsedSlug.data, stepPayload, stepInit)
111
133
  : await flows.submitStep(parsedSlug.data, stepPayload);
112
134
  if (!res.ok) {
113
135
  if (res.status === 400) {
@@ -37,6 +37,11 @@ import {
37
37
  type NavAction,
38
38
  type NavActionFlow,
39
39
  } from "@working-theory/ui/chrome";
40
+ import {
41
+ cookiePurpose,
42
+ resolveConsentStatement,
43
+ SITE_CONSENT_CONFIG_DEFAULTS,
44
+ } from "@working-theory/validation";
40
45
  import {
41
46
  buildHreflangAlternates,
42
47
  getStealthRobots,
@@ -82,6 +87,7 @@ import {
82
87
  } from "../../lib/topology.generated";
83
88
  import { resolveBaseUrl } from "../../src/lib/routes";
84
89
 
90
+ import { ConsentBanner } from "./consent-banner";
85
91
  import { readSiteContactLines } from "./contact/site-contact-lines";
86
92
 
87
93
  /**
@@ -362,6 +368,15 @@ export default async function LocaleLayout({
362
368
  // scaffold's messages file; the Banner below is UI-only — OSS ships no
363
369
  // enforcement, the callbacks are the hook for @working-theory/consent.
364
370
  const tConsent = await getTranslations({ locale, namespace: "consent" });
371
+ // #8438 C4/D12 — the banner's body is read FROM the registry, not a
372
+ // hard-coded string: the `analytics_cookies` purpose's statement, so the
373
+ // text the visitor reads and the text the ledger logs are one string by
374
+ // construction. No site carries its own registry row yet (C1), so this
375
+ // always reads the platform default — wiring a per-site row is a follow-up.
376
+ const consentCookiePurpose = cookiePurpose(SITE_CONSENT_CONFIG_DEFAULTS);
377
+ const consentBannerStatement = consentCookiePurpose
378
+ ? resolveConsentStatement(consentCookiePurpose, locale).statement
379
+ : "";
365
380
 
366
381
  // Right-side action cluster (#4984). Signed out → the auth pair: an
367
382
  // outline-strong "Sign in" → /sign-in beside a primary "Sign up" → /sign-up
@@ -868,7 +883,7 @@ export default async function LocaleLayout({
868
883
  * control (D2) instead of a hard server-side hide.
869
884
  */}
870
885
  {isAccountRoute ? null : (
871
- <Banner
886
+ <ConsentBanner
872
887
  variant="info"
873
888
  placement="bottom"
874
889
  consentCookieName={CONSENT_COOKIE_NAME}
@@ -876,7 +891,7 @@ export default async function LocaleLayout({
876
891
  title={tConsent("title")}
877
892
  message={
878
893
  <>
879
- {tConsent("body")}{" "}
894
+ {consentBannerStatement}{" "}
880
895
  <Link
881
896
  href={localeHref(locale, "/privacy")}
882
897
  variant="unstyled"
@@ -891,6 +906,12 @@ export default async function LocaleLayout({
891
906
  acceptLabel={tConsent("acceptLabel")}
892
907
  secondaryAction={{ label: tConsent("rejectLabel") }}
893
908
  rejectLabel={tConsent("rejectLabel")}
909
+ // #8438 D12 — each banner choice is logged against the
910
+ // visitor id through the one write path, `consent.actions.ts`,
911
+ // which `ConsentBanner` calls itself (never a function passed
912
+ // across the RSC boundary); the browser cookie
913
+ // (`CONSENT_COOKIE_NAME`) stays the operative switch the
914
+ // island itself still writes (C3).
894
915
  locale={locale}
895
916
  // #7742 D5 — the minimal chrome's consent bar: the page ground,
896
917
  // a hairline, no shadow.
@@ -22,7 +22,9 @@
22
22
  import { leads, visitorForwardHeaders } from "@working-theory/api-client";
23
23
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
24
24
  import { subscribeNewsletterInputSchema } from "@working-theory/validation";
25
- import { headers } from "next/headers";
25
+ import { cookies, headers } from "next/headers";
26
+
27
+ import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
26
28
 
27
29
  const GENERIC_ERROR = "Something went wrong, please try again.";
28
30
 
@@ -58,6 +60,15 @@ export async function subscribeNewsletterAction(
58
60
  // No request scope to read — subscribe without forwarding.
59
61
  }
60
62
 
63
+ // #8438 D12 — the banner's visitor id, so a later subscribe links its
64
+ // cookie choices to the client record. Absent or invalid → no header.
65
+ try {
66
+ const visitorId = readConsentVisitor(await cookies());
67
+ if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
68
+ } catch {
69
+ // No request scope to read — subscribe with no visitor-link header.
70
+ }
71
+
61
72
  try {
62
73
  const res = init
63
74
  ? await leads.subscribe(body, init)