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
@@ -4,21 +4,28 @@
4
4
  *
5
5
  * `memberSettingsFrame` supplies the member's half of the shared
6
6
  * `AccountSettingsFrame` (@working-theory/templates/account, spec 8339 D2):
7
- * `standalone: false` (the member area already renders inside AppShell's
8
- * own `<main>`), Back to the Inbox, and the four tabs in the reference's
9
- * order. Every member settings page calls it and renders the result through
10
- * `AccountSettingsFrame` (General, via the shared `AccountSettings`) or
11
- * directly (Security, Notifications, Billing).
7
+ * `standalone: true` — the Admin's shape, full height and flush, because the
8
+ * account layout renders these routes outside its AppShell (#8430) — Back to
9
+ * the Inbox, and the tabs of `SETTINGS_ROUTES`. Every member settings page
10
+ * calls it and renders the result through `AccountSettingsFrame` (General,
11
+ * via the shared `AccountSettings`) or directly (Security, Notifications,
12
+ * Billing).
12
13
  *
13
14
  * `SettingsSection` stays as it is: contributed sections
14
15
  * (`accountSettingsSections`, `accountBillingSections`) still use it, as do
15
16
  * General's and Billing's own trailing content.
17
+ *
18
+ * #8441 — `SETTINGS_ROUTES` is the ONE place the settings routes are named:
19
+ * the tabs here and `isSettingsPath` (which `account-frame.tsx` takes these
20
+ * routes out of its shell with) both read it, so a tab added here is a tab
21
+ * that renders outside the shell — never a second `<main>` nested in the
22
+ * shell's. Adding a tab is one line here (plus its page and message).
16
23
  */
17
24
 
18
25
  import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
19
26
  import { localeHref } from "@working-theory/ui";
20
27
  import { Heading, Icon, Text } from "@working-theory/ui/primitives";
21
- import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
28
+ import { Bell, CreditCard, Lock, ShieldCheck, UserRound } from "lucide-react";
22
29
  import type { ReactElement, ReactNode } from "react";
23
30
 
24
31
  /** The `next-intl` translator General/Security/Notifications/Billing each
@@ -29,42 +36,45 @@ import type { ReactElement, ReactNode } from "react";
29
36
  * own `t` with `as unknown as Translate`. */
30
37
  export type Translate = (key: string, values?: Record<string, unknown>) => string;
31
38
 
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). */
42
+ export const SETTINGS_ROUTES = [
43
+ { key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
44
+ { key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
45
+ { key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
46
+ { key: "privacy", path: "/account/privacy", titleKey: "settings.privacy.title", icon: Lock },
47
+ { key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
48
+ ] as const;
49
+
50
+ export type SettingsTabKey = (typeof SETTINGS_ROUTES)[number]["key"];
51
+
52
+ /** Whether `pathname` is one of `SETTINGS_ROUTES`: the request locale's
53
+ * `/<locale>` prefix removed, then the rest compared whole — so a sub-route
54
+ * (`/account/billing/history`) is not a settings route unless it is listed. */
55
+ export function isSettingsPath(pathname: string, locale: string): boolean {
56
+ const prefix = `/${locale}`;
57
+ const bare =
58
+ pathname === prefix || pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
59
+ return SETTINGS_ROUTES.some(({ path }) => path === bare);
60
+ }
61
+
32
62
  /** The member's half of `AccountSettingsFrameProps` — every settings page's
33
63
  * `frame` prop, less `name` (the caller's own session) and `children`. */
34
64
  export function memberSettingsFrame(
35
65
  locale: string,
36
66
  t: Translate,
37
- active: "general" | "security" | "notifications" | "billing",
67
+ active: SettingsTabKey,
38
68
  ): Omit<AccountSettingsFrameProps, "name" | "children"> {
39
- const tabs: readonly AccountSettingsTab[] = [
40
- {
41
- key: "general",
42
- label: t("settings.general.title"),
43
- href: localeHref(locale, "/account/general"),
44
- icon: <Icon icon={UserRound} size="sm" decorative className="shrink-0" />,
45
- },
46
- {
47
- key: "security",
48
- label: t("settings.security.title"),
49
- href: localeHref(locale, "/account/security"),
50
- icon: <Icon icon={ShieldCheck} size="sm" decorative className="shrink-0" />,
51
- },
52
- {
53
- key: "notifications",
54
- label: t("settings.notifications.title"),
55
- href: localeHref(locale, "/account/notifications"),
56
- icon: <Icon icon={Bell} size="sm" decorative className="shrink-0" />,
57
- },
58
- {
59
- key: "billing",
60
- label: t("settings.billing.title"),
61
- href: localeHref(locale, "/account/billing"),
62
- icon: <Icon icon={CreditCard} size="sm" decorative className="shrink-0" />,
63
- },
64
- ];
69
+ const tabs: readonly AccountSettingsTab[] = SETTINGS_ROUTES.map(({ key, path, titleKey, icon }) => ({
70
+ key,
71
+ label: t(titleKey),
72
+ href: localeHref(locale, path),
73
+ icon: <Icon icon={icon} size="sm" decorative className="shrink-0" />,
74
+ }));
65
75
 
66
76
  return {
67
- standalone: false,
77
+ standalone: true,
68
78
  back: { label: t("settings.back"), href: localeHref(locale, "/account") },
69
79
  railLabel: t("settings.title"),
70
80
  accountLabel: t("settings.account"),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * account/sign-in-method.ts (server code) — how a member signs in, for User settings ›
3
- * General's Sign-in section (#8232, spec
3
+ * Security's sign-in block (#8232, spec
4
4
  * docs/specs/8232-member-settings-account-general-and-spec.md D7).
5
5
  *
6
6
  * One of the account tree's TWO topology seams (roadmap D1; the other is
@@ -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)
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @jest-environment node
3
+ * @jest-environment-options {"customExportConditions": ["react-server", "node", "require", "default"]}
4
+ */
5
+ // /[locale]/unsubscribe → real RSC render regression test (#8438 D17, the
6
+ // #7402 class).
7
+ //
8
+ // The create-ailk scaffold matrix's `organization-product · Free` leg answered
9
+ // `GET /unsubscribe` with a 500: `page.tsx` (a Server Component) handed
10
+ // `UnsubscribeButton` ("use client") a `labels.done` FUNCTION, and React
11
+ // refuses to serialize a function into the Flight payload ("Functions cannot
12
+ // be passed directly to Client Components"). `unsubscribe.test.tsx` renders
13
+ // the island in the same jsdom process, so it never crosses the boundary and
14
+ // stayed green while the real page crashed.
15
+ //
16
+ // This suite runs the page through React's own Flight serializer — the same
17
+ // `react-server-dom-webpack` server Next uses — under the `react-server`
18
+ // export condition, with every `"use client"` module in the render graph
19
+ // replaced by a real client reference, exactly as
20
+ // `apps/web/__tests__/signed-in-nav-rsc-boundary.test.tsx` does for the
21
+ // layout. A function crossing any server→client edge throws the production
22
+ // error here.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { Writable } from "node:stream";
27
+
28
+ import type { ReactElement, ReactNode } from "react";
29
+
30
+ // Next's compiled Flight server ships no types; it is the renderer Next itself runs.
31
+ const flight =
32
+ require("next/dist/compiled/react-server-dom-webpack/server.node") as {
33
+ createClientModuleProxy: (moduleId: string) => Record<string, unknown>;
34
+ renderToPipeableStream: (
35
+ model: ReactNode,
36
+ manifest: unknown,
37
+ options: { onError: (error: unknown) => void },
38
+ ) => { pipe: (destination: Writable) => void };
39
+ };
40
+
41
+ // ── The "use client" boundary ────────────────────────────────────────────────
42
+
43
+ const REPO_ROOT = path.resolve(__dirname, "../../../../../..");
44
+
45
+ /** This app and every workspace package its imports can resolve into. */
46
+ const WORKSPACE_DIRS = [
47
+ path.join(REPO_ROOT, "apps/web"),
48
+ // `packages-paid` is absent from the public tree the strip derives.
49
+ ...["packages", "packages-paid"]
50
+ .map((dir) => path.join(REPO_ROOT, dir))
51
+ .filter((dir) => fs.existsSync(dir))
52
+ .flatMap((dir) => fs.readdirSync(dir).map((pkg) => path.join(dir, pkg))),
53
+ ];
54
+
55
+ /** A file's directive prologue opens with "use client" (comments may precede it). */
56
+ const USE_CLIENT = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use client["']/;
57
+
58
+ /**
59
+ * Every source module that marks itself `"use client"` in the trees jest maps
60
+ * this app's imports onto: the app itself and the workspace packages' `src/`.
61
+ */
62
+ function clientModules(): string[] {
63
+ const roots = WORKSPACE_DIRS.map((dir) =>
64
+ dir.endsWith(path.join("apps", "web")) ? dir : path.join(dir, "src"),
65
+ );
66
+ const found: string[] = [];
67
+ const walk = (dir: string): void => {
68
+ if (!fs.existsSync(dir)) return;
69
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
70
+ if (["node_modules", ".next", "__tests__", "dist"].includes(entry.name))
71
+ continue;
72
+ const full = path.join(dir, entry.name);
73
+ if (entry.isDirectory()) walk(full);
74
+ else if (
75
+ /\.tsx?$/.test(entry.name) &&
76
+ !/\.(stories|test)\.tsx?$/.test(entry.name)
77
+ ) {
78
+ if (USE_CLIENT.test(fs.readFileSync(full, "utf8"))) found.push(full);
79
+ }
80
+ }
81
+ };
82
+ roots.forEach(walk);
83
+ return found;
84
+ }
85
+
86
+ /**
87
+ * The installed `@working-theory/*` packages' built `dist/` trees — the shape a
88
+ * create-ailk scaffold runs this test in (#8557). There jest maps nothing onto
89
+ * `src/`: `@working-theory/ui` is a tarball under `node_modules`, and its
90
+ * Radix-backed primitives carry the directive in their built files. Unmocked,
91
+ * they load as server code under the `react-server` condition and throw
92
+ * (`React.createContext is not a function`). In the monorepo the symlinked
93
+ * workspace package's `dist/` (when built) is walked too; those files are never
94
+ * reached, since jest maps the imports onto `src/`, so the extra mocks are inert.
95
+ */
96
+ function installedClientModules(): string[] {
97
+ const scope = path.join(REPO_ROOT, "apps/web/node_modules/@working-theory");
98
+ if (!fs.existsSync(scope)) return [];
99
+ const found: string[] = [];
100
+ const walk = (dir: string): void => {
101
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
102
+ const full = path.join(dir, entry.name);
103
+ if (entry.isDirectory()) walk(full);
104
+ else if (/\.(?:m|c)?js$/.test(entry.name)) {
105
+ if (USE_CLIENT.test(fs.readFileSync(full, "utf8"))) found.push(full);
106
+ }
107
+ }
108
+ };
109
+ for (const pkg of fs.readdirSync(scope)) {
110
+ let dist: string;
111
+ try {
112
+ dist = path.join(fs.realpathSync(path.join(scope, pkg)), "dist");
113
+ } catch {
114
+ continue;
115
+ }
116
+ if (fs.existsSync(dist)) walk(dist);
117
+ }
118
+ return found;
119
+ }
120
+
121
+ for (const file of [...clientModules(), ...installedClientModules()]) {
122
+ jest.doMock(file, () => flight.createClientModuleProxy(file));
123
+ }
124
+ // Third-party client entries the primitives barrel reaches (`next/image` via
125
+ // `Image.tsx`). pnpm can link a physically distinct `next` per workspace, so
126
+ // each entry is mocked at every copy a workspace resolves.
127
+ for (const specifier of ["next/link", "next/image", "next/script"]) {
128
+ const copies = new Set<string>();
129
+ for (const dir of WORKSPACE_DIRS) {
130
+ try {
131
+ copies.add(require.resolve(specifier, { paths: [dir] }));
132
+ } catch {
133
+ // This workspace does not depend on `next`.
134
+ }
135
+ }
136
+ for (const file of copies) {
137
+ jest.doMock(file, () => flight.createClientModuleProxy(specifier));
138
+ }
139
+ }
140
+ // `next-intl` is path-independent: jest.config.cjs maps every copy onto one stub.
141
+ jest.doMock("next-intl", () => flight.createClientModuleProxy("next-intl"));
142
+
143
+ // ── Render ───────────────────────────────────────────────────────────────────
144
+
145
+ /** Any client-reference id resolves to a chunk-less manifest entry. */
146
+ const CLIENT_MANIFEST = new Proxy(
147
+ {},
148
+ { get: (_target, id) => ({ id: String(id), chunks: [], name: "*" }) },
149
+ );
150
+
151
+ interface FlightResult {
152
+ errors: string[];
153
+ payload: string;
154
+ }
155
+
156
+ /** Serialize the unsubscribe page to a Flight payload, collecting render errors. */
157
+ async function renderPageToFlight(
158
+ token: string | undefined,
159
+ ): Promise<FlightResult> {
160
+ // Required, not imported: an `import` would be hoisted above the doMock calls.
161
+ const { default: UnsubscribePage } = require("../page") as {
162
+ default: (props: {
163
+ params: Promise<{ locale: string }>;
164
+ searchParams: Promise<{ t?: string }>;
165
+ }) => Promise<ReactElement>;
166
+ };
167
+
168
+ const errors: string[] = [];
169
+ const chunks: string[] = [];
170
+ await new Promise<void>((resolve, reject) => {
171
+ const destination = new Writable({
172
+ write(chunk: Buffer, _encoding, done) {
173
+ chunks.push(chunk.toString());
174
+ done();
175
+ },
176
+ final(done) {
177
+ done();
178
+ resolve();
179
+ },
180
+ });
181
+ destination.on("error", reject);
182
+ flight
183
+ .renderToPipeableStream(
184
+ <UnsubscribePage
185
+ params={Promise.resolve({ locale: "en" })}
186
+ searchParams={Promise.resolve(
187
+ token === undefined ? {} : { t: token },
188
+ )}
189
+ />,
190
+ CLIENT_MANIFEST,
191
+ { onError: (error) => void errors.push(String(error)) },
192
+ )
193
+ .pipe(destination);
194
+ });
195
+ return { errors, payload: chunks.join("") };
196
+ }
197
+
198
+ describe("/[locale]/unsubscribe crosses the RSC → client boundary cleanly", () => {
199
+ it("serializes with a token: nothing callable is handed to the island", async () => {
200
+ const { errors, payload } = await renderPageToFlight("tok_1");
201
+ expect(errors).toEqual([]);
202
+ expect(payload).toContain("unsubscribe-page");
203
+ });
204
+
205
+ it("serializes with no token", async () => {
206
+ const { errors } = await renderPageToFlight(undefined);
207
+ expect(errors).toEqual([]);
208
+ });
209
+ });