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.
- package/README.md +50 -8
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +584 -65
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +17 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
- package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
- package/templates/apps/web/auth.ts +22 -6
- package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
- package/templates/apps/web/lib/account-config.ts +52 -14
- package/templates/apps/web/lib/auth-email.ts +87 -5
- package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
- package/templates/apps/web/messages/en.json +26 -1
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/database/seed.ts +7 -7
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +48 -48
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
- 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
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
// #
|
|
34
|
-
//
|
|
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-
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* opened the mail next morning
|
|
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
|
-
|
|
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({
|
|
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 {
|