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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
153
|
-
completion: "
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
completion: "
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
who: "visitor",
|
|
246
246
|
completion: "lead",
|
|
247
247
|
tags: [STANDARD_MAGNET_TAGS[TRIAL_FLOW_SLUG]!],
|
|
248
248
|
landingPage: TRIAL_FLOW_LANDING_PAGE,
|
package/templates/package.json
CHANGED
|
@@ -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
|
-
"
|
|
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",
|