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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- 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
|
|
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 —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|