create-ailk 0.18.0 → 0.19.1

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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  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/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Tests for /[locale]/account/security — User settings › Security (#8339
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.
15
+ */
16
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ import { act, fireEvent, render, screen, within } from "@testing-library/react";
21
+
22
+ jest.mock("next-intl/server", () => {
23
+ const msgs: unknown = require("../../../../../messages/en.json");
24
+ const dig = (obj: unknown, path: string): unknown =>
25
+ path.split(".").reduce<unknown>(
26
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
27
+ obj,
28
+ );
29
+ return {
30
+ __esModule: true,
31
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
32
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
33
+ const messages = dig(msgs, ns);
34
+ return (key: string, values?: Record<string, unknown>): string => {
35
+ const value = dig(messages, key);
36
+ if (typeof value !== "string") return `${ns}.${key}`;
37
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
38
+ values && name in values ? String(values[name]) : m,
39
+ );
40
+ };
41
+ },
42
+ };
43
+ });
44
+
45
+ jest.mock("next/headers", () => ({
46
+ __esModule: true,
47
+ headers: jest.fn().mockResolvedValue(new Headers()),
48
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
49
+ }));
50
+
51
+ const mockGetSession = jest.fn();
52
+ const mockListUserAccounts = jest.fn();
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.
54
+ jest.mock("../../../../../auth", () => ({
55
+ __esModule: true,
56
+ auth: {
57
+ api: {
58
+ getSession: (...args: unknown[]) => mockGetSession(...args),
59
+ listUserAccounts: (...args: unknown[]) => mockListUserAccounts(...args),
60
+ },
61
+ },
62
+ }));
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
+
78
+ jest.mock("@working-theory/api-client", () => ({
79
+ __esModule: true,
80
+ waitlistSignups: { me: jest.fn() },
81
+ flowCheckouts: { me: jest.fn() },
82
+ subscription: { me: jest.fn(), portalSession: jest.fn() },
83
+ leads: { submit: jest.fn() },
84
+ member: { pieces: jest.fn().mockResolvedValue({ ok: true, body: [] }) },
85
+ hubSignIn: { me: jest.fn() },
86
+ visitorForwardHeaders: () => null,
87
+ }));
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();
101
+ jest.mock("next/navigation", () => ({
102
+ __esModule: true,
103
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh, replace: jest.fn() }),
104
+ usePathname: () => "/account/security",
105
+ redirect: jest.fn(),
106
+ }));
107
+
108
+ import Page from "../security/page";
109
+
110
+ const SESSION = {
111
+ user: {
112
+ id: "user_1",
113
+ name: "Ada Lovelace",
114
+ email: "ada@example.com",
115
+ emailVerified: true,
116
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
117
+ },
118
+ };
119
+
120
+ const BASE_URL = "https://site.example.com";
121
+ const ENV = process.env;
122
+ const ENV_KEYS = [
123
+ "GOOGLE_CLIENT_ID",
124
+ "GOOGLE_CLIENT_SECRET",
125
+ "GITHUB_CLIENT_ID",
126
+ "GITHUB_CLIENT_SECRET",
127
+ "AUTH_EMAIL_SERVER",
128
+ "AUTH_EMAIL_FROM",
129
+ "AUTH_EMAIL_WEBHOOK_SECRET",
130
+ "AILK_AUTH_PASSWORD_RESET",
131
+ "BETTER_AUTH_URL",
132
+ "BETTER_AUTH_SECRET",
133
+ ] as const;
134
+
135
+ let contentRoot: string;
136
+
137
+ beforeEach(() => {
138
+ process.env = { ...ENV };
139
+ for (const key of ENV_KEYS) delete process.env[key];
140
+ contentRoot = mkdtempSync(join(tmpdir(), "ailk-security-"));
141
+ process.env.AILK_CONTENT_ROOT = contentRoot;
142
+ writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
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;
150
+ });
151
+
152
+ afterEach(() => {
153
+ rmSync(contentRoot, { recursive: true, force: true });
154
+ process.env = ENV;
155
+ });
156
+
157
+ async function renderPage(locale = "en"): Promise<void> {
158
+ render(await Page({ params: Promise.resolve({ locale }) }));
159
+ }
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
+
181
+ describe("the frame (D7, D8)", () => {
182
+ it("renders in the settings frame with Security current", async () => {
183
+ await renderPage();
184
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Security");
185
+ expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
186
+ expect(screen.getByTestId("account-settings-rail-security")).toHaveAttribute("aria-current", "page");
187
+ expect(screen.getByTestId("account-settings-rail-general")).not.toHaveAttribute("aria-current");
188
+ });
189
+ });
190
+
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 } });
393
+ await renderPage();
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();
399
+ });
400
+
401
+ it.each([
402
+ ["password", enableReset],
403
+ ["sentence", enableGoogle],
404
+ ])("the %s variant has no 'other devices' control", async (_label, arrange) => {
405
+ arrange();
406
+ await renderPage();
407
+ expect(screen.queryByText(/other devices/i)).toBeNull();
408
+ });
409
+ });