create-ailk 0.11.1 → 0.11.3

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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file auth-email.test.ts — Better Auth's three mail callbacks (#5962 § D3).
2
+ * @file auth-email.test.ts — Better Auth's mail callbacks (#5962 § D3, #7435).
3
3
  *
4
4
  * Written before the implementation. The transport is INJECTED, so nothing
5
5
  * here mocks an internal module and nothing here sends mail.
@@ -23,6 +23,8 @@
23
23
  import type { AuthEmailLog } from "../auth-email";
24
24
  import {
25
25
  buildAuthEmailRequest,
26
+ MAGIC_LINK_TTL_SECONDS,
27
+ makeMagicLinkOptions,
26
28
  makeSendResetPassword,
27
29
  makeSendVerificationEmail,
28
30
  makeSendPasswordChangedNotice,
@@ -212,3 +214,156 @@ describe("makeSendPasswordChangedNotice (§ D17)", () => {
212
214
  ).resolves.toBeUndefined();
213
215
  });
214
216
  });
217
+
218
+ // ── #7435 — the magic-link callback (§ D2, § D3, AC-2) ─────────────────────
219
+
220
+ /**
221
+ * A Better Auth endpoint-context double: only the two members the callback
222
+ * may touch. `runInBackgroundOrAwait` records the promise it is handed and
223
+ * returns immediately, as the real one does with `backgroundTasks.handler`.
224
+ */
225
+ function makeCtx(user: { id: string; email: string } | null | "throws") {
226
+ const background: Promise<unknown>[] = [];
227
+ const findUserByEmail = jest.fn((_email: string) =>
228
+ user === "throws"
229
+ ? Promise.reject(new Error("db down for person@example.com"))
230
+ : Promise.resolve(user ? { user } : null),
231
+ );
232
+ const runInBackgroundOrAwait = jest.fn((p: Promise<unknown> | void) => {
233
+ if (p) background.push(p);
234
+ });
235
+ return {
236
+ background,
237
+ findUserByEmail,
238
+ runInBackgroundOrAwait,
239
+ ctx: { context: { internalAdapter: { findUserByEmail }, runInBackgroundOrAwait } },
240
+ };
241
+ }
242
+
243
+ const LINK =
244
+ "https://site.example/api/auth/magic-link/verify?token=MLTOKEN&callbackURL=%2Faccount";
245
+
246
+ describe("makeMagicLinkOptions (#7435 § D2)", () => {
247
+ it("is sign-in only, five minutes, hashed at rest", () => {
248
+ const opts = makeMagicLinkOptions({ send: jest.fn() });
249
+ expect(MAGIC_LINK_TTL_SECONDS).toBe(300);
250
+ expect(opts).toMatchObject({
251
+ disableSignUp: true,
252
+ expiresIn: 300,
253
+ storeToken: "hashed",
254
+ });
255
+ expect(typeof opts.sendMagicLink).toBe("function");
256
+ // allowedAttempts is deliberately unset — the plugin warns on any value.
257
+ expect(opts).not.toHaveProperty("allowedAttempts");
258
+ });
259
+ });
260
+
261
+ describe("sendMagicLink — existing users only, deferred (#7435 § D3, AC-2)", () => {
262
+ it("sends NOTHING for an unknown address, never backgrounds, and logs no address", async () => {
263
+ const send = jest.fn();
264
+ const { lines, log } = makeLog();
265
+ const { ctx, findUserByEmail, runInBackgroundOrAwait } = makeCtx(null);
266
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
267
+
268
+ await expect(
269
+ sendMagicLink({ email: "nobody@example.com", url: LINK, token: "MLTOKEN" }, ctx),
270
+ ).resolves.toBeUndefined();
271
+
272
+ expect(findUserByEmail).toHaveBeenCalledWith("nobody@example.com");
273
+ expect(runInBackgroundOrAwait).not.toHaveBeenCalled();
274
+ expect(send).not.toHaveBeenCalled();
275
+ expect(lines).toEqual([
276
+ { level: "info", fields: { kind: "magic-link", reason: "no_account" } },
277
+ ]);
278
+ const serialized = JSON.stringify(lines);
279
+ expect(serialized).not.toContain("nobody@example.com");
280
+ expect(serialized).not.toContain("MLTOKEN");
281
+ });
282
+
283
+ it("backgrounds ONE magic-link send for a known address and returns before it settles", async () => {
284
+ let settle!: (v: unknown) => void;
285
+ const send = jest.fn(
286
+ (_rawBody: string, _headers: Record<string, string>) =>
287
+ new Promise((resolve) => (settle = resolve)),
288
+ );
289
+ const { lines, log } = makeLog();
290
+ const { ctx, background, runInBackgroundOrAwait } = makeCtx({
291
+ id: "user_1",
292
+ email: "person@example.com",
293
+ });
294
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
295
+
296
+ await sendMagicLink(
297
+ { email: "Person@Example.com", url: LINK, token: "MLTOKEN" },
298
+ ctx,
299
+ );
300
+
301
+ // Returned while the transport promise is still pending: the endpoint's
302
+ // answer does not wait on the relay round-trip (no timing oracle).
303
+ expect(runInBackgroundOrAwait).toHaveBeenCalledTimes(1);
304
+ expect(lines.some((l) => l.fields.reason === "sent")).toBe(false);
305
+
306
+ expect(send).toHaveBeenCalledTimes(1);
307
+ const [rawBody] = send.mock.calls[0]!;
308
+ expect(JSON.parse(rawBody)).toEqual({
309
+ kind: "magic-link",
310
+ userId: "user_1",
311
+ // The STORED address, not the typed casing.
312
+ to: "person@example.com",
313
+ url: LINK,
314
+ });
315
+
316
+ settle({ ok: true });
317
+ await Promise.all(background);
318
+ expect(lines).toEqual([
319
+ {
320
+ level: "info",
321
+ fields: { userId: "user_1", kind: "magic-link", reason: "sent" },
322
+ },
323
+ ]);
324
+ const serialized = JSON.stringify(lines);
325
+ expect(serialized).not.toContain("person@example.com");
326
+ expect(serialized).not.toContain("MLTOKEN");
327
+ expect(serialized).not.toContain("magic-link/verify");
328
+ });
329
+
330
+ it("never throws, and sends nothing, when the user lookup fails", async () => {
331
+ const send = jest.fn();
332
+ const { lines, log } = makeLog();
333
+ const { ctx, runInBackgroundOrAwait } = makeCtx("throws");
334
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
335
+
336
+ await expect(
337
+ sendMagicLink({ email: "person@example.com", url: LINK, token: "MLTOKEN" }, ctx),
338
+ ).resolves.toBeUndefined();
339
+
340
+ expect(runInBackgroundOrAwait).not.toHaveBeenCalled();
341
+ expect(send).not.toHaveBeenCalled();
342
+ expect(lines).toEqual([
343
+ {
344
+ level: "warn",
345
+ fields: {
346
+ kind: "magic-link",
347
+ reason: "send_failed",
348
+ errorKind: "lookup_failed",
349
+ },
350
+ },
351
+ ]);
352
+ });
353
+
354
+ it("never throws, and sends nothing, when Better Auth passes no context", async () => {
355
+ const send = jest.fn();
356
+ const { lines, log } = makeLog();
357
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
358
+
359
+ await expect(
360
+ sendMagicLink({ email: "person@example.com", url: LINK, token: "MLTOKEN" }),
361
+ ).resolves.toBeUndefined();
362
+ expect(send).not.toHaveBeenCalled();
363
+ expect(lines[0]?.fields).toEqual({
364
+ kind: "magic-link",
365
+ reason: "send_failed",
366
+ errorKind: "lookup_failed",
367
+ });
368
+ });
369
+ });
@@ -4,10 +4,10 @@
4
4
  * `isAuthPath()` is the ONE list the locale layout branches on and the five
5
5
  * `(auth)` page segments are named from, so the layout and the routes cannot
6
6
  * drift. Covers bare paths, locale-prefixed paths, near misses, nested paths,
7
- * and the absent-header fail-safe.
7
+ * and the absent-header fail-safe — and the same for `isAccountPath()` (#7430).
8
8
  */
9
9
 
10
- import { AUTH_SEGMENTS, isAuthPath } from "../auth-routes";
10
+ import { AUTH_SEGMENTS, isAccountPath, isAuthPath } from "../auth-routes";
11
11
 
12
12
  const LOCALES = ["en", "fr", "de"];
13
13
 
@@ -72,3 +72,42 @@ describe("isAuthPath", () => {
72
72
  expect(isAuthPath(undefined, LOCALES)).toBe(false);
73
73
  });
74
74
  });
75
+
76
+ describe("isAccountPath (#7430, spec 7423 D4)", () => {
77
+ it("matches the account root, bare and locale-prefixed", () => {
78
+ expect(isAccountPath("/account", LOCALES)).toBe(true);
79
+ expect(isAccountPath("/en/account", LOCALES)).toBe(true);
80
+ expect(isAccountPath("/fr/account", LOCALES)).toBe(true);
81
+ });
82
+
83
+ it("matches every path under /account", () => {
84
+ expect(isAccountPath("/account/settings", LOCALES)).toBe(true);
85
+ expect(isAccountPath("/account/support", LOCALES)).toBe(true);
86
+ expect(isAccountPath("/fr/account/inbox/purchase/fc_1", LOCALES)).toBe(true);
87
+ });
88
+
89
+ it("matches a trailing slash and a query string", () => {
90
+ expect(isAccountPath("/account/", LOCALES)).toBe(true);
91
+ expect(isAccountPath("/account?tab=all", LOCALES)).toBe(true);
92
+ });
93
+
94
+ it("does not match a near miss or a marketing path", () => {
95
+ expect(isAccountPath("/accounts", LOCALES)).toBe(false);
96
+ expect(isAccountPath("/about", LOCALES)).toBe(false);
97
+ expect(isAccountPath("/blog/account", LOCALES)).toBe(false);
98
+ });
99
+
100
+ it("does not match an auth path", () => {
101
+ expect(isAccountPath("/sign-in?next=/account", LOCALES)).toBe(false);
102
+ });
103
+
104
+ it("does not strip a prefix that is not an enabled locale", () => {
105
+ expect(isAccountPath("/es/account", LOCALES)).toBe(false);
106
+ });
107
+
108
+ it("fails to NOT-an-account-path for an empty or absent pathname", () => {
109
+ expect(isAccountPath("", LOCALES)).toBe(false);
110
+ expect(isAccountPath(undefined, LOCALES)).toBe(false);
111
+ expect(isAccountPath(null, LOCALES)).toBe(false);
112
+ });
113
+ });
@@ -1,13 +1,221 @@
1
- // account-config.ts — Experiment registry for the /account Results section (#4984)
2
- //
3
- // Mirrors `nav-config.ts`: the OSS template declares NO experiments — nothing
4
- // in `apps/web` runs a diagnostic today, so this list ships empty and the
5
- // Results section renders its empty state. A consuming site lists the
6
- // `experimentKey`s of the diagnostics it runs (the `key` of each
7
- // `waitlistExperiment` it registers) so `/account` can read the signed-in
8
- // respondent's own newest signup for each via `waitlistSignups.me(key)`
9
- // (`GET /v1/waitlist-signups/me?experimentKey=…`, #4731 D5). Keys the caller
10
- // has no signup for come back 404 and are skipped, so listing every
11
- // experiment a site runs is safe.
1
+ // account-config.ts — what the /account area is made of (#4984, #7429).
2
+ //
3
+ // The module contribution contract (spec 7423 D8, D8a): a module extends a
4
+ // site's account area by adding entries to the three arrays below — Inbox
5
+ // item kinds, User-settings sections and For-you sources — never a rail
6
+ // entry. The contract's types live in `@working-theory/templates/account`; a
7
+ // kind's label is a message key in the `account` namespace, and a For-you
8
+ // item's strings are built through the context's `t`.
9
+ //
10
+ // `accountExperimentKeys` — mirrors `nav-config.ts`: the OSS template
11
+ // declares NO experiments — nothing in `apps/web` runs a diagnostic today, so
12
+ // this list ships empty and the Inbox carries no `result` rows. A consuming
13
+ // site lists the `experimentKey`s of the diagnostics it runs (the `key` of
14
+ // each `waitlistExperiment` it registers) so `/account` can read the
15
+ // signed-in respondent's own newest signup for each via
16
+ // `waitlistSignups.me(key)` (`GET /v1/waitlist-signups/me?experimentKey=…`,
17
+ // #4731 D5). Keys the caller has no signup for come back 404 and are
18
+ // skipped, so listing every experiment a site runs is safe.
19
+ //
20
+ // The base contributions (D9, amended 2026-10-01 by D6a/D8a — #7448) use
21
+ // existing reads only, each with the caller's cookie forwarded (`ctx.init`):
22
+ //
23
+ // - Inbox kinds `result` and `purchase` — rows of the Recents and All tabs.
24
+ // - For-you sources `email-unverified` and `payment-due` — needs-you items
25
+ // with an Open action to User settings (General, Billing), never Inbox
26
+ // rows. Neither read carries the time the condition began, so their time
27
+ // line is the date of the read rather than an invented "Waiting N".
28
+ //
29
+ // A 404 means "nothing for this caller" — no signup / no user row / commerce
30
+ // module off — and loads `[]`; so does a 401/403 while the email is
31
+ // unverified (the `email-unverified` item already says so). Any other
32
+ // failure rejects — a 401/403 for a verified email included — so the area
33
+ // shows an error row with Retry instead of an empty Inbox (D12). Scheduled
34
+ // (the old Upcoming) stays empty in the base area; a module fills it.
35
+
36
+ import {
37
+ flowCheckouts,
38
+ subscription,
39
+ waitlistSignups,
40
+ type FlowCheckoutMeItem,
41
+ type WaitlistSignupMeResponse,
42
+ } from "@working-theory/api-client";
43
+ import type {
44
+ AccountForYouSource,
45
+ AccountInboxKind,
46
+ AccountSettingsSection,
47
+ AccountUser,
48
+ } from "@working-theory/templates/account";
49
+ import { localeHref } from "@working-theory/ui";
50
+ import { formatMoney, formatNumber } from "@working-theory/web-i18n";
51
+
52
+ import { unwrapApiResult } from "./server-api";
12
53
 
13
54
  export const accountExperimentKeys: string[] = [];
55
+
56
+ // ── Reads ─────────────────────────────────────────────────────────────────────
57
+
58
+ /** Structural mirror of `@working-theory/api-client`'s `ApiResult<T>` (not re-exported). */
59
+ type ApiResult<T> =
60
+ { ok: true; body: T } | { ok: false; status: number; body: unknown };
61
+
62
+ /**
63
+ * The body, or `null` when the status means "nothing for this caller" (see
64
+ * the header); any other failure throws `ApiError`.
65
+ */
66
+ async function read<T>(
67
+ result: Promise<ApiResult<T>>,
68
+ user: AccountUser,
69
+ ): Promise<T | null> {
70
+ const res = await result;
71
+ if (!res.ok) {
72
+ if (res.status === 404) return null;
73
+ if (!user.emailVerified && (res.status === 401 || res.status === 403))
74
+ return null;
75
+ }
76
+ return unwrapApiResult(Promise.resolve(res));
77
+ }
78
+
79
+ function formatWhen(iso: string, locale: string): string {
80
+ return new Intl.DateTimeFormat(locale, {
81
+ dateStyle: "medium",
82
+ timeStyle: "short",
83
+ }).format(new Date(iso));
84
+ }
85
+
86
+ // ── Base Inbox kinds (D9) ─────────────────────────────────────────────────────
87
+
88
+ const result: AccountInboxKind<WaitlistSignupMeResponse> = {
89
+ kind: "result",
90
+ label: "results.heading",
91
+ section: "updates",
92
+ async load({ init, user }) {
93
+ // One failed key fails the kind (D12) — never a partial list passed off as whole.
94
+ const signups = await Promise.all(
95
+ accountExperimentKeys.map((key) =>
96
+ read(waitlistSignups.me(key, init), user),
97
+ ),
98
+ );
99
+ return signups
100
+ .filter((s): s is WaitlistSignupMeResponse => s !== null)
101
+ .map((s) => ({
102
+ id: s.experimentKey,
103
+ title: s.experimentKey,
104
+ at: s.completedAt,
105
+ data: s,
106
+ }));
107
+ },
108
+ renderDetail({ data }, { locale, t }) {
109
+ return {
110
+ fields: {
111
+ heading: t("results.heading"),
112
+ items: [
113
+ { label: t("results.experiment"), value: data.experimentKey },
114
+ { label: t("results.waitlist"), value: data.waitlistKey },
115
+ {
116
+ label: t("results.tier"),
117
+ value: data.tier ?? t("results.unavailable"),
118
+ },
119
+ {
120
+ label: t("results.score"),
121
+ value:
122
+ data.score === null
123
+ ? t("results.unavailable")
124
+ : formatNumber(data.score, locale),
125
+ },
126
+ {
127
+ label: t("results.completedAt"),
128
+ value: formatWhen(data.completedAt, locale),
129
+ },
130
+ ],
131
+ },
132
+ };
133
+ },
134
+ };
135
+
136
+ const purchase: AccountInboxKind<FlowCheckoutMeItem> = {
137
+ kind: "purchase",
138
+ label: "purchases.heading",
139
+ section: "updates",
140
+ async load({ init, user }) {
141
+ const checkouts = (await read(flowCheckouts.me(init), user)) ?? [];
142
+ return checkouts.map((c) => ({
143
+ id: c.id,
144
+ title: c.offerKey,
145
+ at: c.completedAt ?? c.createdAt,
146
+ data: c,
147
+ }));
148
+ },
149
+ renderDetail({ data }, { locale, t }) {
150
+ return {
151
+ status: data.status,
152
+ fields: {
153
+ heading: t("purchases.heading"),
154
+ items: [
155
+ { label: t("purchases.offer"), value: data.offerKey },
156
+ {
157
+ label: t("purchases.amount"),
158
+ value: formatMoney(data.amountMinor, data.currency, locale),
159
+ },
160
+ { label: t("purchases.period"), value: data.period },
161
+ {
162
+ label: t("purchases.date"),
163
+ value: formatWhen(data.completedAt ?? data.createdAt, locale),
164
+ },
165
+ ],
166
+ },
167
+ };
168
+ },
169
+ };
170
+
171
+ // ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
172
+
173
+ const emailUnverified: AccountForYouSource = {
174
+ id: "email-unverified",
175
+ async load({ locale, user, t }) {
176
+ if (user.emailVerified) return [];
177
+ return [
178
+ {
179
+ id: "email-unverified",
180
+ type: { id: "email", label: t("profile.email"), tone: "needs-you" },
181
+ title: t("profile.unverified"),
182
+ time: { kind: "at", at: new Date().toISOString() },
183
+ action: { kind: "open", href: localeHref(locale, "/account/settings#general") },
184
+ dismissible: true,
185
+ },
186
+ ];
187
+ },
188
+ };
189
+
190
+ const paymentDue: AccountForYouSource = {
191
+ id: "payment-due",
192
+ async load({ init, locale, t, user }) {
193
+ const sub = await read(subscription.me(init), user);
194
+ // The API's own `actionRequired` decides — the status is never re-derived.
195
+ if (!sub?.actionRequired) return [];
196
+ return [
197
+ {
198
+ id: "payment-due",
199
+ type: { id: "payment", label: t("subscription.action"), tone: "needs-you" },
200
+ title: t("subscription.headline"),
201
+ time: { kind: "at", at: new Date().toISOString() },
202
+ action: { kind: "open", href: localeHref(locale, "/account/settings#billing") },
203
+ dismissible: true,
204
+ },
205
+ ];
206
+ },
207
+ };
208
+
209
+ // ── Contributions ─────────────────────────────────────────────────────────────
210
+
211
+ /** Inbox item kinds, in load order. A module appends its own. */
212
+ export const accountInboxKinds: AccountInboxKind[] = [result, purchase];
213
+
214
+ /** User-settings sections a module adds after the base ones. None in the base area. */
215
+ export const accountSettingsSections: AccountSettingsSection[] = [];
216
+
217
+ /** For-you sources, in load order. A module appends its own. */
218
+ export const accountForYouSources: AccountForYouSource[] = [
219
+ emailUnverified,
220
+ paymentDue,
221
+ ];
@@ -37,11 +37,12 @@ import { createHmac } from "node:crypto";
37
37
 
38
38
  import { authEmail } from "@working-theory/api-client";
39
39
 
40
- /** The three fixed kinds the relay will send. A caller picks no copy. */
40
+ /** The four fixed kinds the relay will send. A caller picks no copy. */
41
41
  export type AuthEmailKind =
42
42
  | "password-reset"
43
43
  | "email-verification"
44
- | "password-changed";
44
+ | "password-changed"
45
+ | "magic-link";
45
46
 
46
47
  export interface AuthEmailPayload {
47
48
  kind: AuthEmailKind;
@@ -313,6 +314,107 @@ export function makeSendPasswordChangedNotice(deps: DispatchDeps) {
313
314
  };
314
315
  }
315
316
 
317
+ /**
318
+ * Magic-link token lifetime (#7435 § D2). Five minutes — the library default,
319
+ * and the number `packages/email`'s magic-link template already promises ("This
320
+ * link expires in 5 minutes"). A longer window would make the email's copy
321
+ * false.
322
+ */
323
+ export const MAGIC_LINK_TTL_SECONDS = 300;
324
+
325
+ /**
326
+ * The narrow slice of Better Auth's endpoint context the magic-link callback
327
+ * touches. Method shorthand, for the reason `AuthEmailPrisma` gives: the real
328
+ * `GenericEndpointContext` satisfies it structurally, and a test passes a
329
+ * plain double.
330
+ */
331
+ export interface MagicLinkEndpointContext {
332
+ context: {
333
+ internalAdapter: {
334
+ findUserByEmail(
335
+ email: string,
336
+ ): Promise<{ user: { id: string; email: string } } | null>;
337
+ };
338
+ runInBackgroundOrAwait(promise: Promise<unknown> | void): unknown;
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Better Auth's `sendMagicLink` (#7435 § D3).
344
+ *
345
+ * WHY THE CALLBACK CHECKS FOR THE USER. The plugin's `POST /sign-in/magic-link`
346
+ * calls this for EVERY address typed into the form; `disableSignUp` is read
347
+ * only by the verify handler. So without this lookup, the plugin would mail a
348
+ * working-looking link to any address at all. The lookup is the same
349
+ * lowercasing `findUserByEmail` the verify handler uses, so "exists" means the
350
+ * same thing at both ends.
351
+ *
352
+ * WHY THE SEND IS DEFERRED. The plugin awaits this callback inline. A relay
353
+ * round-trip for known addresses and nothing for unknown ones would answer the
354
+ * same body at two different latencies — the existence oracle. So both
355
+ * branches cost one indexed read; the send goes to `runInBackgroundOrAwait`,
356
+ * which `auth.ts`'s `backgroundTasks.handler` runs after the response.
357
+ *
358
+ * NOTHING HERE THROWS, so a known and an unknown address both reach the
359
+ * plugin's `{ status: true }`.
360
+ */
361
+ function makeSendMagicLink(deps: DispatchDeps) {
362
+ const { send } = deps;
363
+ const log = deps.log ?? defaultLog;
364
+ return async function sendMagicLink(
365
+ data: { email: string; url: string; token: string },
366
+ ctx?: MagicLinkEndpointContext,
367
+ ): Promise<void> {
368
+ let found: { user: { id: string; email: string } } | null;
369
+ try {
370
+ if (!ctx) throw new Error("no endpoint context");
371
+ found = await ctx.context.internalAdapter.findUserByEmail(data.email);
372
+ } catch {
373
+ log.warn({
374
+ kind: "magic-link",
375
+ reason: "send_failed",
376
+ errorKind: "lookup_failed",
377
+ });
378
+ return;
379
+ }
380
+
381
+ if (!found) {
382
+ // No address in the line — the PII boundary holds for unknown addresses
383
+ // too, and they are the ones an enumerator types.
384
+ log.info({ kind: "magic-link", reason: "no_account" });
385
+ return;
386
+ }
387
+
388
+ const { user } = found;
389
+ // Not awaited: `runInBackgroundOrAwait` never rejects, and `dispatch`
390
+ // swallows its own failures into a log line.
391
+ void ctx.context.runInBackgroundOrAwait(
392
+ dispatch(
393
+ { kind: "magic-link", userId: user.id, to: user.email, url: data.url },
394
+ { send, log },
395
+ ),
396
+ );
397
+ };
398
+ }
399
+
400
+ /**
401
+ * The options `auth.ts` hands Better Auth's `magicLink()` plugin (#7435 § D2).
402
+ *
403
+ * `disableSignUp: true` is defence in depth behind § D3: even a leaked token
404
+ * for an address with no account cannot create one. `storeToken: "hashed"`
405
+ * keeps a database read from yielding a live sign-in token. `allowedAttempts`
406
+ * is left unset — the plugin consumes atomically regardless and warns on any
407
+ * value but 1.
408
+ */
409
+ export function makeMagicLinkOptions(deps: DispatchDeps) {
410
+ return {
411
+ disableSignUp: true,
412
+ expiresIn: MAGIC_LINK_TTL_SECONDS,
413
+ storeToken: "hashed" as const,
414
+ sendMagicLink: makeSendMagicLink(deps),
415
+ };
416
+ }
417
+
316
418
  /** The production transport — the canonical client, never a bare fetch. */
317
419
  export const authEmailTransport: AuthEmailTransport = (rawBody, headers) =>
318
420
  authEmail.send(rawBody, headers);
@@ -60,11 +60,12 @@ export function isResetAvailable(): boolean {
60
60
  * "google" — Google OAuth (GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET)
61
61
  * "github" — GitHub OAuth (GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET)
62
62
  *
63
- * "email" is reported as active when AUTH_EMAIL_SERVER + AUTH_EMAIL_FROM are
64
- * set so existing consumers can detect intent, but Better Auth core does not
65
- * ship a built-in magic-link provider — the wiring is deferred to a follow-up
66
- * (see spec §7 "Out of Scope"). Treat "email" here as an env-presence signal,
67
- * not a wired provider.
63
+ * "email" is the PASSWORD method's presence flag: AUTH_EMAIL_SERVER +
64
+ * AUTH_EMAIL_FROM (#5962 § D2).
65
+ *
66
+ * "magic-link" is the emailed sign-in link (#7435), reported last, exactly
67
+ * when `magicLinkSendConfigured()` holds. `auth.ts`, `/sign-in` and the
68
+ * `(authed)` gate all read it from here and nowhere else.
68
69
  */
69
70
  export function getActiveProviderIds(): string[] {
70
71
  const active: string[] = [];
@@ -81,9 +82,40 @@ export function getActiveProviderIds(): string[] {
81
82
  active.push("email");
82
83
  }
83
84
 
85
+ if (magicLinkSendConfigured()) {
86
+ active.push("magic-link");
87
+ }
88
+
84
89
  return active;
85
90
  }
86
91
 
92
+ /**
93
+ * True when this deployment can SAFELY email a sign-in link (#7435 § D1).
94
+ * Four conjuncts:
95
+ *
96
+ * AUTH_EMAIL_WEBHOOK_SECRET the send seam — without it the apps/api relay
97
+ * + AUTH_EMAIL_FROM answers 503 and no link is ever delivered.
98
+ * BETTER_AUTH_URL unset means Better Auth derives the emailed
99
+ * link's host from the REQUEST (`auth.ts`
100
+ * § D13). A sign-in credential minted on an
101
+ * attacker-chosen host is an account takeover.
102
+ * BETTER_AUTH_SECRET unset means the dev fallback — a string
103
+ * published in this repository — signs the
104
+ * session the link mints.
105
+ *
106
+ * `AUTH_EMAIL_SERVER` is deliberately NOT a conjunct: it is the password
107
+ * method's presence flag, and a site may run with password sign-in off and
108
+ * the email link on. No new variable and no separate opt-in flag.
109
+ */
110
+ export function magicLinkSendConfigured(): boolean {
111
+ return Boolean(
112
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET &&
113
+ process.env.AUTH_EMAIL_FROM &&
114
+ process.env.BETTER_AUTH_URL &&
115
+ process.env.BETTER_AUTH_SECRET,
116
+ );
117
+ }
118
+
87
119
  /**
88
120
  * Returns true when at least one provider is configured.
89
121
  * Used in middleware to skip Better Auth route setup entirely when no provider