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
@@ -19,21 +19,41 @@
19
19
  * launch lands on `errors.email` (the social row sits inside the password form
20
20
  * beside the email field). The SSO body has no fields, so the same message is
21
21
  * rendered as an `Alert` above the card for `method="sso"`.
22
+ *
23
+ * #8438 D13 — the site's sign-up agreements. `AuthCard` has no slot to render
24
+ * inside and no external "disabled" hook (packages/ui is not this unit's to
25
+ * change), so the card composes its own `ConsentGroup` below `AuthCard` and
26
+ * GATES every entry point from the outside: the password submit and every
27
+ * provider's click both run through this component's own closures, which do
28
+ * nothing until every required agreement is ticked. A wrapping div's
29
+ * `aria-disabled` + `pointer-events-none` styling carries the same state
30
+ * visually for a mouse/touch pointer in a real browser.
22
31
  */
23
32
 
24
33
  import {
25
34
  AuthCard,
35
+ ConsentGroup,
26
36
  type AuthCardLabels,
27
37
  type AuthSubmitData,
28
38
  } from "@working-theory/ui/blocks";
29
39
  import { Alert, AlertDescription } from "@working-theory/ui/primitives";
30
- import type { ReactElement } from "react";
40
+ import { useState, type MouseEvent, type ReactElement } from "react";
31
41
 
32
42
  import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
33
43
 
44
+ import { rememberSignUpAgreementsAction } from "./sign-up.actions";
45
+
34
46
  /** The OAuth providers this page can launch — a subset of AuthCard's. */
35
47
  export type SignUpProvider = "google" | "github";
36
48
 
49
+ /** One site agreement asked at sign-up (#8438 D1's `signUp` purposes). */
50
+ export interface SignUpAgreement {
51
+ id: string;
52
+ required: boolean;
53
+ /** Resolved to the active locale, link tokens included (D1). */
54
+ statement: string;
55
+ }
56
+
37
57
  export interface SignUpCardProps {
38
58
  /** Active OAuth providers, exactly as auth-env reports them. */
39
59
  providers: SignUpProvider[];
@@ -49,8 +69,16 @@ export interface SignUpCardProps {
49
69
  notConfiguredSubhead: string;
50
70
  /** Translated error copy. */
51
71
  errorLabels: { signUpFailed: string; providerFailed: string };
72
+ /** The site's `signUp` agreements — always at least `account_terms`. */
73
+ agreements: SignUpAgreement[];
74
+ /** Policy key → href, for the statements' inline links (D1/D3). */
75
+ agreementPolicies: Record<string, string>;
76
+ /** The consent fieldset's legend (`auth.signUp.agreementsLegend`). */
77
+ agreementsLegend: string;
52
78
  }
53
79
 
80
+ const AGREEMENTS_FIELD_NAME = "agreements";
81
+
54
82
  export function SignUpCard({
55
83
  providers,
56
84
  method,
@@ -59,6 +87,9 @@ export function SignUpCard({
59
87
  labels,
60
88
  notConfiguredSubhead,
61
89
  errorLabels,
90
+ agreements,
91
+ agreementPolicies,
92
+ agreementsLegend,
62
93
  }: SignUpCardProps): ReactElement {
63
94
  const { errors, providerError, launchProvider, submitPassword } =
64
95
  useAuthCardFlow(next, {
@@ -66,25 +97,62 @@ export function SignUpCard({
66
97
  passwordFailed: errorLabels.signUpFailed,
67
98
  });
68
99
 
100
+ const requiredAgreementIds = agreements.filter((a) => a.required).map((a) => a.id);
101
+ const [ticked, setTicked] = useState<ReadonlySet<string>>(new Set());
102
+ const allAgreed = requiredAgreementIds.every((id) => ticked.has(id));
103
+
104
+ // `ConsentGroup`'s checkboxes are uncontrolled, by design (#5275), and
105
+ // always start unticked (no `defaults` are ever passed here) — so a click
106
+ // on one is always a plain toggle of the LOCAL record below; this never
107
+ // reads the Radix root's own post-click DOM state, which a same-tick
108
+ // ancestor listener could still see as stale.
109
+ function handleAgreementsClick(event: MouseEvent<HTMLDivElement>): void {
110
+ const target = (event.target as HTMLElement).closest<HTMLElement>('[role="checkbox"]');
111
+ if (!target?.id) return;
112
+ const purposeId = target.id.slice(`${AGREEMENTS_FIELD_NAME}.`.length);
113
+ setTicked((previous) => {
114
+ const updated = new Set(previous);
115
+ if (updated.has(purposeId)) updated.delete(purposeId);
116
+ else updated.add(purposeId);
117
+ return updated;
118
+ });
119
+ }
120
+
69
121
  const notConfigured = method === "sso" && providers.length === 0;
70
122
 
71
123
  const providerButtons = providers.map((provider) => ({
72
124
  provider,
73
- onClick: () => void launchProvider(provider),
125
+ // Gated: a provider launch that would create the account is refused
126
+ // until every required agreement is ticked, exactly as the password
127
+ // submit below. No remember-cookie here — Better Auth's own OAuth
128
+ // callback carries no form data this component could read an email
129
+ // from, so the account home's explicit "Otherwise" card asks instead.
130
+ onClick: () => {
131
+ if (!allAgreed) return;
132
+ void launchProvider(provider);
133
+ },
74
134
  }));
75
135
 
76
136
  const handleSubmit = (data: AuthSubmitData): void => {
77
- // Better Auth requires a `name` on email sign-up; this card collects
78
- // none, so the address doubles as the display name until the person
79
- // sets one.
80
- void submitPassword(
81
- authClient.signUp.email({
82
- email: data.email,
83
- password: data.password ?? "",
84
- name: data.email,
85
- callbackURL: next,
86
- }),
87
- );
137
+ if (!allAgreed) return;
138
+ void (async () => {
139
+ try {
140
+ await rememberSignUpAgreementsAction(data.email, requiredAgreementIds);
141
+ } catch {
142
+ // Convenience only — see sign-up.actions.ts's file header.
143
+ }
144
+ // Better Auth requires a `name` on email sign-up; this card collects
145
+ // none, so the address doubles as the display name until the person
146
+ // sets one.
147
+ await submitPassword(
148
+ authClient.signUp.email({
149
+ email: data.email,
150
+ password: data.password ?? "",
151
+ name: data.email,
152
+ callbackURL: next,
153
+ }),
154
+ );
155
+ })();
88
156
  };
89
157
 
90
158
  return (
@@ -94,16 +162,31 @@ export function SignUpCard({
94
162
  <AlertDescription>{providerError}</AlertDescription>
95
163
  </Alert>
96
164
  ) : null}
97
- <AuthCard
98
- intent="sign-up"
99
- method={method}
100
- signInHref={signInHref}
101
- {...(notConfigured ? { subhead: notConfiguredSubhead } : {})}
102
- providers={providerButtons}
103
- onSubmit={handleSubmit}
104
- errors={errors}
105
- labels={labels}
106
- />
165
+ <div
166
+ aria-disabled={!allAgreed}
167
+ className={allAgreed ? undefined : "pointer-events-none opacity-60"}
168
+ data-testid="sign-up-card-gate"
169
+ >
170
+ <AuthCard
171
+ intent="sign-up"
172
+ method={method}
173
+ signInHref={signInHref}
174
+ {...(notConfigured ? { subhead: notConfiguredSubhead } : {})}
175
+ providers={providerButtons}
176
+ onSubmit={handleSubmit}
177
+ errors={errors}
178
+ labels={labels}
179
+ />
180
+ </div>
181
+ {/* eslint-disable-next-line -- delegated-click capture over ConsentGroup's own real `role="checkbox"` descendants (Radix), which are themselves already keyboard-operable and dispatch a native click on Enter/Space; this wrapper adds no interactive semantics of its own and is never itself focused. (Bare directive, not named rules: `jsx-a11y/*` is registered in this monorepo's own lint but not in a scaffolded site's trimmed eslint config — see agreements-prompt.tsx's identical note on `react-hooks/exhaustive-deps`.) */}
182
+ <div onClick={handleAgreementsClick} data-testid="sign-up-agreements">
183
+ <ConsentGroup
184
+ name={AGREEMENTS_FIELD_NAME}
185
+ legend={agreementsLegend}
186
+ purposes={agreements}
187
+ policies={agreementPolicies}
188
+ />
189
+ </div>
107
190
  </div>
108
191
  );
109
192
  }
@@ -0,0 +1,125 @@
1
+ "use server";
2
+
3
+ /**
4
+ * sign-up.actions.ts — mints the sign-up card's carried-agreement cookie
5
+ * (#8438 D13, spec
6
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
7
+ *
8
+ * `apps/web` never touches the database (the ledger writer is `apps/api`),
9
+ * and Better Auth creates the user from THIS app before any Person exists to
10
+ * record a grant against. So the card remembers which agreements the person
11
+ * ticked, in a short-lived, signed, email-bound cookie — the account home's
12
+ * first signed-in visit (`(authed)/account/actions.ts`'s
13
+ * `acceptCarriedAgreementsAction`) consumes it, one write per agreement,
14
+ * then deletes it either way (single-use).
15
+ *
16
+ * The CONSUME side lives in the account area, not here, deliberately:
17
+ * `(auth)/**` is one of the sign-in seeds a sign-in-less spoke drops
18
+ * (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), so a
19
+ * file OUTSIDE `(auth)` (the account home's `agreements-prompt.tsx`) must
20
+ * never import FROM here — doing so once failed the scaffolder's own
21
+ * dangling-import check (`spokeExclusions`) for exactly this reason. This
22
+ * file is reached only from `sign-up-card.tsx`, its own directory sibling,
23
+ * so it carries no such obligation. The two files duplicate the small
24
+ * cookie codec (encode here, decode there) rather than share a module,
25
+ * precisely so each side's dependencies stay native to where it lives.
26
+ *
27
+ * Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
28
+ * <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
29
+ * dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
30
+ * is unset; production refuses to mint without one. That secret is read
31
+ * directly HERE rather than importing `resolveSecret` from `auth.ts`: that
32
+ * module statically imports the `better-auth` ESM package, which every
33
+ * broad page-level test suite that renders `/sign-up` would then have to
34
+ * add to its `../../../../auth` mock. `__Host-` prefixed, httpOnly,
35
+ * `Secure`, one hour, `SameSite=Lax` — the `__Host-` prefix needs `Secure`,
36
+ * so development over plain `http://localhost` uses the unprefixed name
37
+ * instead (SEC-8438-5).
38
+ */
39
+
40
+ import { createHash, createHmac } from "node:crypto";
41
+
42
+ import { consentPurposeById, SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
43
+ import { cookies } from "next/headers";
44
+
45
+ const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
46
+ const DEV_COOKIE_NAME = "ailk-signup-agreements";
47
+ const MAX_AGE_SECONDS = 60 * 60;
48
+
49
+ /** The string `auth.ts`'s own `resolveSecret()` publishes — see file header. */
50
+ const DEV_FALLBACK_SECRET =
51
+ "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
52
+
53
+ /** Mirrors `auth.ts`'s `resolveSecret()` exactly, without importing it. */
54
+ function signUpAgreementsSecret(): string {
55
+ const configured = process.env.BETTER_AUTH_SECRET;
56
+ if (configured) return configured;
57
+ const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
58
+ if (process.env.NODE_ENV === "production" && !isBuildPhase) {
59
+ throw new Error(
60
+ "BETTER_AUTH_SECRET is required in production: it signs the carried sign-up-agreement cookie.",
61
+ );
62
+ }
63
+ return DEV_FALLBACK_SECRET;
64
+ }
65
+
66
+ function cookieName(): string {
67
+ return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
68
+ }
69
+
70
+ function emailHash(email: string): string {
71
+ return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
72
+ }
73
+
74
+ function sign(payload: string): string {
75
+ return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
76
+ }
77
+
78
+ interface CarriedPurpose {
79
+ id: string;
80
+ version: number;
81
+ }
82
+
83
+ function encodeCarriedAgreements(
84
+ purposes: CarriedPurpose[],
85
+ email: string,
86
+ issuedAtMs: number,
87
+ ): string {
88
+ const purposesPart = purposes.map((p) => `${p.id}@${p.version}`).join(",");
89
+ const payload = `${purposesPart}|${issuedAtMs}|${emailHash(email)}`;
90
+ return `${payload}|${sign(payload)}`;
91
+ }
92
+
93
+ /**
94
+ * Mints the carried-agreement cookie from the ticked `signUp` agreements,
95
+ * read back from the registry for their CURRENT version — never trusting a
96
+ * version the client might assert. Purposes the registry no longer marks
97
+ * `signUp` are dropped. Never throws: a failed mint just means the account
98
+ * home's first visit asks explicitly instead (D13's "Otherwise" card).
99
+ */
100
+ export async function rememberSignUpAgreementsAction(
101
+ email: string,
102
+ purposeIds: string[],
103
+ ): Promise<void> {
104
+ try {
105
+ const config = SITE_CONSENT_CONFIG_DEFAULTS;
106
+ const purposes: CarriedPurpose[] = [];
107
+ for (const id of purposeIds) {
108
+ const purpose = consentPurposeById(config, id);
109
+ if (purpose?.signUp) purposes.push({ id: purpose.id, version: purpose.policyVersion });
110
+ }
111
+ if (purposes.length === 0) return;
112
+
113
+ const value = encodeCarriedAgreements(purposes, email, Date.now());
114
+ const cookieStore = await cookies();
115
+ cookieStore.set(cookieName(), value, {
116
+ httpOnly: true,
117
+ secure: process.env.NODE_ENV === "production",
118
+ sameSite: "lax",
119
+ path: "/",
120
+ maxAge: MAX_AGE_SECONDS,
121
+ });
122
+ } catch {
123
+ // Convenience only — see file header.
124
+ }
125
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Log out in AccountFrame (#8465).
3
+ *
4
+ * A spoke that signs in through its hub holds its session in the sealed
5
+ * hub-session cookie, which only `POST /api/hub-session/sign-out` clears
6
+ * (and which also revokes the hub credential); Better Auth's `signOut()` hits
7
+ * a route that spoke does not serve. The server layout hands the seam's
8
+ * `SIGN_OUT_ENDPOINT` down as `signOutEndpoint`:
9
+ *
10
+ * - with an endpoint, Log out POSTs it (same-origin), never calls Better
11
+ * Auth, and lands on the sign-in page once the answer is 2xx;
12
+ * - a refused or failed sign-out (403 on an Origin mismatch, 5xx, a
13
+ * rejected fetch) stays on the page, shows the translated error, and
14
+ * navigates nowhere — the cookie is still valid (#8467 review addendum);
15
+ * - without an endpoint (a hub or a single site) it calls Better Auth and
16
+ * routes home, as before.
17
+ */
18
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
19
+ import type { ReactNode } from "react";
20
+
21
+ const mockSignOut = jest.fn();
22
+ jest.mock("better-auth/react", () => ({
23
+ __esModule: true,
24
+ createAuthClient: () => ({
25
+ signOut: (...args: unknown[]) => mockSignOut(...args),
26
+ }),
27
+ }));
28
+
29
+ const mockPush = jest.fn();
30
+ const mockRefresh = jest.fn();
31
+ jest.mock("next/navigation", () => ({
32
+ __esModule: true,
33
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
34
+ usePathname: () => "/account",
35
+ useSearchParams: () => new URLSearchParams(""),
36
+ }));
37
+
38
+ jest.mock("@working-theory/templates/shells", () => ({
39
+ __esModule: true,
40
+ SignedInShell: ({
41
+ onSignOut,
42
+ children,
43
+ }: {
44
+ onSignOut: () => void;
45
+ children: ReactNode;
46
+ }) => (
47
+ <div>
48
+ <button type="button" data-testid="sign-out" onClick={onSignOut} />
49
+ {children}
50
+ </div>
51
+ ),
52
+ }));
53
+
54
+ jest.mock("@working-theory/templates/account", () => ({
55
+ __esModule: true,
56
+ ForYouPanel: () => null,
57
+ PhoneTabProvider: ({ children }: { children: ReactNode }) => children,
58
+ useForYouDismissed: () => [new Set<string>(), jest.fn()],
59
+ }));
60
+
61
+ import { AccountFrame, type AccountFrameProps } from "../account-frame";
62
+
63
+ const ENDPOINT = "/api/hub-session/sign-out";
64
+ const FAILED = "We couldn't log you out. You're still signed in; try again.";
65
+ const mockFetch = jest.fn();
66
+
67
+ function renderFrame(extra: Partial<AccountFrameProps> = {}): void {
68
+ render(
69
+ <AccountFrame
70
+ locale="en"
71
+ user={{ name: "Ada", email: "ada@example.com" }}
72
+ logo={null}
73
+ helpItems={[]}
74
+ // eslint-disable-next-line no-restricted-syntax -- partial fixture: the suite exercises only the sign-out form, so the full forYou/labels shapes are not built
75
+ forYou={{ items: [] } as unknown as AccountFrameProps["forYou"]}
76
+ labels={
77
+ // eslint-disable-next-line no-restricted-syntax -- partial fixture (see forYou above)
78
+ {
79
+ home: "Home",
80
+ signOutFailed: FAILED,
81
+ } as unknown as AccountFrameProps["labels"]
82
+ }
83
+ {...extra}
84
+ >
85
+ <p>page</p>
86
+ </AccountFrame>,
87
+ );
88
+ }
89
+
90
+ beforeEach(() => {
91
+ jest.clearAllMocks();
92
+ globalThis.fetch = mockFetch.mockResolvedValue({ ok: true, status: 204 });
93
+ });
94
+
95
+ describe("AccountFrame Log out — a spoke that signs in through its hub", () => {
96
+ it("POSTs the hub sign-out endpoint, never Better Auth, then lands on the sign-in page and refreshes", async () => {
97
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
98
+ fireEvent.click(screen.getByTestId("sign-out"));
99
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
100
+ expect(mockFetch).toHaveBeenCalledWith(ENDPOINT, {
101
+ method: "POST",
102
+ credentials: "same-origin",
103
+ });
104
+ expect(mockSignOut).not.toHaveBeenCalled();
105
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
106
+ expect(
107
+ screen.queryByTestId("account-sign-out-error"),
108
+ ).not.toBeInTheDocument();
109
+ });
110
+
111
+ it("falls back to the site home when no sign-in href is given", async () => {
112
+ renderFrame({ signOutEndpoint: ENDPOINT });
113
+ fireEvent.click(screen.getByTestId("sign-out"));
114
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
115
+ });
116
+
117
+ it.each([
118
+ ["a 403 (Origin mismatch)", { ok: false, status: 403 }],
119
+ ["a 500", { ok: false, status: 500 }],
120
+ ["a 404", { ok: false, status: 404 }],
121
+ ])(
122
+ "%s stays on the page with the error and routes nowhere",
123
+ async (_label, response) => {
124
+ mockFetch.mockResolvedValue(response);
125
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
126
+ fireEvent.click(screen.getByTestId("sign-out"));
127
+ expect(
128
+ await screen.findByTestId("account-sign-out-error"),
129
+ ).toHaveTextContent(FAILED);
130
+ expect(mockPush).not.toHaveBeenCalled();
131
+ expect(mockRefresh).not.toHaveBeenCalled();
132
+ expect(mockSignOut).not.toHaveBeenCalled();
133
+ },
134
+ );
135
+
136
+ it("a rejected request stays on the page with the error and routes nowhere", async () => {
137
+ mockFetch.mockRejectedValue(new TypeError("network down"));
138
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
139
+ fireEvent.click(screen.getByTestId("sign-out"));
140
+ expect(
141
+ await screen.findByTestId("account-sign-out-error"),
142
+ ).toHaveTextContent(FAILED);
143
+ expect(mockPush).not.toHaveBeenCalled();
144
+ expect(mockRefresh).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("a retry that succeeds clears the error and lands on the sign-in page", async () => {
148
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
149
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
150
+ fireEvent.click(screen.getByTestId("sign-out"));
151
+ await screen.findByTestId("account-sign-out-error");
152
+ fireEvent.click(screen.getByTestId("sign-out"));
153
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
154
+ expect(
155
+ screen.queryByTestId("account-sign-out-error"),
156
+ ).not.toBeInTheDocument();
157
+ });
158
+ });
159
+
160
+ describe("AccountFrame Log out — a hub or a single site", () => {
161
+ it.each([
162
+ ["absent", {}],
163
+ ["null", { signOutEndpoint: null }],
164
+ ])(
165
+ "with the endpoint %s calls Better Auth, then routes home and refreshes",
166
+ async (_label, extra) => {
167
+ renderFrame(extra);
168
+ fireEvent.click(screen.getByTestId("sign-out"));
169
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
170
+ expect(mockSignOut).toHaveBeenCalledTimes(1);
171
+ expect(mockFetch).not.toHaveBeenCalled();
172
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
173
+ },
174
+ );
175
+ });