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
@@ -29,6 +29,16 @@
29
29
  * `tools/content-compile`, which the scaffold does not ship, and the adapter
30
30
  * reads the same files with the same frontmatter schema the manifest is
31
31
  * compiled from. See docs/internal/decisions/scaffold-structured-data-check.md.
32
+ *
33
+ * The pages come from the site's active content source (#10704):
34
+ * `resolvedAdapterKind()`, the resolver every page reads through. Under `mdx`
35
+ * (the default) they are the files under `content/`. Under `database` — a
36
+ * spoke that serves every page from its hub and may hold no page file — they
37
+ * are the hub's published pages as the build's last-good snapshot holds them
38
+ * (`.ailk/content-snapshot.json`, written by `pnpm --filter web build` under
39
+ * `CONTENT_ADAPTER=database`), read through the site's own content source
40
+ * with the API origin pinned to a refused loopback, so a test run reads no
41
+ * hub and needs no key.
32
42
  */
33
43
  import {
34
44
  copyFileSync,
@@ -51,8 +61,11 @@ import type {
51
61
  import {
52
62
  SCHEMA_ORG_TYPES,
53
63
  createMdxAdapter,
64
+ resolvedAdapterKind,
54
65
  } from "@working-theory/content-adapters";
55
66
  import {
67
+ getContentAdapter,
68
+ getMdxSource,
56
69
  getPageSections,
57
70
  pageToJsonLd,
58
71
  siteOrigin,
@@ -158,6 +171,94 @@ const adapter = createMdxAdapter({
158
171
  defaultLocale: DEFAULT_LOCALE,
159
172
  });
160
173
 
174
+ // ── #10704 D2 — a site that serves its pages from its hub ──────────────────
175
+
176
+ const SOURCE = resolvedAdapterKind();
177
+ // page-renderer's `CONTENT_SNAPSHOT_PATH`, which this jest config maps no
178
+ // subpath to read; create-ailk's structured-data-check suite pins the two
179
+ // equal. Relative to cwd, as the content source reads it.
180
+ const SNAPSHOT_REL = ".ailk/content-snapshot.json";
181
+
182
+ // No read may reach a hub: every page and list read fails as unavailable and
183
+ // the content source answers from the snapshot, the path a deployed spoke
184
+ // takes while its hub is down.
185
+ if (SOURCE === "database") process.env.AILK_API_URL = "http://127.0.0.1:9";
186
+
187
+ /**
188
+ * Every page the snapshot holds, keyed by its cache tag
189
+ * (`content:<workspace>:<type>:<slug>:<locale>`, `cache-tags.ts`), or why it
190
+ * holds none. Read only under `database`.
191
+ */
192
+ function readHubPages(): {
193
+ pages: Array<{ locale: string; type: string; slug: string }>;
194
+ problem: string | null;
195
+ } {
196
+ const path = join(process.cwd(), SNAPSHOT_REL);
197
+ const build = "run `pnpm --filter web build` under CONTENT_ADAPTER=database to write it";
198
+ if (!existsSync(path)) {
199
+ return { pages: [], problem: `${path} does not exist — ${build}` };
200
+ }
201
+ let parsed: { version?: unknown; pages?: unknown; lists?: unknown } | null;
202
+ try {
203
+ parsed = JSON.parse(readFileSync(path, "utf8")) as typeof parsed;
204
+ } catch {
205
+ parsed = null;
206
+ }
207
+ if (
208
+ parsed === null ||
209
+ typeof parsed !== "object" ||
210
+ parsed.version !== 1 ||
211
+ typeof parsed.pages !== "object" ||
212
+ parsed.pages === null ||
213
+ typeof parsed.lists !== "object" ||
214
+ parsed.lists === null
215
+ ) {
216
+ return { pages: [], problem: `${path} is not a version-1 content snapshot — ${build}` };
217
+ }
218
+ const pages = Object.keys(parsed.pages).map((tag) => {
219
+ const parts = tag.split(":");
220
+ return {
221
+ type: parts[2] ?? "",
222
+ slug: parts.slice(3, -1).join(":"),
223
+ locale: parts[parts.length - 1] ?? "",
224
+ };
225
+ });
226
+ if (pages.length === 0) {
227
+ return { pages, problem: `${path} holds no page — ${build}` };
228
+ }
229
+ return { pages, problem: null };
230
+ }
231
+
232
+ const HUB = SOURCE === "database" ? readHubPages() : { pages: [], problem: null };
233
+
234
+ /** The snapshot's (locale, type) pairs, in the shape the content walk takes. */
235
+ function hubTypeDirs(): Array<{ locale: string; typeDir: string }> {
236
+ const seen = new Set<string>();
237
+ const out: Array<{ locale: string; typeDir: string }> = [];
238
+ for (const { locale, type } of HUB.pages) {
239
+ if (seen.has(`${locale}/${type}`)) continue;
240
+ seen.add(`${locale}/${type}`);
241
+ out.push({ locale, typeDir: type });
242
+ }
243
+ return out.sort((a, b) =>
244
+ `${a.locale}/${a.typeDir}`.localeCompare(`${b.locale}/${b.typeDir}`),
245
+ );
246
+ }
247
+
248
+ /** A type's pages in a locale, from the active source. */
249
+ function listDetailFor(type: PageType, locale: string): Promise<ContentPage[]> {
250
+ return SOURCE === "database"
251
+ ? getContentAdapter().listDetail(type, locale)
252
+ : adapter.listDetail(type, locale);
253
+ }
254
+
255
+ /** One page's source, from the active source (`ContentAdapter` has no `getPageSource`). */
256
+ function pageSourceFor(type: PageType, slug: string, locale: string) {
257
+ return SOURCE === "database"
258
+ ? getMdxSource(type, slug, locale)
259
+ : adapter.getPageSource(type, slug, locale);
260
+ }
261
+
161
262
  // ── JSON-LD flatten + validate (the ten lines from tools/scrape-jsonld.ts) ──
162
263
 
163
264
  function flattenBlocks(parsed: unknown): JsonLdObject[] {
@@ -244,9 +345,21 @@ function identityFailure(blocks: JsonLdObject[], label: string): string | null {
244
345
  // ── 1. Content-driven: every content page builds valid JSON-LD ──────────────
245
346
 
246
347
  describe("structured data — every content page builds valid JSON-LD", () => {
247
- const typeDirs = contentTypeDirs();
348
+ const typeDirs = SOURCE === "database" ? hubTypeDirs() : contentTypeDirs();
349
+ let hubPagesChecked = 0;
350
+
351
+ afterAll(() => {
352
+ if (SOURCE === "database") {
353
+ console.info(`structured-data: ${hubPagesChecked} hub page(s) checked from ${SNAPSHOT_REL}`);
354
+ }
355
+ });
248
356
 
249
357
  it("finds content to check", () => {
358
+ if (SOURCE === "database") {
359
+ // A hub-served site with no snapshot has nothing to check: misconfigured.
360
+ expect(HUB.problem).toBeNull();
361
+ return;
362
+ }
250
363
  // A site with no content directory at all is misconfigured, not clean.
251
364
  expect(existsSync(CONTENT_ROOT)).toBe(true);
252
365
  expect(typeDirs.length).toBeGreaterThan(0);
@@ -274,14 +387,17 @@ describe("structured data — every content page builds valid JSON-LD", () => {
274
387
  }
275
388
  const type = typeDir as PageType;
276
389
 
277
- const pages: ContentPage[] = await adapter.listDetail(type, locale);
278
- const dirPath = join(CONTENT_ROOT, locale, typeDir);
279
- const mdxCount = readdirSync(dirPath).filter(
280
- (n) => n.endsWith(".mdx") && !n.startsWith("_"),
281
- ).length;
390
+ const pages: ContentPage[] = await listDetailFor(type, locale);
282
391
  // The adapter drops a page whose frontmatter fails the schema; that
283
392
  // page would render nothing, so count it here rather than lose it.
284
- expect(pages.length).toBe(mdxCount);
393
+ // Under `database` the count is the pages the hub published (#10704).
394
+ const expected =
395
+ SOURCE === "database"
396
+ ? HUB.pages.filter((p) => p.locale === locale && p.type === type).length
397
+ : readdirSync(join(CONTENT_ROOT, locale, typeDir)).filter(
398
+ (n) => n.endsWith(".mdx") && !n.startsWith("_"),
399
+ ).length;
400
+ expect(pages.length).toBe(expected);
285
401
 
286
402
  const failures: string[] = [];
287
403
  for (const page of pages) {
@@ -295,6 +411,7 @@ describe("structured data — every content page builds valid JSON-LD", () => {
295
411
  if (failure !== null) failures.push(failure);
296
412
  }
297
413
  expect(failures).toEqual([]);
414
+ if (SOURCE === "database") hubPagesChecked += pages.length;
298
415
  },
299
416
  );
300
417
  });
@@ -674,14 +791,16 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
674
791
  // #10043 D7 — a Service's category, for the nested route's params.
675
792
  const parents = new Map<string, string>();
676
793
  if (identity.slug !== null) {
677
- const page = await adapter.getPageSource(
794
+ const page = await pageSourceFor(
678
795
  type,
679
796
  identity.slug,
680
797
  DEFAULT_LOCALE,
681
798
  );
682
799
  if (page === null) {
683
800
  throw new Error(
684
- `${label}: renders ${type}/${identity.slug}, but content/${DEFAULT_LOCALE}/${type}/${identity.slug}.mdx does not exist — the route serves 404 in production.`,
801
+ SOURCE === "database"
802
+ ? `${label}: renders ${type}/${identity.slug}, but the hub serves no such page (${SNAPSHOT_REL}) — the route serves 404 in production.`
803
+ : `${label}: renders ${type}/${identity.slug}, but content/${DEFAULT_LOCALE}/${type}/${identity.slug}.mdx does not exist — the route serves 404 in production.`,
685
804
  );
686
805
  }
687
806
  slugs = [identity.slug];
@@ -689,7 +808,7 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
689
808
  // The hub's own page shares this route's type but is served at the hub
690
809
  // (#10044 D13), so it is never rendered here.
691
810
  const hubSlug = hubOwnSlug(file, type);
692
- const pages = (await adapter.listDetail(type, DEFAULT_LOCALE)).filter(
811
+ const pages = (await listDetailFor(type, DEFAULT_LOCALE)).filter(
693
812
  (p: ContentPage) => p.slug !== hubSlug,
694
813
  );
695
814
  for (const p of pages) if (p.parent !== undefined) parents.set(p.slug, p.parent);
@@ -772,6 +891,8 @@ describe("structured data — a hub's own page is not rendered at its detail rou
772
891
  async () => {
773
892
  const root = mkdtempSync(join(tmpdir(), "ailk-10044-sd-"));
774
893
  const prior = process.env.AILK_CONTENT_ROOT;
894
+ // #10704 D2 — the fixture is files, whatever source the site serves from.
895
+ const priorSource = process.env.CONTENT_ADAPTER;
775
896
  try {
776
897
  mkdirSync(join(root, DEFAULT_LOCALE, "Pricing"), { recursive: true });
777
898
  copyFileSync(join(CONTENT_ROOT, "_site.mdx"), join(root, "_site.mdx"));
@@ -784,6 +905,7 @@ describe("structured data — a hub's own page is not rendered at its detail rou
784
905
  '---\ntype: "Pricing"\nslug: "website-rebuild"\ntitle: "Website Rebuild"\ndescription: "What a rebuild costs."\nplans: ["ss-website-rebuild"]\n---\n',
785
906
  );
786
907
  process.env.AILK_CONTENT_ROOT = root;
908
+ process.env.CONTENT_ADAPTER = "mdx";
787
909
  const fixtureAdapter = createMdxAdapter({
788
910
  contentRoot: root,
789
911
  defaultLocale: DEFAULT_LOCALE,
@@ -807,6 +929,8 @@ describe("structured data — a hub's own page is not rendered at its detail rou
807
929
  } finally {
808
930
  if (prior === undefined) delete process.env.AILK_CONTENT_ROOT;
809
931
  else process.env.AILK_CONTENT_ROOT = prior;
932
+ if (priorSource === undefined) delete process.env.CONTENT_ADAPTER;
933
+ else process.env.CONTENT_ADAPTER = priorSource;
810
934
  rmSync(root, { recursive: true, force: true });
811
935
  }
812
936
  },
@@ -82,3 +82,23 @@ describe("scrubTelemetryUrl — the /v1/deliverables/<grantId> path (#9162)", ()
82
82
  }
83
83
  });
84
84
  });
85
+
86
+ describe("scrubTelemetryUrl — the email-verification nonce (#10820 D2)", () => {
87
+ it("strips verify=<nonce> from a reported confirm-page URL", () => {
88
+ const nonce = "N".repeat(43);
89
+ const scrubbed = scrubTelemetryUrl(`https://site.example/verify-email?verify=${nonce}`);
90
+ expect(scrubbed).not.toContain(nonce);
91
+ expect(scrubbed.startsWith("https://site.example/verify-email")).toBe(true);
92
+ });
93
+
94
+ it("strips lead= and verify= from a magnet Lead's confirm link (#10826 D5, SEC-10826-7)", () => {
95
+ const nonce = "N".repeat(43);
96
+ const leadId = "lead_01920000-1082-7000-8000-000000000001";
97
+ const scrubbed = scrubTelemetryUrl(
98
+ `https://site.example/flows/growth-check/verify?lead=${leadId}&verify=${nonce}`,
99
+ );
100
+ expect(scrubbed).not.toContain(nonce);
101
+ expect(scrubbed).not.toContain(leadId);
102
+ expect(scrubbed).toBe("https://site.example/flows/growth-check/verify");
103
+ });
104
+ });
@@ -21,7 +21,10 @@
21
21
  // The base contributions (D9, amended 2026-10-01 by D6a/D8a — #7448) use
22
22
  // existing reads only, each with the caller's cookie forwarded (`ctx.init`):
23
23
  //
24
- // - Inbox kind `result` — a row of the All tab.
24
+ // - Inbox kind `result` — a row of the All tab: the waitlist signups above
25
+ // and, where the workspace module is present, the caller's verified
26
+ // submissions to `user` magnets (`member.results`, #10826 D7), each with
27
+ // an Open action back to its results.
25
28
  // - For-you sources `email-unverified` and `payment-due` — needs-you items
26
29
  // with an Open action to User settings' two pages, `/account/general`
27
30
  // and `/account/billing` (#8232 D10), never Inbox rows. Neither read carries the time the condition began, so their time
@@ -109,6 +112,7 @@ import type {
109
112
  MemberMessage,
110
113
  MemberPiece,
111
114
  MemberPurchase,
115
+ MemberResult,
112
116
  } from "@working-theory/validation";
113
117
  import { formatMoney, formatNumber } from "@working-theory/web-i18n";
114
118
  import { cache } from "react";
@@ -149,27 +153,61 @@ function formatWhen(iso: string, locale: string): string {
149
153
 
150
154
  // ── Base Inbox kinds (D9) ─────────────────────────────────────────────────────
151
155
 
152
- const result: AccountInboxKind<WaitlistSignupMeResponse> = {
156
+ /** A `result` row: a waitlist signup, or a submission to a `user` magnet (#10826 D7). */
157
+ type ResultRecord = WaitlistSignupMeResponse | MemberResult;
158
+
159
+ const result: AccountInboxKind<ResultRecord> = {
153
160
  kind: "result",
154
161
  label: "results.heading",
155
162
  section: "updates",
156
163
  async load({ init, user }) {
157
164
  // One failed key fails the kind (D12) — never a partial list passed off as whole.
158
- const signups = await Promise.all(
159
- accountExperimentKeys.map((key) =>
160
- read(waitlistSignups.me(key, init), user),
165
+ // A 404 from the results read (no workspace module) loads none.
166
+ const [signups, results] = await Promise.all([
167
+ Promise.all(
168
+ accountExperimentKeys.map((key) =>
169
+ read(waitlistSignups.me(key, init), user),
170
+ ),
161
171
  ),
162
- );
163
- return signups
164
- .filter((s): s is WaitlistSignupMeResponse => s !== null)
165
- .map((s) => ({
166
- id: s.experimentKey,
167
- title: s.experimentKey,
168
- at: s.completedAt,
169
- data: s,
170
- }));
172
+ read(member.results(init), user),
173
+ ]);
174
+ return [
175
+ ...signups
176
+ .filter((s): s is WaitlistSignupMeResponse => s !== null)
177
+ .map((s) => ({
178
+ id: s.experimentKey,
179
+ title: s.experimentKey,
180
+ at: s.completedAt,
181
+ data: s,
182
+ })),
183
+ ...(results?.items ?? []).map((r) => ({
184
+ id: r.id,
185
+ title: r.title,
186
+ at: r.submittedAt,
187
+ data: r,
188
+ })),
189
+ ];
171
190
  },
172
191
  renderDetail({ data }, { locale, t }) {
192
+ if ("magnetSlug" in data) {
193
+ return {
194
+ actions: [
195
+ {
196
+ key: "open-result",
197
+ label: t("results.open"),
198
+ variant: "default" as const,
199
+ href: localeHref(locale, data.href),
200
+ },
201
+ ],
202
+ fields: {
203
+ heading: t("results.heading"),
204
+ items: [
205
+ { label: t("results.magnet"), value: data.title },
206
+ { label: t("results.submittedAt"), value: formatWhen(data.submittedAt, locale) },
207
+ ],
208
+ },
209
+ };
210
+ }
173
211
  return {
174
212
  fields: {
175
213
  heading: t("results.heading"),
@@ -319,13 +319,30 @@ export function makeSendResetPassword(deps: ResetPasswordDeps) {
319
319
  };
320
320
  }
321
321
 
322
- /** Better Auth's `sendVerificationEmail` (§ D9). */
323
- export function makeSendVerificationEmail(deps: DispatchDeps) {
324
- const { send } = deps;
322
+ interface VerificationDeps extends DispatchDeps {
323
+ /**
324
+ * The site's own confirm page — `new URL("/verify-email", BETTER_AUTH_URL)`
325
+ * — which the API's email-verification service appends its single-use nonce
326
+ * to (#10820 D4).
327
+ */
328
+ confirmBase: string;
329
+ }
330
+
331
+ /**
332
+ * Better Auth's `sendVerificationEmail` (§ D9, #10820 D4).
333
+ *
334
+ * Keeps the library's signature and IGNORES its `url`: that link is Better
335
+ * Auth's stateless JWT, which is neither single-use nor revocable, and its
336
+ * `/verify-email` endpoint is disabled in `auth.ts`. The relay's
337
+ * `email-verification` kind mints the service's own nonce for this user and
338
+ * mails `confirmBase?verify=<nonce>`; a click on that page confirms through
339
+ * `confirmSignUpEmail` below.
340
+ */
341
+ export function makeSendVerificationEmail(deps: VerificationDeps) {
342
+ const { send, confirmBase } = deps;
325
343
  const log = deps.log ?? defaultLog;
326
344
  return async function sendVerificationEmail({
327
345
  user,
328
- url,
329
346
  }: {
330
347
  user: { id: string; email: string };
331
348
  url: string;
@@ -335,7 +352,7 @@ export function makeSendVerificationEmail(deps: DispatchDeps) {
335
352
  kind: "email-verification",
336
353
  userId: user.id,
337
354
  to: user.email,
338
- url,
355
+ url: confirmBase,
339
356
  expiresInMinutes: 24 * 60,
340
357
  },
341
358
  { send, log },
@@ -343,6 +360,71 @@ export function makeSendVerificationEmail(deps: DispatchDeps) {
343
360
  };
344
361
  }
345
362
 
363
+ /** The confirm hop's answer, as the `/verify-email` action reads it. */
364
+ export type SignUpConfirmOutcome =
365
+ | "verified"
366
+ | "already_verified"
367
+ | "expired"
368
+ | "invalid"
369
+ | "failed";
370
+
371
+ /** The confirm hop's transport seam — `authEmail.confirm`'s shape. */
372
+ export type ConfirmTransport = (
373
+ rawBody: string,
374
+ headers: Record<string, string>,
375
+ ) => Promise<{ ok: boolean; body?: unknown }>;
376
+
377
+ const CONFIRM_OUTCOMES = new Set(["verified", "already_verified", "expired", "invalid"]);
378
+
379
+ /**
380
+ * Confirm a sign-up link's nonce through the API's signed hop,
381
+ * `POST /v1/auth/email-verification/confirm` (#10820 D2, D7).
382
+ *
383
+ * ONE serialization of `{ nonce }`, signed over the DOMAIN-SEPARATED base
384
+ * string `email-verification-confirm.<timestamp>.<rawBody>` — so a body signed
385
+ * for the mail relay can never be replayed to the confirm route, or the
386
+ * reverse — and the same bytes handed to the transport.
387
+ *
388
+ * NEVER THROWS, and logs only `{ kind, outcome | errorKind }`: never the
389
+ * nonce, which is the credential. Anything but a 2xx carrying a known outcome
390
+ * is `failed`.
391
+ */
392
+ export async function confirmSignUpEmail(
393
+ nonce: string,
394
+ deps: { confirm?: ConfirmTransport; log?: AuthEmailLog } = {},
395
+ ): Promise<SignUpConfirmOutcome> {
396
+ const confirm: ConfirmTransport =
397
+ deps.confirm ?? ((rawBody, headers) => authEmail.confirm(rawBody, headers));
398
+ const log = deps.log ?? defaultLog;
399
+ const base = { kind: "email-verification-confirm" };
400
+ const secret = process.env.AUTH_EMAIL_WEBHOOK_SECRET;
401
+ if (!secret) {
402
+ log.warn({ ...base, errorKind: "not_configured" });
403
+ return "failed";
404
+ }
405
+ try {
406
+ const rawBody = JSON.stringify({ nonce });
407
+ const timestamp = String(Math.floor(Date.now() / 1000));
408
+ const signature = `sha256=${createHmac("sha256", secret)
409
+ .update(`email-verification-confirm.${timestamp}.${rawBody}`, "utf8")
410
+ .digest("hex")}`;
411
+ const result = await confirm(rawBody, {
412
+ "x-ailk-auth-email-signature": signature,
413
+ "x-ailk-auth-email-timestamp": timestamp,
414
+ });
415
+ const outcome = (result.body as { outcome?: unknown } | null | undefined)?.outcome;
416
+ if (!result.ok || typeof outcome !== "string" || !CONFIRM_OUTCOMES.has(outcome)) {
417
+ log.warn({ ...base, errorKind: "relay_rejected" });
418
+ return "failed";
419
+ }
420
+ log.info({ ...base, outcome });
421
+ return outcome as SignUpConfirmOutcome;
422
+ } catch {
423
+ log.warn({ ...base, errorKind: "transport_error" });
424
+ return "failed";
425
+ }
426
+ }
427
+
346
428
  /**
347
429
  * The completed-reset notice (§ D17).
348
430
  *
@@ -19,12 +19,18 @@
19
19
  // Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
20
20
  // since apps/* must not import @working-theory/* packages — keep both lists
21
21
  // in sync (#5613).
22
+ // `verify` joined by #10820 (D2): the email-verification nonce on a
23
+ // `/verify-email` or listing resume link.
24
+ // `lead` joined by #10826 (D5, SEC-10826-7): a magnet Lead's id on its
25
+ // `/flows/<slug>/verify?lead=…&verify=…` confirm link.
22
26
  export const TOKEN_QUERY_PARAMS = [
23
27
  "g",
24
28
  "session_id",
25
29
  "email",
26
30
  "login_hint",
27
31
  "ref",
32
+ "verify",
33
+ "lead",
28
34
  ];
29
35
 
30
36
  // The same grant id rides a PATH too: `deliverables.openGrant` fetches
@@ -80,6 +80,7 @@
80
80
  "heading": "Blog",
81
81
  "emptyState": "No posts yet. Drop MDX files into",
82
82
  "emptyStateTrailing": "to populate this list.",
83
+ "emptyStateLive": "No posts yet.",
83
84
  "allTopics": "All",
84
85
  "topicsNav": "Blog topics",
85
86
  "latestPosts": "Latest posts",
@@ -254,6 +255,20 @@
254
255
  },
255
256
  "questions": {}
256
257
  },
258
+ "flowVerify": {
259
+ "title": "Confirm your email",
260
+ "confirmHeading": "Confirm your email",
261
+ "confirmSubhead": "One click and your address is confirmed.",
262
+ "confirmBody": "Select the button below to confirm this email address.",
263
+ "confirmButton": "Confirm my email",
264
+ "confirmedHeading": "Your email is confirmed",
265
+ "confirmedSubhead": "Thanks — your address is verified.",
266
+ "confirmedBody": "You can close this tab once you're done.",
267
+ "errorHeading": "This confirmation link has expired",
268
+ "errorSubhead": "Confirmation links last 24 hours.",
269
+ "errorBody": "Answer the form again and we'll send you a fresh confirmation email.",
270
+ "back": "Back to the form"
271
+ },
257
272
  "how-to": {
258
273
  "heading": "How-To Guides",
259
274
  "description": "Step-by-step guides for building with this site.",
@@ -665,6 +680,13 @@
665
680
  "verifyExpiredHeading": "This confirmation link has expired",
666
681
  "verifyExpiredSubhead": "Confirmation links last 24 hours.",
667
682
  "verifyExpiredBody": "Sign in again and we'll send you a fresh confirmation email.",
683
+ "confirmHeading": "Confirm your email",
684
+ "confirmSubhead": "One click and your address is confirmed.",
685
+ "confirmBody": "Select the button below to confirm this email address for your account.",
686
+ "confirmButton": "Confirm my email",
687
+ "verifiedHeading": "Your email is confirmed",
688
+ "verifiedSubhead": "Thanks — your address is verified.",
689
+ "verifiedBody": "You can sign in now. You can close this tab once you're done.",
668
690
  "sentHeading": "Check your email",
669
691
  "sentSubhead": "Follow the link we just sent to continue.",
670
692
  "sentBody": "Check your inbox and follow the link to continue. You can close this tab once you're done.",
@@ -969,7 +991,10 @@
969
991
  "tier": "Tier",
970
992
  "score": "Score",
971
993
  "completedAt": "Completed",
972
- "unavailable": "—"
994
+ "unavailable": "—",
995
+ "magnet": "Form",
996
+ "submittedAt": "Submitted",
997
+ "open": "Open"
973
998
  },
974
999
  "purchases": {
975
1000
  "heading": "My purchases",
@@ -39,9 +39,11 @@ export function resolveBaseUrl(): string {
39
39
  * `packages/templates/src/__tests__/routes-map.test.ts`'s table test and
40
40
  * `apps/web/__tests__/routes/routes-map-identity.test.ts`'s differential
41
41
  * test over the live content tree). The signature is annotated explicitly so
42
- * this stays the exact `(type: string, slug: string) => string` contract the
43
- * three call sites (`sitemap.xml`, `llms.txt`, `docs-blog/llms-route.ts`)
44
- * depend on, even though `routeFor`'s own `slug` parameter is optional.
42
+ * this stays the exact `(type: string, slug: string) => string` contract its
43
+ * callers depend on, even though `routeFor`'s own `slug` parameter is
44
+ * optional. `sitemap.xml`, `llms.txt` and, since #10775, the docs-blog
45
+ * module's `docs-blog/llms-route.ts` call `pagePathFor` instead; the export
46
+ * stays for a docs-blog site that installed the older copy of that route.
45
47
  */
46
48
  export const slugToPath: (type: string, slug: string) => string = routeFor;
47
49
 
@@ -149,8 +149,8 @@ export async function provisionDefaultWorkspace(
149
149
  kind: "contact",
150
150
  title: REQUEST_QUOTE_FLOW_TITLE,
151
151
  status: "draft",
152
- customersEnabled: true,
153
- completion: "invite",
152
+ who: "user",
153
+ completion: "user",
154
154
  tags: [STANDARD_MAGNET_TAGS[REQUEST_QUOTE_FLOW_SLUG]!],
155
155
  },
156
156
  update: {},
@@ -171,7 +171,7 @@ export async function provisionDefaultWorkspace(
171
171
  kind: "contact",
172
172
  title: CONTACT_FORM_FLOW_TITLE,
173
173
  status: "live",
174
- customersEnabled: false,
174
+ who: "visitor",
175
175
  completion: "lead",
176
176
  tags: [STANDARD_MAGNET_TAGS[CONTACT_FORM_FLOW_SLUG]!],
177
177
  landingPage: CONTACT_FORM_LANDING_PAGE,
@@ -194,8 +194,8 @@ export async function provisionDefaultWorkspace(
194
194
  kind: "diagnostic",
195
195
  title: JOB_APPLICATION_FLOW_TITLE,
196
196
  status: "live",
197
- customersEnabled: false,
198
- completion: "invite",
197
+ who: "visitor",
198
+ completion: "user",
199
199
  tags: [STANDARD_MAGNET_TAGS[JOB_APPLICATION_FLOW_SLUG]!],
200
200
  landingPage: JOB_APPLICATION_LANDING_PAGE,
201
201
  },
@@ -218,7 +218,7 @@ export async function provisionDefaultWorkspace(
218
218
  kind: "newsletter",
219
219
  title: NEWSLETTER_FLOW_TITLE,
220
220
  status: "live",
221
- customersEnabled: false,
221
+ who: "visitor",
222
222
  completion: "lead",
223
223
  tags: [STANDARD_MAGNET_TAGS[NEWSLETTER_FLOW_SLUG]!],
224
224
  landingPage: NEWSLETTER_LANDING_PAGE,
@@ -242,7 +242,7 @@ export async function provisionDefaultWorkspace(
242
242
  kind: "diagnostic",
243
243
  title: TRIAL_FLOW_TITLE,
244
244
  status: "live",
245
- customersEnabled: false,
245
+ who: "visitor",
246
246
  completion: "lead",
247
247
  tags: [STANDARD_MAGNET_TAGS[TRIAL_FLOW_SLUG]!],
248
248
  landingPage: TRIAL_FLOW_LANDING_PAGE,
@@ -21,7 +21,8 @@
21
21
  "architecture": "tsx tools/lint-architecture.ts",
22
22
  "claude-md:check": "tsx tools/check-claude-md.ts",
23
23
  "content-source:check": "tsx tools/check-content-source.ts",
24
- "preflight": "pnpm architecture && pnpm claude-md:check && pnpm content-source:check && pnpm type-check && pnpm lint && pnpm build"
24
+ "mdx-body:check": "tsx tools/check-mdx-body.ts",
25
+ "preflight": "pnpm architecture && pnpm claude-md:check && pnpm content-source:check && pnpm mdx-body:check && pnpm type-check && pnpm lint && pnpm build"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@arethetypeswrong/cli": "^0.18.5",