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
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * Tests for /[locale]/account/security — User settings › Security (#8339
3
- * D8, NEW). Moved from general.test.tsx's "Sign-in" cases (#8339 D12):
4
- * the frame with Security current, and `signInMethod()`'s three branches
5
- * (#8232 D7) — configured methods, none, and (hub-mode.test.tsx) the hub
6
- * line.
3
+ * D8; #8431 D2, D3, AC-2, AC-3, AC-4).
4
+ *
5
+ * - the frame with Security current (#8339);
6
+ * - AC-2: a site or hub that can email a password link shows the Password
7
+ * row from the session's own accounts, and its button requests a link
8
+ * for the person's own address with the server-built `redirectTo`; a
9
+ * seam rendered before #8431 degrades to the sentence;
10
+ * - AC-3: any other site shows one sentence naming the deployment's
11
+ * methods, never a provider list;
12
+ * - AC-4 (site half): Log out signs out through Better Auth and lands on
13
+ * the sign-in page, and a failed log-out navigates nowhere. The spoke
14
+ * half is hub-mode.test.tsx's.
7
15
  */
8
16
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
17
  import { tmpdir } from "node:os";
10
18
  import { join } from "node:path";
11
19
 
12
- import { render, screen, within } from "@testing-library/react";
20
+ import { act, fireEvent, render, screen, within } from "@testing-library/react";
13
21
 
14
22
  jest.mock("next-intl/server", () => {
15
23
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -41,12 +49,32 @@ jest.mock("next/headers", () => ({
41
49
  }));
42
50
 
43
51
  const mockGetSession = jest.fn();
52
+ const mockListUserAccounts = jest.fn();
44
53
  // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
45
54
  jest.mock("../../../../../auth", () => ({
46
55
  __esModule: true,
47
- auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
56
+ auth: {
57
+ api: {
58
+ getSession: (...args: unknown[]) => mockGetSession(...args),
59
+ listUserAccounts: (...args: unknown[]) => mockListUserAccounts(...args),
60
+ },
61
+ },
48
62
  }));
49
63
 
64
+ /** `stale: true` serves the seam as one rendered before #8431 — no accounts reader (D2b). */
65
+ const mockSeam = { stale: false };
66
+ // eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); a seam rendered before #8431 (D2b) is the boundary under test, and no env or HTTP surface selects it.
67
+ jest.mock("../../../../../lib/topology.generated", () => {
68
+ const real = jest.requireActual("../../../../../lib/topology.generated") as Record<string, unknown>;
69
+ return {
70
+ ...real,
71
+ __esModule: true,
72
+ get readOwnAccounts(): unknown {
73
+ return mockSeam.stale ? undefined : real.readOwnAccounts;
74
+ },
75
+ };
76
+ });
77
+
50
78
  jest.mock("@working-theory/api-client", () => ({
51
79
  __esModule: true,
52
80
  waitlistSignups: { me: jest.fn() },
@@ -58,9 +86,21 @@ jest.mock("@working-theory/api-client", () => ({
58
86
  visitorForwardHeaders: () => null,
59
87
  }));
60
88
 
89
+ const mockRequestPasswordReset = jest.fn();
90
+ const mockSignOut = jest.fn();
91
+ jest.mock("better-auth/react", () => ({
92
+ __esModule: true,
93
+ createAuthClient: () => ({
94
+ requestPasswordReset: (...args: unknown[]) => mockRequestPasswordReset(...args),
95
+ signOut: (...args: unknown[]) => mockSignOut(...args),
96
+ }),
97
+ }));
98
+
99
+ const mockPush = jest.fn();
100
+ const mockRefresh = jest.fn();
61
101
  jest.mock("next/navigation", () => ({
62
102
  __esModule: true,
63
- useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
103
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh, replace: jest.fn() }),
64
104
  usePathname: () => "/account/security",
65
105
  redirect: jest.fn(),
66
106
  }));
@@ -77,6 +117,7 @@ const SESSION = {
77
117
  },
78
118
  };
79
119
 
120
+ const BASE_URL = "https://site.example.com";
80
121
  const ENV = process.env;
81
122
  const ENV_KEYS = [
82
123
  "GOOGLE_CLIENT_ID",
@@ -86,6 +127,9 @@ const ENV_KEYS = [
86
127
  "AUTH_EMAIL_SERVER",
87
128
  "AUTH_EMAIL_FROM",
88
129
  "AUTH_EMAIL_WEBHOOK_SECRET",
130
+ "AILK_AUTH_PASSWORD_RESET",
131
+ "BETTER_AUTH_URL",
132
+ "BETTER_AUTH_SECRET",
89
133
  ] as const;
90
134
 
91
135
  let contentRoot: string;
@@ -97,6 +141,12 @@ beforeEach(() => {
97
141
  process.env.AILK_CONTENT_ROOT = contentRoot;
98
142
  writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
99
143
  mockGetSession.mockReset().mockResolvedValue(SESSION);
144
+ mockListUserAccounts.mockReset().mockResolvedValue([]);
145
+ mockRequestPasswordReset.mockReset().mockResolvedValue({ data: { status: true }, error: null });
146
+ mockSignOut.mockReset().mockResolvedValue({ data: { success: true }, error: null });
147
+ mockPush.mockReset();
148
+ mockRefresh.mockReset();
149
+ mockSeam.stale = false;
100
150
  });
101
151
 
102
152
  afterEach(() => {
@@ -108,6 +158,26 @@ async function renderPage(locale = "en"): Promise<void> {
108
158
  render(await Page({ params: Promise.resolve({ locale }) }));
109
159
  }
110
160
 
161
+ /** The five `resetSendConfigured()` conjuncts (C1). */
162
+ function enableReset(): void {
163
+ process.env.AILK_AUTH_PASSWORD_RESET = "true";
164
+ process.env.AUTH_EMAIL_SERVER = "smtp://x";
165
+ process.env.AUTH_EMAIL_FROM = "site@example.com";
166
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "w".repeat(32);
167
+ process.env.BETTER_AUTH_URL = BASE_URL;
168
+ process.env.BETTER_AUTH_SECRET = "s".repeat(32);
169
+ }
170
+
171
+ function enableGoogle(): void {
172
+ process.env.GOOGLE_CLIENT_ID = "id";
173
+ process.env.GOOGLE_CLIENT_SECRET = "secret";
174
+ }
175
+
176
+ function enableGitHub(): void {
177
+ process.env.GITHUB_CLIENT_ID = "id";
178
+ process.env.GITHUB_CLIENT_SECRET = "secret";
179
+ }
180
+
111
181
  describe("the frame (D7, D8)", () => {
112
182
  it("renders in the settings frame with Security current", async () => {
113
183
  await renderPage();
@@ -118,22 +188,222 @@ describe("the frame (D7, D8)", () => {
118
188
  });
119
189
  });
120
190
 
121
- describe("Sign-in — the configured methods (D7, moved from general.test.tsx)", () => {
122
- it("lists each configured method as Available", async () => {
123
- process.env.GOOGLE_CLIENT_ID = "id";
124
- process.env.GOOGLE_CLIENT_SECRET = "secret";
125
- process.env.AUTH_EMAIL_SERVER = "smtp://x";
126
- process.env.AUTH_EMAIL_FROM = "site@example.com";
191
+ describe("the standalone shape (#8430)", () => {
192
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
193
+ await renderPage();
194
+ const settings = screen.getByTestId("account-settings");
195
+ expect(settings).toHaveClass("min-h-screen");
196
+ const column = within(settings).getByRole("complementary");
197
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
198
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
199
+ expect(within(settings).getByRole("main")).toContainElement(
200
+ screen.getByRole("heading", { level: 1 }),
201
+ );
202
+ });
203
+ });
204
+
205
+ describe("AC-2 — a site or hub that can email a password link", () => {
206
+ beforeEach(enableReset);
207
+
208
+ it("with no password: Not set, the create note and Set password — read from the session's own accounts", async () => {
209
+ await renderPage();
210
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Sign-in methods");
211
+ expect(screen.getByTestId("account-security-password-status")).toHaveTextContent("Not set");
212
+ expect(screen.getByTestId("account-security-password-note")).toHaveTextContent(
213
+ "We'll email ada@example.com a secure link to create your password.",
214
+ );
215
+ expect(screen.getByTestId("account-security-password-request")).toHaveTextContent("Set password");
216
+ expect(mockListUserAccounts).toHaveBeenCalledTimes(1);
217
+ expect(mockListUserAccounts.mock.calls[0]?.[0]).toEqual({ headers: expect.any(Headers) });
218
+ expect(document.querySelector('input[type="password"]')).toBeNull();
219
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
220
+ });
221
+
222
+ it("with a credential account: Set, the change note and Change password", async () => {
223
+ mockListUserAccounts.mockResolvedValue([{ providerId: "google" }, { providerId: "credential" }]);
224
+ await renderPage();
225
+ expect(screen.getByTestId("account-security-password-status")).toHaveTextContent("Set");
226
+ expect(screen.getByTestId("account-security-password-note")).toHaveTextContent(
227
+ "We'll email ada@example.com a secure link to change your password.",
228
+ );
229
+ expect(screen.getByTestId("account-security-password-request")).toHaveTextContent("Change password");
230
+ });
231
+
232
+ it("a failed accounts read renders the error row in the block's place, with Log out still rendered", async () => {
233
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
234
+ mockListUserAccounts.mockRejectedValue(new TypeError("db down"));
235
+ await renderPage();
236
+ expect(within(screen.getByTestId("account-signin-method")).getByTestId("inbox-error")).toHaveTextContent(
237
+ "We couldn't check your password settings.",
238
+ );
239
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
240
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
241
+ expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
242
+ "[account] read failed source=security-password errName=TypeError",
243
+ ]);
244
+ errorSpy.mockRestore();
245
+ });
246
+
247
+ it.each([
248
+ ["en", `${BASE_URL}/reset-password`],
249
+ ["fr", `${BASE_URL}/fr/reset-password`],
250
+ ])("Set password (%s) requests a link for the session's own address with the server-built redirectTo", async (locale, redirectTo) => {
251
+ await renderPage(locale);
252
+ await act(async () => {
253
+ fireEvent.click(screen.getByTestId("account-security-password-request"));
254
+ });
255
+ expect(mockRequestPasswordReset).toHaveBeenCalledTimes(1);
256
+ expect(mockRequestPasswordReset).toHaveBeenCalledWith({ email: "ada@example.com", redirectTo });
257
+ expect(screen.getByTestId("account-security-password-sent")).toBeInTheDocument();
258
+ });
259
+
260
+ it("a refused request shows the error, not the sent line", async () => {
261
+ mockRequestPasswordReset.mockResolvedValue({ data: null, error: { status: 429 } });
262
+ await renderPage();
263
+ await act(async () => {
264
+ fireEvent.click(screen.getByTestId("account-security-password-request"));
265
+ });
266
+ expect(screen.getByTestId("account-security-password-error")).toBeInTheDocument();
267
+ expect(screen.queryByTestId("account-security-password-sent")).not.toBeInTheDocument();
268
+ });
269
+
270
+ it("with Google configured as well, no provider list renders", async () => {
271
+ enableGoogle();
272
+ await renderPage();
273
+ expect(screen.getByTestId("account-security-password")).toBeInTheDocument();
274
+ expect(screen.queryByText(/Google/)).not.toBeInTheDocument();
275
+ });
276
+
277
+ it("a seam rendered before #8431 (no accounts reader) degrades to the sentence, never the error row (D2b)", async () => {
278
+ mockSeam.stale = true;
279
+ await renderPage();
280
+ // The five reset conjuncts include the email link's four, so a site that
281
+ // can email a password link always offers the email link too.
282
+ expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(
283
+ "You sign in with a link sent to ada@example.com or your email and password.",
284
+ );
285
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
286
+ expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
287
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
288
+ expect(mockListUserAccounts).not.toHaveBeenCalled();
289
+ });
290
+ });
291
+
292
+ describe("AC-3 — a site that cannot email a password link shows one sentence", () => {
293
+ it.each([
294
+ ["Google alone", [enableGoogle], "You sign in with Google."],
295
+ ["Google and GitHub", [enableGoogle, enableGitHub], "You sign in with Google or GitHub."],
296
+ [
297
+ "the email link alone",
298
+ [
299
+ () => {
300
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "w".repeat(32);
301
+ process.env.AUTH_EMAIL_FROM = "site@example.com";
302
+ process.env.BETTER_AUTH_URL = BASE_URL;
303
+ process.env.BETTER_AUTH_SECRET = "s".repeat(32);
304
+ },
305
+ ],
306
+ "You sign in with a link sent to ada@example.com.",
307
+ ],
308
+ [
309
+ "the password method without the reset flag",
310
+ [
311
+ () => {
312
+ process.env.AUTH_EMAIL_SERVER = "smtp://x";
313
+ process.env.AUTH_EMAIL_FROM = "site@example.com";
314
+ },
315
+ ],
316
+ "You sign in with your email and password.",
317
+ ],
318
+ ])("%s", async (_label, arrange, sentence) => {
319
+ for (const step of arrange) step();
320
+ await renderPage();
321
+ expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(sentence);
322
+ expect(screen.queryByText("You're signed in as ada@example.com.")).not.toBeInTheDocument();
323
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
324
+ expect(screen.queryByText("Sign-in methods")).not.toBeInTheDocument();
325
+ expect(screen.queryByText("Available")).not.toBeInTheDocument();
326
+ expect(mockListUserAccounts).not.toHaveBeenCalled();
327
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
328
+ });
329
+
330
+ it("with no configured method it names the signed-in address (C5)", async () => {
331
+ await renderPage();
332
+ expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(
333
+ "You're signed in as ada@example.com.",
334
+ );
335
+ });
336
+
337
+ it("the old Sign-in keys are gone and every added key carries its D5 value", () => {
338
+ const msgs = require("../../../../../messages/en.json") as {
339
+ account: { settings: { signIn: Record<string, unknown>; security: Record<string, unknown> } };
340
+ };
341
+ const { signIn, security } = msgs.account.settings;
342
+ expect(Object.keys(signIn)).toEqual(["lead"]);
343
+ expect(security).toEqual({
344
+ title: "Security",
345
+ methodsHeading: "Sign-in methods",
346
+ passwordLabel: "Password",
347
+ passwordSet: "Set",
348
+ passwordNotSet: "Not set",
349
+ setHint: "Add a password so you can also sign in with your email and password.",
350
+ changeHint: "Change the password you sign in with.",
351
+ setNote: "We'll email {email} a secure link to create your password.",
352
+ changeNote: "We'll email {email} a secure link to change your password.",
353
+ setButton: "Set password",
354
+ changeButton: "Change password",
355
+ sending: "Sending…",
356
+ sent: "Link sent. It works once, for an hour. Saving a password signs you out everywhere.",
357
+ sendFailed: "We couldn't send the email. Try again in a few minutes.",
358
+ logOut: "Log out",
359
+ logOutFailed: "Log out didn't finish. Try again.",
360
+ signsInWith: "You sign in with {methods}.",
361
+ signedInAs: "You're signed in as {email}.",
362
+ methods: {
363
+ google: "Google",
364
+ github: "GitHub",
365
+ magicLink: "a link sent to {email}",
366
+ password: "your email and password",
367
+ },
368
+ managedAt: "Your sign-in is managed at {hub}.",
369
+ managedUnnamed: "Your sign-in is managed by our main site.",
370
+ manageOnHub: "Manage sign-in on {hub}",
371
+ passwordUnavailable: "We couldn't check your password settings.",
372
+ });
373
+ });
374
+ });
375
+
376
+ describe("AC-4 — Log out on a site", () => {
377
+ it.each([
378
+ ["en", "/sign-in"],
379
+ ["fr", "/fr/sign-in"],
380
+ ])("(%s) signs out through Better Auth once and lands on the sign-in page", async (locale, href) => {
381
+ enableGoogle();
382
+ await renderPage(locale);
383
+ await act(async () => {
384
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
385
+ });
386
+ expect(mockSignOut).toHaveBeenCalledTimes(1);
387
+ expect(mockPush).toHaveBeenCalledWith(href);
388
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
389
+ });
390
+
391
+ it("a refused sign-out navigates nowhere and shows the error", async () => {
392
+ mockSignOut.mockResolvedValue({ data: null, error: { status: 500 } });
127
393
  await renderPage();
128
- const signIn = screen.getByTestId("account-signin-method");
129
- expect(within(signIn).getByTestId("account-signin-google")).toHaveTextContent("Available");
130
- expect(within(signIn).getByText("Google")).toBeInTheDocument();
131
- expect(within(signIn).getByText("Password")).toBeInTheDocument();
132
- expect(within(signIn).queryByText("GitHub")).not.toBeInTheDocument();
394
+ await act(async () => {
395
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
396
+ });
397
+ expect(mockPush).not.toHaveBeenCalled();
398
+ expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
133
399
  });
134
400
 
135
- it("no configured method says so", async () => {
401
+ it.each([
402
+ ["password", enableReset],
403
+ ["sentence", enableGoogle],
404
+ ])("the %s variant has no 'other devices' control", async (_label, arrange) => {
405
+ arrange();
136
406
  await renderPage();
137
- expect(screen.getByTestId("account-signin-method")).toHaveTextContent("No sign-in method is configured.");
407
+ expect(screen.queryByText(/other devices/i)).toBeNull();
138
408
  });
139
409
  });
@@ -9,6 +9,11 @@
9
9
  * for D2–D9. A `#billing` fragment never reaches the server, so the server
10
10
  * redirect is to General and `BillingAnchor` forwards the fragment there
11
11
  * (`general.test.tsx` § AC-6).
12
+ *
13
+ * #8441 — `SETTINGS_ROUTES` (settings-frame.tsx) is the one list of User
14
+ * settings' routes: the tabs read it, and so does `isSettingsPath`, the
15
+ * exact, locale-aware membership test the account frame takes the settings
16
+ * routes out of its shell with (layout.test.tsx renders each route).
12
17
  */
13
18
 
14
19
  const mockRedirect = jest.fn();
@@ -18,6 +23,7 @@ jest.mock("next/navigation", () => ({
18
23
  }));
19
24
 
20
25
  import Page from "../settings/page";
26
+ import { isSettingsPath, memberSettingsFrame, SETTINGS_ROUTES, type Translate } from "../settings-frame";
21
27
 
22
28
  beforeEach(() => {
23
29
  mockRedirect.mockReset();
@@ -35,3 +41,53 @@ describe("AC-6 — /account/settings redirects to /account/general (D8)", () =>
35
41
  expect(mockRedirect).toHaveBeenCalledWith("/fr/account/general");
36
42
  });
37
43
  });
44
+
45
+ describe("the one list of User settings' routes (#8441; #8438 D14 adds Privacy)", () => {
46
+ it("names the five tabs, in the reference's order", () => {
47
+ expect(SETTINGS_ROUTES.map(({ key, path }) => [key, path])).toEqual([
48
+ ["general", "/account/general"],
49
+ ["security", "/account/security"],
50
+ ["notifications", "/account/notifications"],
51
+ ["privacy", "/account/privacy"],
52
+ ["billing", "/account/billing"],
53
+ ]);
54
+ });
55
+
56
+ it.each(["en", "fr"])("the settings frame's tabs are that list, in %s", (locale) => {
57
+ const t: Translate = (key) => key;
58
+ const { tabs } = memberSettingsFrame(locale, t, "security");
59
+ expect(tabs.map(({ key, href, label }) => [key, href, label])).toEqual(
60
+ SETTINGS_ROUTES.map(({ key, path, titleKey }) => [
61
+ key,
62
+ locale === "en" ? path : `/${locale}${path}`,
63
+ titleKey,
64
+ ]),
65
+ );
66
+ });
67
+ });
68
+
69
+ describe("isSettingsPath — an exact, locale-aware match (#8441)", () => {
70
+ it.each(SETTINGS_ROUTES.map(({ path }) => path))("%s is a settings route, bare or prefixed", (path) => {
71
+ expect(isSettingsPath(path, "en")).toBe(true);
72
+ expect(isSettingsPath(`/fr${path}`, "fr")).toBe(true);
73
+ });
74
+
75
+ it.each([
76
+ ["/account", "en"],
77
+ ["/account/settings", "en"],
78
+ ["/account/support", "en"],
79
+ ["/account/artifacts", "en"],
80
+ // A sub-route stays inside the shell unless it is listed.
81
+ ["/account/billing/history", "en"],
82
+ ["/fr/account/billing/history", "fr"],
83
+ ["/account/general/", "en"],
84
+ ["/account/generalx", "en"],
85
+ // Only the request's own locale is a prefix.
86
+ ["/fr/account/general", "en"],
87
+ ["/de/account/general", "fr"],
88
+ ["/frx/account/general", "fr"],
89
+ ["", "en"],
90
+ ])("%s (locale %s) is not", (path, locale) => {
91
+ expect(isSettingsPath(path, locale)).toBe(false);
92
+ });
93
+ });
@@ -1,4 +1,4 @@
1
- "use client"; // the shell owns collapse state; sign-out is a client-side Better Auth call + router navigation
1
+ "use client"; // the shell owns collapse state; sign-out is a client-side hub-session POST or Better Auth call + router navigation
2
2
 
3
3
  /**
4
4
  * AccountFrame — the account area's SignedInShell composition (#7430, spec
@@ -7,8 +7,21 @@
7
7
  * what only the client has: the current path and the sign-out call.
8
8
  *
9
9
  * `createAuthClient()` at module scope is the `sign-in-gate.tsx` idiom. Log
10
- * out ends the session, then navigates home and refreshes so every RSC under
11
- * the `(authed)` gate re-reads the (now absent) session.
10
+ * out ends the session, then navigates and refreshes so every RSC under the
11
+ * `(authed)` gate re-reads the (now absent) session.
12
+ *
13
+ * #8465 — a spoke that signs in through its hub (#7894) holds its session in
14
+ * a sealed cookie only `POST /api/hub-session/sign-out` clears (it also
15
+ * revokes the credential at the hub); Better Auth's `signOut()` hits a route
16
+ * that spoke does not serve. The server layout reads the generated seam's
17
+ * `SIGN_OUT_ENDPOINT` and hands it down as `signOutEndpoint` — the signed-in
18
+ * Nav's own pattern, because a client component cannot import the seam. With
19
+ * an endpoint, Log out POSTs it and navigates to the sign-in page only when
20
+ * the answer was 2xx; a refused or failed sign-out (a 403 on an Origin
21
+ * mismatch, a 5xx, a network error) leaves the cookie valid, so the frame
22
+ * stays on the page and says so, never routing away with a live session.
23
+ * Without an endpoint (a hub or a single site) nothing changes: Better Auth,
24
+ * then home.
12
25
  *
13
26
  * #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
14
27
  * holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
@@ -35,6 +48,15 @@
35
48
  * portals to `document.body` (the avatar/help menus, For-you, New, the phone
36
49
  * sheets) without the wrapper adding a box — `contents` keeps the shell's own
37
50
  * layout (`min-h-screen`) unchanged.
51
+ *
52
+ * #8430 — the User settings routes take the screen as the Admin's do: the
53
+ * frame renders the page alone, inside the marker only — no rail, no Inbox,
54
+ * no AppShell — and the page draws the shared `AccountSettingsFrame` in its
55
+ * standalone shape, its own column flush and full height with Back to the
56
+ * Inbox at its top. The marker stays, so the Admin's tokens still reach the
57
+ * settings in both modes. Which routes those are is `isSettingsPath` — an
58
+ * exact match on `SETTINGS_ROUTES`, the list the settings tabs read too
59
+ * (#8441), so a sub-route stays inside the shell.
38
60
  */
39
61
 
40
62
  import {
@@ -50,7 +72,7 @@ import {
50
72
  type SignedInShellProps,
51
73
  } from "@working-theory/templates/shells";
52
74
  import { localeHref } from "@working-theory/ui";
53
- import { Link } from "@working-theory/ui/primitives";
75
+ import { InlineMessage, Link } from "@working-theory/ui/primitives";
54
76
  import { createAuthClient } from "better-auth/react";
55
77
  import { Inbox, Layers, Settings, Sparkles } from "lucide-react";
56
78
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -58,6 +80,7 @@ import { useState, type ReactElement, type ReactNode } from "react";
58
80
 
59
81
  import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
60
82
  import { NewMenuSlotProvider } from "./new-menu";
83
+ import { isSettingsPath } from "./settings-frame";
61
84
 
62
85
  const authClient = createAuthClient();
63
86
 
@@ -74,6 +97,8 @@ export interface AccountFrameLabels extends SignedInShellLabels {
74
97
  /** The rail's Library group heading (#8231 D1). */
75
98
  library: string;
76
99
  artifacts: string;
100
+ /** Shown when a hub sign-out is refused or fails (#8465). */
101
+ signOutFailed: string;
77
102
  }
78
103
 
79
104
  export interface AccountFrameProps {
@@ -84,6 +109,14 @@ export interface AccountFrameProps {
84
109
  helpItems: NonNullable<SignedInShellProps["helpItems"]>;
85
110
  forYou: { items: readonly ForYouItem[]; now: string; labels: ForYouLabels };
86
111
  labels: AccountFrameLabels;
112
+ /**
113
+ * Where Log out POSTs on a spoke that signs in through its hub (the seam's
114
+ * `SIGN_OUT_ENDPOINT`, read by the server layout). Absent or `null`: Better
115
+ * Auth's client signs out (#8465).
116
+ */
117
+ signOutEndpoint?: string | null;
118
+ /** Where Log out lands after a hub sign-out; defaults to the site home. */
119
+ signInHref?: string;
87
120
  children: ReactNode;
88
121
  }
89
122
 
@@ -94,6 +127,8 @@ export function AccountFrame({
94
127
  helpItems,
95
128
  forYou,
96
129
  labels,
130
+ signOutEndpoint,
131
+ signInHref,
97
132
  children,
98
133
  }: AccountFrameProps): ReactElement {
99
134
  const router = useRouter();
@@ -101,12 +136,23 @@ export function AccountFrame({
101
136
  const searchParams = useSearchParams();
102
137
  const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
103
138
  const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
139
+ const [signOutFailed, setSignOutFailed] = useState(false);
104
140
 
105
141
  const homeHref = localeHref(locale, "/");
106
142
  const inboxHref = localeHref(locale, "/account");
107
143
  const artifactsHref = localeHref(locale, "/account/artifacts");
108
144
  const settingsHref = localeHref(locale, "/account/general");
109
145
  const within = (href: string): boolean => pathname === href || pathname.startsWith(`${href}/`);
146
+
147
+ // #8430 — a Settings page draws its own standalone frame: the marker only.
148
+ if (isSettingsPath(pathname, locale)) {
149
+ return (
150
+ <div className="contents" data-ailk-shell="account" data-testid="account-shell">
151
+ {children}
152
+ </div>
153
+ );
154
+ }
155
+
110
156
  // The Inbox row stays selected on an item's own page (/account/inbox/…).
111
157
  const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
112
158
  const activePath = onInbox ? inboxHref : within(artifactsHref) ? artifactsHref : undefined;
@@ -118,6 +164,24 @@ export function AccountFrame({
118
164
 
119
165
  const onSignOut = (): void => {
120
166
  void (async () => {
167
+ setSignOutFailed(false);
168
+ if (signOutEndpoint) {
169
+ // A refused or failed sign-out leaves the session cookie valid, so the
170
+ // page must not pretend otherwise by navigating away (#8465).
171
+ try {
172
+ const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
173
+ if (!res.ok) {
174
+ setSignOutFailed(true);
175
+ return;
176
+ }
177
+ } catch {
178
+ setSignOutFailed(true);
179
+ return;
180
+ }
181
+ router.push(signInHref ?? homeHref);
182
+ router.refresh();
183
+ return;
184
+ }
121
185
  try {
122
186
  await authClient.signOut();
123
187
  } catch {
@@ -136,6 +200,7 @@ export function AccountFrame({
136
200
  settings,
137
201
  library,
138
202
  artifacts,
203
+ signOutFailed: signOutFailedLabel,
139
204
  ...shellLabels
140
205
  } = labels;
141
206
 
@@ -222,6 +287,11 @@ export function AccountFrame({
222
287
  : {})}
223
288
  labels={shellLabels}
224
289
  >
290
+ {signOutFailed ? (
291
+ <InlineMessage variant="error" className="px-4 pt-3" data-testid="account-sign-out-error">
292
+ {signOutFailedLabel}
293
+ </InlineMessage>
294
+ ) : null}
225
295
  <PhoneTabProvider tab={phoneTab}>
226
296
  <NewMenuSlotProvider node={onInboxRoute ? topBarActions : null}>
227
297
  {children}
@@ -33,6 +33,14 @@
33
33
  * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE any
34
34
  * try/catch (mirrors `checkout.actions.ts`'s idiom) so a catch never
35
35
  * swallows the navigation this action exists to perform.
36
+ *
37
+ * This file does NOT hold the carried sign-up-agreement consume step
38
+ * (`acceptCarriedAgreementsAction`, #8438 D13) — that lives in this
39
+ * directory's own `agreements-actions.ts` instead, deliberately kept apart
40
+ * from this file's `billing-source.ts` import: that module pulls in
41
+ * `account-data.ts`, `@working-theory/ui` and `lucide-react`, a far heavier
42
+ * footprint than the small, self-contained cookie codec needs. See
43
+ * `agreements-actions.ts`'s own header.
36
44
  */
37
45
 
38
46
  import { redirect } from "next/navigation";