create-ailk 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +50 -8
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cutover.d.ts +6 -1
  7. package/dist/cutover.js +23 -6
  8. package/dist/export-content.d.ts +6 -3
  9. package/dist/export-content.js +79 -10
  10. package/dist/language-import.d.ts +16 -3
  11. package/dist/language-import.js +27 -11
  12. package/dist/lib/import-mdx.d.ts +42 -0
  13. package/dist/lib/import-mdx.js +584 -65
  14. package/dist/lib/render-comparison.d.ts +51 -0
  15. package/dist/lib/render-comparison.js +205 -6
  16. package/dist/mdx-braces.d.ts +73 -0
  17. package/dist/mdx-braces.js +285 -0
  18. package/dist/migrate-content.d.ts +1 -1
  19. package/dist/migrate-content.js +4 -2
  20. package/dist/programmatic.js +2 -8
  21. package/dist/seed-flows.d.ts +1 -1
  22. package/dist/seed-flows.js +9 -4
  23. package/dist/site-manifest.js +6 -38
  24. package/dist/site-roster.d.ts +46 -3
  25. package/dist/site-roster.js +124 -4
  26. package/dist/surfaces.d.ts +8 -0
  27. package/dist/surfaces.js +17 -0
  28. package/dist/update.d.ts +21 -1
  29. package/dist/update.js +142 -11
  30. package/package.json +3 -3
  31. package/templates/.github/workflows/ci.yml +2 -0
  32. package/templates/apps/api/CLAUDE.md +7 -5
  33. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  34. package/templates/apps/mcp/.env.example +7 -1
  35. package/templates/apps/web/__tests__/setup.ts +9 -0
  36. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  37. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  38. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  39. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  40. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  43. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  44. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  45. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  46. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  47. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  48. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  49. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  50. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  51. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  52. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  53. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  54. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  55. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  56. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  57. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  58. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  59. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  60. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  61. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  67. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  68. package/templates/apps/web/auth.ts +22 -6
  69. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  70. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  71. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
  72. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
  73. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  74. package/templates/apps/web/lib/account-config.ts +52 -14
  75. package/templates/apps/web/lib/auth-email.ts +87 -5
  76. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  77. package/templates/apps/web/messages/en.json +26 -1
  78. package/templates/apps/web/src/lib/routes.ts +5 -3
  79. package/templates/database/seed.ts +7 -7
  80. package/templates/package.json +2 -1
  81. package/templates/tools/body-heading.ts +440 -0
  82. package/templates/tools/check-mdx-body.ts +123 -0
  83. package/templates/tools/mdx-braces.ts +303 -0
  84. package/versions.json +48 -48
  85. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  86. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -0,0 +1,151 @@
1
+ // oss: false
2
+ "use server";
3
+
4
+ /**
5
+ * confirmLeadEmailAction — a person's confirm of a magnet Lead's emailed link
6
+ * (#10826, spec docs/specs/10826-the-four-conversions-as-completion-spec.md
7
+ * D5). The one place that link's nonce is spent.
8
+ *
9
+ * The page's GET (`verify/page.tsx`) renders a button and writes nothing, so a
10
+ * mail scanner or a link preview that fetches the link verifies nothing; only
11
+ * a person's POST reaches here. In order:
12
+ *
13
+ * 1. Every bound argument is re-validated (SEC-10826-6) — a direct call can
14
+ * set any of them: `slug` against `flowSlugSchema`, `leadId` against the
15
+ * id shape, `nonce` at 1–256 characters, `locale` against the enabled
16
+ * locales. A refusal makes no API call.
17
+ * 2. The per-visitor limit (5 per 10 minutes) and the bot check, before any
18
+ * API call, because the API sees only this site's server address.
19
+ * 3. `flows.verifyLead`, forwarding the visitor's address (SEC-10826-8).
20
+ * 4. A redirect: a `user` magnet's verified Lead to the sign-up landing
21
+ * (`accountInviteHref`), a `lead-link` landing when it is a plain
22
+ * site-relative path (SEC-10826-5), else the confirmed card; everything
23
+ * else to the error card.
24
+ *
25
+ * The nonce is never in a redirect URL and never logged here. No MCP twin
26
+ * (`WEB_ONLY_ACTIONS` in `apps/mcp/__tests__/parity.test.ts`): it spends an
27
+ * address credential only the mailbox holder has.
28
+ */
29
+
30
+ import { flows } from "@working-theory/api-client";
31
+ import { localeHref } from "@working-theory/ui";
32
+ import { flowSlugSchema } from "@working-theory/validation";
33
+ import { headers } from "next/headers";
34
+ import { redirect } from "next/navigation";
35
+
36
+ import { accountInviteHref } from "../../../../lib/account-invite";
37
+ import { runBotCheck } from "../../../../lib/bot-check";
38
+ import { takeVisitorLimit } from "../../../../src/lib/rate-limit";
39
+
40
+ const CONFIRM_LIMIT = 5;
41
+ const CONFIRM_WINDOW_MS = 10 * 60 * 1000;
42
+ const NO_HEADERS = { get: () => null };
43
+
44
+ const LEAD_ID_RE = /^lead_[0-9a-f-]{36}$/;
45
+ const NONCE_MAX = 256;
46
+
47
+ const DEFAULT_LOCALE = "en";
48
+ const ENABLED_LOCALES = (
49
+ process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE
50
+ ).split(",");
51
+
52
+ /**
53
+ * SEC-10826-5 — a landing is followed only when it is one `/` followed by
54
+ * neither `/` nor a backslash, with no backslash anywhere: a site-relative
55
+ * path, never `//host`, `/\host` or an absolute URL.
56
+ */
57
+ function isSitePath(landing: string): boolean {
58
+ // One leading `/`, then no `/` or backslash; no backslash, whitespace or
59
+ // control character anywhere — a browser strips TAB/CR/LF, so `/\t/host`
60
+ // would otherwise become `//host` (security review, #10826).
61
+ return /^\/(?![/\\])[^\\\s\p{Cc}]*$/u.test(landing);
62
+ }
63
+
64
+ type Answer = { kind: "confirmed"; to: string | null } | { kind: "refused" };
65
+
66
+ async function confirm(
67
+ slug: string,
68
+ leadId: string,
69
+ nonce: string,
70
+ locale: string,
71
+ ): Promise<Answer> {
72
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
73
+ try {
74
+ requestHeaders = await headers();
75
+ } catch {
76
+ // No request scope to read — the shared unknown-visitor bucket.
77
+ }
78
+ const limit = takeVisitorLimit(
79
+ "flowverify",
80
+ requestHeaders ?? NO_HEADERS,
81
+ CONFIRM_LIMIT,
82
+ CONFIRM_WINDOW_MS,
83
+ );
84
+ if (!limit.allowed) return { kind: "refused" };
85
+
86
+ const bot = await runBotCheck({
87
+ source: "flow-verify",
88
+ target: "confirmLeadEmailAction",
89
+ scope: "write",
90
+ });
91
+ if (bot !== "pass") return { kind: "refused" };
92
+
93
+ const visitorIp =
94
+ requestHeaders?.get("x-forwarded-for") ?? requestHeaders?.get("x-real-ip");
95
+ try {
96
+ const res = visitorIp
97
+ ? await flows.verifyLead(
98
+ slug,
99
+ { leadId, nonce },
100
+ { headers: { "x-forwarded-for": visitorIp } },
101
+ )
102
+ : await flows.verifyLead(slug, { leadId, nonce });
103
+ if (!res.ok) return { kind: "refused" };
104
+ const { outcome, landing, signUpEmail } = res.body;
105
+ if (outcome !== "verified" && outcome !== "already_verified")
106
+ return { kind: "refused" };
107
+ if (typeof signUpEmail === "string") {
108
+ const href = accountInviteHref(locale, signUpEmail);
109
+ if (href !== null) return { kind: "confirmed", to: href };
110
+ }
111
+ if (typeof landing === "string" && isSitePath(landing)) {
112
+ return { kind: "confirmed", to: localeHref(locale, landing) };
113
+ }
114
+ return { kind: "confirmed", to: null };
115
+ } catch {
116
+ return { kind: "refused" };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Bound by the page — `confirmLeadEmailAction.bind(null, slug, leadId, nonce,
122
+ * locale)` — so no hidden input carries the nonce; the form's own data is
123
+ * ignored.
124
+ */
125
+ export async function confirmLeadEmailAction(
126
+ slug: string,
127
+ leadId: string,
128
+ nonce: string,
129
+ locale = DEFAULT_LOCALE,
130
+ ): Promise<never> {
131
+ const slugOk = flowSlugSchema.safeParse(slug).success;
132
+ const localeOk = ENABLED_LOCALES.includes(locale);
133
+ const safeLocale = localeOk ? locale : DEFAULT_LOCALE;
134
+ // An unreadable slug has no confirm page of its own to land on.
135
+ const page = slugOk ? `/flows/${slug}/verify` : "/";
136
+ const errorHref = localeHref(safeLocale, `${page}?error=invalid`);
137
+
138
+ const argumentsOk =
139
+ slugOk &&
140
+ localeOk &&
141
+ typeof leadId === "string" &&
142
+ LEAD_ID_RE.test(leadId) &&
143
+ typeof nonce === "string" &&
144
+ nonce.length >= 1 &&
145
+ nonce.length <= NONCE_MAX;
146
+ if (!argumentsOk) redirect(errorHref);
147
+
148
+ const answer = await confirm(slug, leadId, nonce, safeLocale);
149
+ if (answer.kind === "refused") redirect(errorHref);
150
+ redirect(answer.to ?? localeHref(safeLocale, `${page}?confirmed=1`));
151
+ }
@@ -5,7 +5,7 @@
5
5
  * BookingHandOff — `/meet/confirmed`'s booked time and Slack next step
6
6
  * (#9853 D10). The booker writes one `sessionStorage` item before it
7
7
  * navigates here (`BOOKING_CONFIRMATION_STORAGE_KEY`); this reads it once and
8
- * removes it, the `/thanks` page's `AccountInviteLink` pattern, so the page's
8
+ * removes it, the read-once hand-off pattern, so the page's
9
9
  * address carries nothing about the booking and a later visit shows nothing.
10
10
  *
11
11
  * It renders only an item written for this page (`to`'s path, without its
@@ -32,6 +32,9 @@ jest.mock("next/navigation", () => ({
32
32
 
33
33
  import OfferingPricingPage, { dynamic, generateMetadata } from "../page";
34
34
 
35
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
36
+ process.env.CONTENT_ADAPTER = "mdx";
37
+
35
38
  const SLUG = "website-rebuild";
36
39
  const SITE_EMAIL = "hello@example.com";
37
40
 
@@ -27,6 +27,9 @@ import {
27
27
 
28
28
  import PricingPage, { generateMetadata } from "../page";
29
29
 
30
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
31
+ process.env.CONTENT_ADAPTER = "mdx";
32
+
30
33
  const SITE_EMAIL = "hello@example.com";
31
34
  const HOSTILE = "</script><script>alert(1)</script>";
32
35
 
@@ -15,6 +15,9 @@ import { act, render, screen, within } from "@testing-library/react";
15
15
 
16
16
  import PricingPage from "../page";
17
17
 
18
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
19
+ process.env.CONTENT_ADAPTER = "mdx";
20
+
18
21
  const PLANS = [
19
22
  {
20
23
  key: "basic",
@@ -27,6 +27,9 @@ import { TOPOLOGY } from "../../../../lib/topology.generated";
27
27
  import * as pricingPage from "../page";
28
28
  import PricingPage, { generateMetadata } from "../page";
29
29
 
30
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
31
+ process.env.CONTENT_ADAPTER = "mdx";
32
+
30
33
  const PLANS = [
31
34
  {
32
35
  key: "basic",
@@ -51,6 +51,9 @@ import { notFound } from "next/navigation";
51
51
  import ProjectsPage from "../page";
52
52
  import ProjectSlugPage from "../[slug]/page";
53
53
 
54
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
55
+ process.env.CONTENT_ADAPTER = "mdx";
56
+
54
57
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
55
58
 
56
59
  // ── Shared stub helpers ────────────────────────────────────────────────────────
@@ -31,6 +31,9 @@ import ServicesPage from "../page";
31
31
  import ServiceSlugPage from "../[slug]/page";
32
32
  import * as pageRenderer from "@working-theory/page-renderer";
33
33
 
34
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
35
+ process.env.CONTENT_ADAPTER = "mdx";
36
+
34
37
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
35
38
 
36
39
  // ── Shared stub helpers ────────────────────────────────────────────────────────
@@ -31,6 +31,9 @@ import TeamPage from "../page";
31
31
  import TeamMemberPage from "../[slug]/page";
32
32
  import * as pageRenderer from "@working-theory/page-renderer";
33
33
 
34
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
35
+ process.env.CONTENT_ADAPTER = "mdx";
36
+
34
37
  type JsonLd = Record<string, unknown>;
35
38
 
36
39
  function jsonLdScripts(container: HTMLElement): JsonLd[] {
@@ -37,20 +37,6 @@ jest.mock("@working-theory/ui", () => ({
37
37
  }),
38
38
  }));
39
39
 
40
- // #8771 D6 — the page reads the link label on the server and renders the client
41
- // component that owns the hand-off; this file asserts only that wiring.
42
- jest.mock("next-intl/server", () => ({
43
- getTranslations: jest.fn().mockResolvedValue((key: string) => `label:${key}`),
44
- }));
45
- // eslint-disable-next-line no-restricted-syntax -- the page's wiring is the unit under test; the component has its own suite
46
- jest.mock("../AccountInviteLink", () => ({
47
- AccountInviteLink: ({ thanksSlug, label }: { thanksSlug: string; label: string }) =>
48
- React.createElement("div", {
49
- "data-testid": "account-invite-link",
50
- "data-slug": thanksSlug,
51
- "data-label": label,
52
- }),
53
- }));
54
40
 
55
41
  import { existsSync, readFileSync } from "node:fs";
56
42
  import { join } from "node:path";
@@ -111,7 +97,7 @@ describe("ThankYouSlugPage — detail route", () => {
111
97
  expect(mockNotFound).toHaveBeenCalled();
112
98
  });
113
99
 
114
- it("renders AccountInviteLink with the route's slug and the contact label (#8771)", async () => {
100
+ it("renders no account hand-off: a user magnet's walk lands on sign-up itself (#10826 D6)", async () => {
115
101
  const getMdxSource = jest.fn().mockResolvedValue(makeThankYouSource("flow-thanks"));
116
102
 
117
103
  const element = await ThankYouSlugPage({
@@ -120,11 +106,8 @@ describe("ThankYouSlugPage — detail route", () => {
120
106
  });
121
107
  const { container } = render(element);
122
108
 
123
- const link = container.querySelector('[data-testid="account-invite-link"]');
124
- // Inside the page's own <main>, not after it (#8771 design review).
125
- expect(link?.closest("main")).not.toBeNull();
126
- expect(link).toHaveAttribute("data-slug", "flow-thanks");
127
- expect(link).toHaveAttribute("data-label", "label:trackInAccount");
109
+ expect(container.querySelector('[data-testid="account-invite-link"]')).toBeNull();
110
+ expect(existsSync(join(__dirname, "..", "AccountInviteLink.tsx"))).toBe(false);
128
111
  });
129
112
 
130
113
  it("stays a server page: the module has no client directive (#8771)", () => {
@@ -8,11 +8,8 @@
8
8
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
9
9
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
10
10
  import type { Metadata } from "next";
11
- import { getTranslations } from "next-intl/server";
12
11
  import type { ReactElement } from "react";
13
12
 
14
- import { AccountInviteLink } from "./AccountInviteLink";
15
-
16
13
  interface Props {
17
14
  params: Promise<{ locale: string; slug: string }>;
18
15
  getMdxSource?: typeof defaultGetMdxSource;
@@ -30,17 +27,13 @@ export default async function Page({
30
27
  getMdxSource,
31
28
  }: Props): Promise<ReactElement> {
32
29
  const { locale, slug } = await params;
33
- // #8771 D6 — the magnet walk's "Track this in your account" hand-off. The
34
- // page stays a server page; the client component decides if it has a link.
35
- // It renders through `trailing`, inside the page's own <main>, so it keeps
36
- // the page's landmark and layout rather than sitting after `</main>`.
37
- const t = await getTranslations({ locale, namespace: "contact" });
30
+ // #10826 D6 — no account hand-off: a `user` magnet's walk lands on sign-up
31
+ // itself, so the walk writes no `/thanks` link any more.
38
32
  return renderPage({
39
33
  type: "ThankYouPage",
40
34
  slug,
41
35
  locale,
42
36
  populatedDescriptionTestId: "thank-you-description",
43
- trailing: <AccountInviteLink thanksSlug={slug} label={t("trackInAccount")} />,
44
37
  ...(getMdxSource !== undefined && { getMdxSource }),
45
38
  });
46
39
  }
@@ -63,14 +63,23 @@ import {
63
63
  const RESET_TOKEN_TTL_SECONDS = 3600;
64
64
 
65
65
  /**
66
- * Verification-token lifetime (§ D11). Twenty-four hours. Unlike the reset
67
- * token this is NOT a credential — it is a signed JWT whose blast radius is
68
- * `emailVerified: true` on one row — so its failure mode is a person who
69
- * opened the mail next morning having to ask again. The library's 3600 s
70
- * default is too short for that; a day is not.
66
+ * Verification-link lifetime (§ D11). Twenty-four hours — the API's
67
+ * `EMAIL_VERIFICATION_TTL_MS.user` (#10820), whose single-use link the hook
68
+ * sends; Better Auth still reads this to decide when to call it. A person who
69
+ * opened the mail next morning should not have to ask again.
71
70
  */
72
71
  const VERIFY_TOKEN_TTL_SECONDS = 86_400;
73
72
 
73
+ /**
74
+ * Better Auth's own `GET /verify-email` (#10820 D4), disabled on EVERY build.
75
+ * Its stateless JWT is neither single-use nor revocable, and it stamps
76
+ * `emailVerified` on a valid signature; the API's email-verification service
77
+ * is the one verifier now, reached from the site's `/verify-email` page by a
78
+ * person's click. The magic-link verify is `/magic-link/verify`, a distinct
79
+ * path, and is unaffected.
80
+ */
81
+ const VERIFY_EMAIL_DISABLED_PATHS = ["/verify-email"];
82
+
74
83
  // mcp-oauth:begin
75
84
  /**
76
85
  * The site's MCP authorization server (#10357 D4): better-auth's `mcp` plugin
@@ -510,6 +519,10 @@ export function createAuth(deps: CreateAuthDeps = {}) {
510
519
  });
511
520
  const sendVerificationEmail = makeSendVerificationEmail({
512
521
  send: authEmailTransport,
522
+ // The site's own confirm page, on the same pinned origin the reset links
523
+ // use (#10820 D4). The callback is registered only under
524
+ // `resetConfigured`, which requires that origin.
525
+ confirmBase: baseURL ? new URL("/verify-email", baseURL).href : "",
513
526
  });
514
527
  const sendPasswordChangedNotice = makeSendPasswordChangedNotice({
515
528
  send: authEmailTransport,
@@ -709,7 +722,10 @@ export function createAuth(deps: CreateAuthDeps = {}) {
709
722
  * deployment that has not pinned its origin never offers reset at all.
710
723
  */
711
724
  ...(baseURL ? { trustedOrigins: [baseURL] } : {}),
712
- ...(baseURL ? { disabledPaths: MCP_DISABLED_PATHS } : {}),
725
+ disabledPaths: [
726
+ ...VERIFY_EMAIL_DISABLED_PATHS,
727
+ ...(baseURL ? MCP_DISABLED_PATHS : []),
728
+ ],
713
729
  emailAndPassword: {
714
730
  enabled: passwordMethodActive,
715
731
  // Turning the password method on also opens POST /sign-up/email —
@@ -36,6 +36,7 @@ const mockSubscriptionMe = jest.fn();
36
36
  const mockMemberPieces = jest.fn();
37
37
  const mockMemberMessages = jest.fn();
38
38
  const mockMemberPurchases = jest.fn();
39
+ const mockMemberResults = jest.fn();
39
40
  const mockMemberAppointments = jest.fn();
40
41
  const mockMemberMeetingTypes = jest.fn();
41
42
  const mockMemberRequestForm = jest.fn();
@@ -48,6 +49,7 @@ jest.mock("@working-theory/api-client", () => ({
48
49
  pieces: (...args: unknown[]) => mockMemberPieces(...args),
49
50
  messages: (...args: unknown[]) => mockMemberMessages(...args),
50
51
  purchases: (...args: unknown[]) => mockMemberPurchases(...args),
52
+ results: (...args: unknown[]) => mockMemberResults(...args),
51
53
  appointments: (...args: unknown[]) => mockMemberAppointments(...args),
52
54
  meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
53
55
  requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
@@ -148,6 +150,11 @@ const MESSAGE = {
148
150
  const ok = <T>(body: T) => ({ ok: true as const, body });
149
151
  const fail = (status: number) => ({ ok: false as const, status, body: null });
150
152
 
153
+ beforeEach(() => {
154
+ // A site with no workspace module answers the results read 404.
155
+ mockMemberResults.mockResolvedValue(fail(404));
156
+ });
157
+
151
158
  afterEach(() => {
152
159
  jest.clearAllMocks();
153
160
  accountExperimentKeys.length = 0;
@@ -256,6 +263,68 @@ describe("result — waitlistSignups.me per accountExperimentKeys", () => {
256
263
  });
257
264
  });
258
265
 
266
+ describe("result — member.results, a User's pending link (#10826 D7, AC-2)", () => {
267
+ const RESULT = {
268
+ id: "lead_01920000-0000-7000-8000-000000000001",
269
+ magnetSlug: "request-quote",
270
+ title: "Request a quote",
271
+ submittedAt: "2026-11-02T09:00:00.000Z",
272
+ href: "/flows/request-quote/results/eyJ2ZXJzaW9uIjoxfQ",
273
+ };
274
+ const ITEM = { id: RESULT.id, title: "Request a quote", at: RESULT.submittedAt, data: RESULT };
275
+
276
+ it("loads member.results items as rows with the forwarded init, beside the signups", async () => {
277
+ accountExperimentKeys.push("diag");
278
+ mockSignupMe.mockResolvedValue(ok(SIGNUP));
279
+ mockMemberResults.mockResolvedValue(ok({ items: [RESULT] }));
280
+ const items = await kind("result").load(ctx());
281
+ expect(mockMemberResults).toHaveBeenCalledWith(INIT);
282
+ expect(items).toEqual([
283
+ { id: "diag", title: "diag", at: SIGNUP.completedAt, data: SIGNUP },
284
+ ITEM,
285
+ ]);
286
+ });
287
+
288
+ it("loads none on a 404 (no workspace module)", async () => {
289
+ mockMemberResults.mockResolvedValue(fail(404));
290
+ await expect(kind("result").load(ctx())).resolves.toEqual([]);
291
+ });
292
+
293
+ it("loads none on a 403 while the email is unverified, and rejects on a 503", async () => {
294
+ mockMemberResults.mockResolvedValue(fail(403));
295
+ await expect(kind("result").load(ctx({ emailVerified: false }))).resolves.toEqual([]);
296
+ mockMemberResults.mockResolvedValue(fail(503));
297
+ await expect(kind("result").load(ctx())).rejects.toThrow();
298
+ });
299
+
300
+ it("details the magnet's title and submitted date, with an Open action to its href", () => {
301
+ const detail = kind("result").renderDetail(ITEM, ctx());
302
+ expect(detail.fields?.heading).toBe("t:results.heading");
303
+ expect(detail.fields?.items.map((i) => i.label)).toEqual([
304
+ "t:results.magnet",
305
+ "t:results.submittedAt",
306
+ ]);
307
+ expect(detail.fields?.items[0]?.value).toBe("Request a quote");
308
+ expect(detail.actions).toEqual([
309
+ {
310
+ key: "open-result",
311
+ label: "t:results.open",
312
+ variant: "default",
313
+ href: "/flows/request-quote/results/eyJ2ZXJzaW9uIjoxfQ",
314
+ },
315
+ ]);
316
+ });
317
+
318
+ it("still details a waitlist signup as before", () => {
319
+ const detail = kind("result").renderDetail(
320
+ { id: "diag", title: "diag", at: SIGNUP.completedAt, data: SIGNUP },
321
+ ctx(),
322
+ );
323
+ expect(detail.actions).toBeUndefined();
324
+ expect(detail.fields?.items[0]).toEqual({ label: "t:results.experiment", value: "diag" });
325
+ });
326
+ });
327
+
259
328
  describe("purchase — member.purchases (#8231 D5)", () => {
260
329
  const ITEM = { id: "pur_1", title: "Pro plan", at: PURCHASE.purchasedAt, data: PURCHASE };
261
330
 
@@ -20,9 +20,12 @@
20
20
  * change it reports.
21
21
  */
22
22
 
23
+ import { createHmac } from "node:crypto";
24
+
23
25
  import type { AuthEmailLog } from "../auth-email";
24
26
  import {
25
27
  buildAuthEmailRequest,
28
+ confirmSignUpEmail,
26
29
  MAGIC_LINK_TTL_SECONDS,
27
30
  makeMagicLinkOptions,
28
31
  makeSendResetPassword,
@@ -246,7 +249,11 @@ describe("the § D12 PII boundary", () => {
246
249
  const reset = makeSendResetPassword({ prisma: makePrisma(true), send, log });
247
250
  await reset({ user: { id: "user_1", email: "person@example.com" }, url });
248
251
 
249
- const verify = makeSendVerificationEmail({ send, log });
252
+ const verify = makeSendVerificationEmail({
253
+ send,
254
+ log,
255
+ confirmBase: "https://site.example/verify-email",
256
+ });
250
257
  await verify({ user: { id: "user_1", email: "person@example.com" }, url });
251
258
 
252
259
  const notice = makeSendPasswordChangedNotice({ send, log });
@@ -727,3 +734,94 @@ describe("sendMagicLink — the sign-up kind (#9720 D6)", () => {
727
734
  });
728
735
  });
729
736
  });
737
+
738
+ // ── #10820 — one email-verification service ──────────────────────────────
739
+
740
+ describe("makeSendVerificationEmail (#10820 D4)", () => {
741
+ it("posts the site's confirm base as `url`, never Better Auth's JWT link", async () => {
742
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "test-secret";
743
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
744
+ const { log } = makeLog();
745
+ const confirmBase = "https://site.example/verify-email";
746
+ const verify = makeSendVerificationEmail({ send, log, confirmBase });
747
+
748
+ await verify({
749
+ user: { id: "user_1", email: "person@example.com" },
750
+ url: "https://site.example/api/auth/verify-email?token=JWT",
751
+ });
752
+
753
+ expect(send).toHaveBeenCalledTimes(1);
754
+ const [rawBody] = send.mock.calls[0] as [string];
755
+ const body = JSON.parse(rawBody) as Record<string, unknown>;
756
+ expect(body).toEqual({
757
+ kind: "email-verification",
758
+ userId: "user_1",
759
+ to: "person@example.com",
760
+ url: confirmBase,
761
+ expiresInMinutes: 1440,
762
+ });
763
+ expect(rawBody).not.toContain("JWT");
764
+ });
765
+ });
766
+
767
+ describe("confirmSignUpEmail (#10820 D7)", () => {
768
+ const NONCE = "N".repeat(43);
769
+
770
+ beforeEach(() => {
771
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "test-secret";
772
+ });
773
+
774
+ it("signs the domain-separated base string over the exact bytes it sends", async () => {
775
+ const confirm = jest
776
+ .fn()
777
+ .mockResolvedValue({ ok: true, body: { ok: true, outcome: "verified" } });
778
+ const { log } = makeLog();
779
+
780
+ expect(await confirmSignUpEmail(NONCE, { confirm, log })).toBe("verified");
781
+
782
+ const [rawBody, headers] = confirm.mock.calls[0] as [string, Record<string, string>];
783
+ expect(rawBody).toBe(JSON.stringify({ nonce: NONCE }));
784
+ const timestamp = headers["x-ailk-auth-email-timestamp"]!;
785
+ expect(headers["x-ailk-auth-email-signature"]).toBe(
786
+ `sha256=${createHmac("sha256", "test-secret")
787
+ .update(`email-verification-confirm.${timestamp}.${rawBody}`, "utf8")
788
+ .digest("hex")}`,
789
+ );
790
+ });
791
+
792
+ it.each(["verified", "already_verified", "expired", "invalid"])(
793
+ "hands back the hop's %s outcome",
794
+ async (outcome) => {
795
+ const confirm = jest.fn().mockResolvedValue({ ok: true, body: { ok: true, outcome } });
796
+ expect(await confirmSignUpEmail(NONCE, { confirm, log: makeLog().log })).toBe(outcome);
797
+ },
798
+ );
799
+
800
+ it("answers failed — never throwing — on a non-2xx, an unknown body, a throw or no secret", async () => {
801
+ const { log } = makeLog();
802
+ const rejected = jest.fn().mockResolvedValue({ ok: false, status: 401, body: null });
803
+ const odd = jest.fn().mockResolvedValue({ ok: true, body: { outcome: "stamped" } });
804
+ const throwing = jest.fn().mockRejectedValue(new Error("boom"));
805
+ expect(await confirmSignUpEmail(NONCE, { confirm: rejected, log })).toBe("failed");
806
+ expect(await confirmSignUpEmail(NONCE, { confirm: odd, log })).toBe("failed");
807
+ expect(await confirmSignUpEmail(NONCE, { confirm: throwing, log })).toBe("failed");
808
+ delete process.env.AUTH_EMAIL_WEBHOOK_SECRET;
809
+ const unused = jest.fn();
810
+ expect(await confirmSignUpEmail(NONCE, { confirm: unused, log })).toBe("failed");
811
+ expect(unused).not.toHaveBeenCalled();
812
+ });
813
+
814
+ it("never logs the nonce", async () => {
815
+ const { lines, log } = makeLog();
816
+ await confirmSignUpEmail(NONCE, {
817
+ confirm: jest.fn().mockResolvedValue({ ok: true, body: { ok: true, outcome: "verified" } }),
818
+ log,
819
+ });
820
+ await confirmSignUpEmail(NONCE, {
821
+ confirm: jest.fn().mockRejectedValue(new Error(NONCE)),
822
+ log,
823
+ });
824
+ expect(lines.length).toBeGreaterThan(0);
825
+ expect(JSON.stringify(lines)).not.toContain(NONCE);
826
+ });
827
+ });
@@ -29,6 +29,9 @@ import {
29
29
  } from "../page-chrome";
30
30
  import type { SiteChrome } from "../site-chrome";
31
31
 
32
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
33
+ process.env.CONTENT_ADAPTER = "mdx";
34
+
32
35
  // ── AC-2 — the route families ────────────────────────────────────────────
33
36
 
34
37
  function camel(prefix: string): string {