create-ailk 0.27.0 → 0.28.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 +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -18,10 +18,21 @@ import { captureLeadAction } from "../contact.actions.js";
|
|
|
18
18
|
|
|
19
19
|
// Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
|
|
20
20
|
// so AC-9 compares the action's `init` with the helper's own output.
|
|
21
|
+
// #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
|
|
22
|
+
// request context, which jest has none of, so every case decides it here.
|
|
23
|
+
const checkBotIdMock = jest.fn();
|
|
24
|
+
jest.mock("botid/server", () => ({
|
|
25
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
21
28
|
jest.mock("@working-theory/api-client", () => ({
|
|
22
29
|
leads: {
|
|
23
30
|
submit: jest.fn(),
|
|
24
31
|
},
|
|
32
|
+
// #9171 — a `checkout` magnet's lookup key comes from the shown set.
|
|
33
|
+
offerings: {
|
|
34
|
+
shown: jest.fn(),
|
|
35
|
+
},
|
|
25
36
|
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
26
37
|
.visitorForwardHeaders,
|
|
27
38
|
}));
|
|
@@ -53,9 +64,9 @@ jest.mock("next-intl/server", () => {
|
|
|
53
64
|
};
|
|
54
65
|
});
|
|
55
66
|
|
|
56
|
-
// #
|
|
57
|
-
//
|
|
58
|
-
//
|
|
67
|
+
// #9171 — a `checkout` magnet never calls Next's `redirect()`: a Server
|
|
68
|
+
// Action redirect is fetched as RSC, which the buy route refuses. The mock
|
|
69
|
+
// throws NEXT_REDIRECT as Next does, so a stray call fails its case.
|
|
59
70
|
const mockRedirect = jest.fn((url: string): never => {
|
|
60
71
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
|
61
72
|
});
|
|
@@ -65,21 +76,19 @@ jest.mock("next/navigation", () => ({
|
|
|
65
76
|
}));
|
|
66
77
|
|
|
67
78
|
let mockSignInProvider: "site" | "hub" | "none" = "site";
|
|
68
|
-
|
|
69
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D8, D11), not runtime inputs; no env or HTTP surface selects them.
|
|
79
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER is a generated per-build constant (#8757 D8), not a runtime input; no env or HTTP surface selects it.
|
|
70
80
|
jest.mock("../../../../lib/topology.generated", () => ({
|
|
71
81
|
__esModule: true,
|
|
72
82
|
get SIGN_IN_PROVIDER() {
|
|
73
83
|
return mockSignInProvider;
|
|
74
84
|
},
|
|
75
|
-
get TOPOLOGY() {
|
|
76
|
-
return mockTopology;
|
|
77
|
-
},
|
|
78
85
|
}));
|
|
79
86
|
|
|
80
|
-
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
87
|
+
import { leads, offerings, visitorForwardHeaders } from "@working-theory/api-client";
|
|
88
|
+
import { workspaceShownSetSchema } from "@working-theory/validation";
|
|
81
89
|
import { headers } from "next/headers";
|
|
82
90
|
const mockSubmit = leads.submit as jest.Mock;
|
|
91
|
+
const mockShown = offerings.shown as jest.Mock;
|
|
83
92
|
const mockHeaders = headers as jest.Mock;
|
|
84
93
|
|
|
85
94
|
afterEach(() => {
|
|
@@ -612,7 +621,22 @@ describe("captureLeadAction — the built-in form's source (#8747)", () => {
|
|
|
612
621
|
|
|
613
622
|
describe("captureLeadAction — the per-visitor limit (#8747)", () => {
|
|
614
623
|
const visitor = (ip: string) => ({
|
|
615
|
-
get: (name: string) => (name === "x-forwarded-for" ? ip : null),
|
|
624
|
+
get: (name: string) => (name === "x-vercel-forwarded-for" ? ip : null),
|
|
625
|
+
});
|
|
626
|
+
// On Vercel, where the edge-set address is read; the bot check has its own suite.
|
|
627
|
+
const ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
|
|
628
|
+
const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
629
|
+
|
|
630
|
+
beforeEach(() => {
|
|
631
|
+
process.env.VERCEL = "1";
|
|
632
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
afterEach(() => {
|
|
636
|
+
for (const [name, value] of priorEnv) {
|
|
637
|
+
if (value === undefined) delete process.env[name];
|
|
638
|
+
else process.env[name] = value;
|
|
639
|
+
}
|
|
616
640
|
});
|
|
617
641
|
|
|
618
642
|
it("admits 30 posts from one visitor in ten minutes, and refuses the 31st without calling the API", async () => {
|
|
@@ -633,17 +657,27 @@ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
|
|
|
633
657
|
expect(mockSubmit).toHaveBeenCalledTimes(30);
|
|
634
658
|
});
|
|
635
659
|
|
|
636
|
-
it("with no readable address, the limit
|
|
637
|
-
|
|
660
|
+
it("with no readable address, every post shares one bucket at ten times the limit, never skipped (#9967)", async () => {
|
|
661
|
+
// A client-sent x-forwarded-for is no address: it changes on every post.
|
|
662
|
+
let n = 0;
|
|
663
|
+
mockHeaders.mockImplementation(async () => {
|
|
664
|
+
n += 1;
|
|
665
|
+
return { get: (name: string) => (name === "x-forwarded-for" ? `10.0.${n >> 8}.${n & 255}` : null) };
|
|
666
|
+
});
|
|
638
667
|
mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
639
668
|
|
|
640
|
-
for (let i = 0; i <
|
|
669
|
+
for (let i = 0; i < 300; i += 1) {
|
|
641
670
|
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
642
671
|
kind: "success",
|
|
643
672
|
name: expect.any(String),
|
|
644
673
|
});
|
|
645
674
|
}
|
|
646
|
-
expect(mockSubmit).toHaveBeenCalledTimes(
|
|
675
|
+
expect(mockSubmit).toHaveBeenCalledTimes(300);
|
|
676
|
+
|
|
677
|
+
const refused = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
678
|
+
|
|
679
|
+
expect(refused).toMatchObject({ kind: "error", message: expect.any(String) });
|
|
680
|
+
expect(mockSubmit).toHaveBeenCalledTimes(300);
|
|
647
681
|
});
|
|
648
682
|
|
|
649
683
|
it("another visitor keeps its own budget", async () => {
|
|
@@ -786,7 +820,16 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
|
|
|
786
820
|
});
|
|
787
821
|
});
|
|
788
822
|
|
|
789
|
-
describe("captureLeadAction — a checkout magnet
|
|
823
|
+
describe("captureLeadAction — a checkout magnet answers the Offerings buy route after the write (#8757 D11, #9171)", () => {
|
|
824
|
+
// The shown-set read is shared for 15 seconds per instance, so each case
|
|
825
|
+
// starts a minute past the last one's window.
|
|
826
|
+
let clock = Date.now();
|
|
827
|
+
let now: jest.SpyInstance<number, []>;
|
|
828
|
+
beforeEach(() => {
|
|
829
|
+
clock += 60_000;
|
|
830
|
+
now = jest.spyOn(Date, "now").mockReturnValue(clock);
|
|
831
|
+
});
|
|
832
|
+
|
|
790
833
|
function answers(next: unknown): void {
|
|
791
834
|
mockSubmit.mockResolvedValueOnce({
|
|
792
835
|
ok: true,
|
|
@@ -794,18 +837,135 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
794
837
|
});
|
|
795
838
|
}
|
|
796
839
|
|
|
797
|
-
|
|
840
|
+
/** A shown set in `state`: one `plan` carrying a Price per `period`. */
|
|
841
|
+
function shownSet(
|
|
842
|
+
periods: readonly ("monthly" | "yearly" | "once")[],
|
|
843
|
+
plan = "retainer",
|
|
844
|
+
state = "current",
|
|
845
|
+
): unknown {
|
|
846
|
+
return workspaceShownSetSchema.partial({ workspaceId: true, organizationId: true }).parse({
|
|
847
|
+
productIds: [`prod_${plan}`],
|
|
848
|
+
recommended: { plan: null, addon: null },
|
|
849
|
+
offerings: [
|
|
850
|
+
{
|
|
851
|
+
productId: `prod_${plan}`,
|
|
852
|
+
plan,
|
|
853
|
+
name: plan,
|
|
854
|
+
description: null,
|
|
855
|
+
marketingFeatures: [],
|
|
856
|
+
unitLabel: null,
|
|
857
|
+
note: null,
|
|
858
|
+
termsNote: null,
|
|
859
|
+
ailkGroup: "plan",
|
|
860
|
+
ailkOrder: null,
|
|
861
|
+
prices: periods.map((period) => ({
|
|
862
|
+
priceId: `price_${plan}_${period}`,
|
|
863
|
+
lookupKey: `${plan}_${period}`,
|
|
864
|
+
plan,
|
|
865
|
+
period,
|
|
866
|
+
interval: { monthly: "month", yearly: "year", once: null }[period],
|
|
867
|
+
amount: 4900,
|
|
868
|
+
currency: "usd",
|
|
869
|
+
})),
|
|
870
|
+
recommended: false,
|
|
871
|
+
},
|
|
872
|
+
],
|
|
873
|
+
state,
|
|
874
|
+
snapshotAt: "2026-10-26T00:00:00.000Z",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
afterEach(() => {
|
|
879
|
+
mockShown.mockReset();
|
|
880
|
+
now.mockRestore();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
/** The shown-set read answers `body` (the api-client's unwrapped 2xx). */
|
|
884
|
+
function shows(body: unknown): void {
|
|
885
|
+
mockShown.mockResolvedValueOnce({ ok: true, status: 200, body });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
it("next: checkout, retainer → the success state carries /api/offerings/checkout/retainer_monthly, after exactly one write", async () => {
|
|
798
889
|
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
890
|
+
shows({ status: "shown", shownSet: shownSet(["monthly", "yearly"]) });
|
|
799
891
|
|
|
800
|
-
await
|
|
801
|
-
"
|
|
802
|
-
|
|
892
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
893
|
+
kind: "success",
|
|
894
|
+
name: "Jane Smith",
|
|
895
|
+
redirect: "/api/offerings/checkout/retainer_monthly",
|
|
896
|
+
});
|
|
803
897
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
804
|
-
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
805
|
-
expect(mockRedirect).toHaveBeenCalledWith("/api/checkout/retainer");
|
|
806
898
|
expect(mockSubmit.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
807
|
-
|
|
899
|
+
mockShown.mock.invocationCallOrder[0]!,
|
|
808
900
|
);
|
|
901
|
+
// The form navigates; never a Server Action redirect, and never the retired route.
|
|
902
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("one shown-set read serves every submit within 15 seconds", async () => {
|
|
906
|
+
const checkout = { completion: "checkout", checkoutOffering: "retainer" };
|
|
907
|
+
answers(checkout);
|
|
908
|
+
answers(checkout);
|
|
909
|
+
answers(checkout);
|
|
910
|
+
shows({ status: "shown", shownSet: shownSet(["monthly"]) });
|
|
911
|
+
shows({ status: "shown", shownSet: shownSet(["yearly"]) });
|
|
912
|
+
const submit = () => captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
913
|
+
|
|
914
|
+
expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_monthly" });
|
|
915
|
+
now.mockReturnValue(clock + 14_999);
|
|
916
|
+
expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_monthly" });
|
|
917
|
+
expect(mockShown).toHaveBeenCalledTimes(1);
|
|
918
|
+
now.mockReturnValue(clock + 15_000);
|
|
919
|
+
expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_yearly" });
|
|
920
|
+
expect(mockShown).toHaveBeenCalledTimes(2);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it.each([
|
|
924
|
+
[["yearly", "once"], "retainer_yearly"],
|
|
925
|
+
[["once"], "retainer_once"],
|
|
926
|
+
] as const)(
|
|
927
|
+
"the key /pricing's card links first: shown %j → %s",
|
|
928
|
+
async (periods, lookupKey) => {
|
|
929
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
930
|
+
shows({ status: "shown", shownSet: shownSet(periods) });
|
|
931
|
+
|
|
932
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toMatchObject({
|
|
933
|
+
redirect: `/api/offerings/checkout/${lookupKey}`,
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
it.each([
|
|
939
|
+
["a plan the shown set does not show", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "growth") })],
|
|
940
|
+
["an unconnected site", () => shows({ status: "unconnected" })],
|
|
941
|
+
["a shown set that is not current", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "retainer", "expired") })],
|
|
942
|
+
["a non-2xx read", () => mockShown.mockResolvedValueOnce({ ok: false, status: 503, body: null })],
|
|
943
|
+
["a thrown read", () => mockShown.mockRejectedValueOnce(new Error("network"))],
|
|
944
|
+
] as const)("%s → exactly { kind: success } and no buy route", async (_label, read) => {
|
|
945
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
946
|
+
read();
|
|
947
|
+
|
|
948
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
949
|
+
kind: "success",
|
|
950
|
+
name: expect.any(String),
|
|
951
|
+
});
|
|
952
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
953
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("a read with no answer within 5 seconds → exactly { kind: success } and no buy route", async () => {
|
|
957
|
+
jest.useFakeTimers({ doNotFake: ["nextTick", "setImmediate", "queueMicrotask", "Date"] });
|
|
958
|
+
try {
|
|
959
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
960
|
+
mockShown.mockReturnValueOnce(new Promise(() => {}));
|
|
961
|
+
|
|
962
|
+
const result = captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
963
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
964
|
+
expect(await result).toStrictEqual({ kind: "success", name: expect.any(String) });
|
|
965
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
966
|
+
} finally {
|
|
967
|
+
jest.useRealTimers();
|
|
968
|
+
}
|
|
809
969
|
});
|
|
810
970
|
|
|
811
971
|
it.each([
|
|
@@ -816,24 +976,26 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
816
976
|
{ ok: false, status: 503, body: { error: "lead_capture_not_configured" } },
|
|
817
977
|
"unavailable",
|
|
818
978
|
],
|
|
819
|
-
] as const)("a failed write (%s) → { kind: %s } and no
|
|
979
|
+
] as const)("a failed write (%s) → { kind: %s } and no buy route", async (_label, response, kind) => {
|
|
820
980
|
mockSubmit.mockResolvedValueOnce(response);
|
|
821
981
|
|
|
822
982
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
823
983
|
expect(result).toMatchObject({ kind });
|
|
984
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
824
985
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
825
986
|
});
|
|
826
987
|
|
|
827
|
-
it("a thrown write → { kind: unavailable } and no
|
|
988
|
+
it("a thrown write → { kind: unavailable } and no buy route", async () => {
|
|
828
989
|
mockSubmit.mockRejectedValueOnce(new Error("network"));
|
|
829
990
|
|
|
830
991
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
831
992
|
expect(result).toMatchObject({ kind: "unavailable" });
|
|
993
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
832
994
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
833
995
|
});
|
|
834
996
|
|
|
835
997
|
it.each(["../x", "/evil", "Retainer", "has_underscore", "a".repeat(41), ""])(
|
|
836
|
-
"checkoutOffering %j → exactly { kind: success } and no
|
|
998
|
+
"checkoutOffering %j → exactly { kind: success }, no shown-set read and no buy route",
|
|
837
999
|
async (checkoutOffering) => {
|
|
838
1000
|
answers({ completion: "checkout", checkoutOffering });
|
|
839
1001
|
|
|
@@ -841,27 +1003,12 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
841
1003
|
kind: "success",
|
|
842
1004
|
name: expect.any(String),
|
|
843
1005
|
});
|
|
1006
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
844
1007
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
845
1008
|
},
|
|
846
1009
|
);
|
|
847
1010
|
|
|
848
|
-
it("
|
|
849
|
-
mockTopology = "hub";
|
|
850
|
-
try {
|
|
851
|
-
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
852
|
-
|
|
853
|
-
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
854
|
-
kind: "success",
|
|
855
|
-
name: expect.any(String),
|
|
856
|
-
});
|
|
857
|
-
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
858
|
-
expect(mockRedirect).not.toHaveBeenCalled();
|
|
859
|
-
} finally {
|
|
860
|
-
mockTopology = "spoke";
|
|
861
|
-
}
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
it("an invite still answers the account link and never redirects", async () => {
|
|
1011
|
+
it("an invite still answers the account link and no buy route", async () => {
|
|
865
1012
|
answers({ completion: "invite" });
|
|
866
1013
|
|
|
867
1014
|
const result = await captureLeadAction(
|
|
@@ -869,6 +1016,86 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
869
1016
|
makeFormData({ ...VALID_FIELDS, email: "a@b.example" }),
|
|
870
1017
|
);
|
|
871
1018
|
expect(result).toMatchObject({ kind: "success", next: { label: "Track this in your account" } });
|
|
1019
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
872
1020
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
873
1021
|
});
|
|
874
1022
|
});
|
|
1023
|
+
|
|
1024
|
+
// #9255 D4 — the write bot check runs after the honeypot, the consent and
|
|
1025
|
+
// the schema, immediately before the write (spec
|
|
1026
|
+
// docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
|
|
1027
|
+
describe("captureLeadAction — the write bot check (#9255)", () => {
|
|
1028
|
+
const BOT = { isHuman: false, isBot: true, isVerifiedBot: false, bypassed: false };
|
|
1029
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
1030
|
+
const BOT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK", "AILK_BOOKING_BOT_CHECK"] as const;
|
|
1031
|
+
const priorBotEnv = new Map(BOT_ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
1032
|
+
|
|
1033
|
+
let botLogSpies: jest.SpyInstance[] = [];
|
|
1034
|
+
|
|
1035
|
+
beforeEach(() => {
|
|
1036
|
+
checkBotIdMock.mockReset();
|
|
1037
|
+
for (const name of BOT_ENV_KEYS) delete process.env[name];
|
|
1038
|
+
process.env.VERCEL = "1";
|
|
1039
|
+
botLogSpies = [
|
|
1040
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined),
|
|
1041
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined),
|
|
1042
|
+
];
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
afterEach(() => {
|
|
1046
|
+
for (const [name, value] of priorBotEnv) {
|
|
1047
|
+
if (value === undefined) delete process.env[name];
|
|
1048
|
+
else process.env[name] = value;
|
|
1049
|
+
}
|
|
1050
|
+
for (const spy of botLogSpies) spy.mockRestore();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
const OK = { ok: true, body: { id: "lead_abc123", persisted: "db" } };
|
|
1054
|
+
|
|
1055
|
+
it("a bot gets the generic error, and nothing is sent", async () => {
|
|
1056
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1057
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
1058
|
+
expect(result).toStrictEqual({ kind: "error", message: "Something went wrong, please try again." });
|
|
1059
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1060
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("a check that fails is unavailable, and nothing is sent", async () => {
|
|
1064
|
+
checkBotIdMock.mockRejectedValue(new Error("boom"));
|
|
1065
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
1066
|
+
expect(result).toStrictEqual({ kind: "unavailable" });
|
|
1067
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("a human sends exactly what the check off sends", async () => {
|
|
1071
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
1072
|
+
mockSubmit.mockResolvedValue(OK);
|
|
1073
|
+
delete process.env.VERCEL;
|
|
1074
|
+
await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
1075
|
+
process.env.VERCEL = "1";
|
|
1076
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toMatchObject({
|
|
1077
|
+
kind: "success",
|
|
1078
|
+
});
|
|
1079
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1080
|
+
expect(mockSubmit).toHaveBeenCalledTimes(2);
|
|
1081
|
+
expect(mockSubmit.mock.calls[1]).toEqual(mockSubmit.mock.calls[0]);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("a filled honeypot answers success with no check", async () => {
|
|
1085
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1086
|
+
const result = await captureLeadAction(
|
|
1087
|
+
INITIAL_STATE,
|
|
1088
|
+
makeFormData({ ...VALID_FIELDS, ailk_hp: "spam" }),
|
|
1089
|
+
);
|
|
1090
|
+
expect(result).toEqual({ kind: "success" });
|
|
1091
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("a post without consent answers the consent error with no check", async () => {
|
|
1095
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1096
|
+
const { consent: _consent, ...unticked } = VALID_FIELDS;
|
|
1097
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(unticked));
|
|
1098
|
+
expect(result).toEqual({ kind: "error", fieldErrors: { consent: expect.any(String) } });
|
|
1099
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* calls per 10 minutes per visitor address, before the write. From a server
|
|
17
17
|
* action the hub sees this site's egress address, so one visitor could
|
|
18
18
|
* otherwise drain the hub's per-IP budget for every visitor of the site. With
|
|
19
|
-
* no readable address the
|
|
20
|
-
* own per-IP limit on the
|
|
19
|
+
* no readable address the post is counted in one shared bucket at ten times
|
|
20
|
+
* the limit, never skipped (#9967). The hub keeps its own per-IP limit on the
|
|
21
|
+
* route.
|
|
21
22
|
*
|
|
22
23
|
* The write (#8747): every lead goes through `writeLead`, the site's lead
|
|
23
24
|
* writer (`src/lib/lead-write.ts`), which marks it `payload.site =
|
|
@@ -68,6 +69,11 @@
|
|
|
68
69
|
* requires of a contact lead and records on it); an API `400
|
|
69
70
|
* consent_required` is shown as the same field error.
|
|
70
71
|
*
|
|
72
|
+
* The bot check (#9255 D4): after the honeypot, consent, schema and
|
|
73
|
+
* per-visitor refusals and immediately before the write, `runBotCheck`
|
|
74
|
+
* (lib/bot-check.ts) asks Vercel BotID for a verdict. A bot gets the generic
|
|
75
|
+
* error and a check that cannot decide `unavailable`; neither is sent.
|
|
76
|
+
*
|
|
71
77
|
* Visitor forwarding (#7394): on a spoke whose API is a hub, the call
|
|
72
78
|
* carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
|
|
73
79
|
* (server-only, never `NEXT_PUBLIC_`) via `visitorForwardHeaders`, so the
|
|
@@ -86,35 +92,43 @@
|
|
|
86
92
|
* be read — answers exactly `{ kind: "success" }`: a written lead is never
|
|
87
93
|
* reported as a failure.
|
|
88
94
|
*
|
|
89
|
-
* Continue to checkout (#8757 D11): `{ completion: "checkout",
|
|
95
|
+
* Continue to checkout (#8757 D11, #9171): `{ completion: "checkout",
|
|
90
96
|
* checkoutOffering }` — the magnet sends the submitter on to the offering's
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* `
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
97
|
+
* checkout — answers `{ kind: "success", redirect:
|
|
98
|
+
* "/api/offerings/checkout/<lookupKey>" }`, the Offerings buy route every
|
|
99
|
+
* `/pricing` card links; the legacy `/api/checkout/<offering>` is retired
|
|
100
|
+
* (#9142). The form hard-navigates there (`LeadCaptureForm`'s `redirect`),
|
|
101
|
+
* because the route starts a session only for a top-level navigation and a
|
|
102
|
+
* Server Action `redirect()` is fetched as RSC, which it sends to `/pricing`.
|
|
103
|
+
* The stored value is a `{plan}`, so the key is the one that plan's
|
|
104
|
+
* `/pricing` card links first: the first of `<plan>_monthly`,
|
|
105
|
+
* `<plan>_yearly` and `<plan>_once` the shown set shows, read through
|
|
106
|
+
* `offerings.shown()` within 5 seconds and shared per instance for 15, as
|
|
107
|
+
* the buy route reads it. It runs only after the write resolved `ok`. The
|
|
108
|
+
* value is re-checked with `flowCheckoutOfferingSchema` first; one that
|
|
109
|
+
* fails, a plan the shown set does not show, or a read that fails or times
|
|
110
|
+
* out answers `{ kind: "success" }`, as `lead` does.
|
|
111
|
+
* A failed write never answers the buy route.
|
|
99
112
|
*/
|
|
100
113
|
|
|
101
|
-
import { visitorForwardHeaders } from "@working-theory/api-client";
|
|
114
|
+
import { offerings, visitorForwardHeaders } from "@working-theory/api-client";
|
|
102
115
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
103
116
|
import {
|
|
104
117
|
CONTACT_LEAD_SOURCES,
|
|
105
118
|
flowCheckoutOfferingSchema,
|
|
106
119
|
leadSubmitNextSchema,
|
|
120
|
+
LOOKUP_KEY_PERIODS,
|
|
107
121
|
} from "@working-theory/validation";
|
|
108
122
|
import { cookies, headers } from "next/headers";
|
|
109
|
-
import { redirect } from "next/navigation";
|
|
110
123
|
import { getLocale, getTranslations } from "next-intl/server";
|
|
111
124
|
import { z } from "zod";
|
|
112
125
|
|
|
113
126
|
import { accountInviteHref } from "../../../lib/account-invite";
|
|
127
|
+
import { runBotCheck } from "../../../lib/bot-check";
|
|
114
128
|
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
115
|
-
import {
|
|
129
|
+
import { isShownLookupKey, offeringsCheckoutHref } from "../../../lib/offerings-pricing";
|
|
116
130
|
import { writeLead } from "../../../src/lib/lead-write";
|
|
117
|
-
import {
|
|
131
|
+
import { takeVisitorLimit } from "../../../src/lib/rate-limit";
|
|
118
132
|
|
|
119
133
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
120
134
|
const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
|
|
@@ -122,6 +136,8 @@ const RATE_LIMITED = "Too many messages from here. Please try again in a few min
|
|
|
122
136
|
/** The per-visitor budget (#8747): the consumer sites' own values. */
|
|
123
137
|
const RATE_LIMIT_MAX_CALLS = 30;
|
|
124
138
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
139
|
+
/** Outside a request scope there is no address: the shared bucket. */
|
|
140
|
+
const NO_HEADERS = { get: () => null };
|
|
125
141
|
|
|
126
142
|
/** Field names the action reads itself — never copied into `payload`. */
|
|
127
143
|
const RESERVED_FIELDS = new Set([
|
|
@@ -196,16 +212,54 @@ function isApiUnreachable(res: { status: number }): boolean {
|
|
|
196
212
|
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
197
213
|
}
|
|
198
214
|
|
|
215
|
+
/** The buy route's own bounds on its shown-set read. */
|
|
216
|
+
const SHOWN_READ_TIMEOUT_MS = 5_000;
|
|
217
|
+
const SHOWN_READ_TTL_MS = 15_000;
|
|
218
|
+
|
|
219
|
+
/** The shown set `/pricing` renders from, or `null`: any other answer, a throw, or none in time. */
|
|
220
|
+
async function readShownSet(): Promise<unknown> {
|
|
221
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
222
|
+
const late = new Promise<null>((resolve) => {
|
|
223
|
+
timer = setTimeout(() => resolve(null), SHOWN_READ_TIMEOUT_MS);
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
const res = await Promise.race([offerings.shown(), late]);
|
|
227
|
+
return res?.ok && res.body.status === "shown" ? res.body.shownSet : null;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let shownRead: { at: number; read: Promise<unknown> } | undefined;
|
|
236
|
+
|
|
199
237
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
238
|
+
* One read per instance per SHOWN_READ_TTL_MS: `offerings.shown()` forwards
|
|
239
|
+
* no visitor header, so a read per submit would count every visitor against
|
|
240
|
+
* this server's one per-IP limit at the API. Failed reads are shared too.
|
|
202
241
|
*/
|
|
203
|
-
function
|
|
204
|
-
|
|
242
|
+
function sharedShownSet(): Promise<unknown> {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
if (shownRead && now - shownRead.at < SHOWN_READ_TTL_MS) return shownRead.read;
|
|
245
|
+
shownRead = { at: now, read: readShownSet() };
|
|
246
|
+
return shownRead.read;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* #8757 D11, #9171 — the Offerings buy route for the plan a `checkout` answer
|
|
251
|
+
* names: its first shown key in `/pricing`'s card order; else `null`.
|
|
252
|
+
*/
|
|
253
|
+
async function checkoutHrefOf(next: unknown): Promise<string | null> {
|
|
205
254
|
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
206
255
|
if (!parsed.success || parsed.data.completion !== "checkout") return null;
|
|
207
|
-
const
|
|
208
|
-
|
|
256
|
+
const plan = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
|
|
257
|
+
if (!plan.success) return null;
|
|
258
|
+
const shownSet = await sharedShownSet();
|
|
259
|
+
const key = LOOKUP_KEY_PERIODS.map((period) => `${plan.data}_${period}`).find((k) =>
|
|
260
|
+
isShownLookupKey(shownSet, k),
|
|
261
|
+
);
|
|
262
|
+
return key === undefined ? null : offeringsCheckoutHref(key);
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
@@ -294,13 +348,11 @@ export async function captureLeadAction(
|
|
|
294
348
|
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
295
349
|
}
|
|
296
350
|
|
|
297
|
-
// No readable address:
|
|
298
|
-
//
|
|
299
|
-
// per-IP limit still applies.
|
|
300
|
-
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
351
|
+
// No readable address: the shared, wider bucket, never a skip (#9967 D1).
|
|
352
|
+
// The hub's own per-IP limit still applies.
|
|
301
353
|
if (
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
!takeVisitorLimit("contact", requestHeaders ?? NO_HEADERS, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
355
|
+
.allowed
|
|
304
356
|
) {
|
|
305
357
|
return { kind: "error", message: RATE_LIMITED };
|
|
306
358
|
}
|
|
@@ -321,6 +373,12 @@ export async function captureLeadAction(
|
|
|
321
373
|
// No request scope to read — submit with no visitor-link header.
|
|
322
374
|
}
|
|
323
375
|
|
|
376
|
+
// #9255 D4 — the spoke edge's bot check, after every local refusal and
|
|
377
|
+
// immediately before the write.
|
|
378
|
+
const botCheck = await runBotCheck({ source: "bot-check", target: "captureLeadAction", scope: "write" });
|
|
379
|
+
if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
|
|
380
|
+
if (botCheck === "unavailable") return { kind: "unavailable" };
|
|
381
|
+
|
|
324
382
|
// writeLead never throws: a thrown submit is logged there by error name
|
|
325
383
|
// only, with no field values (#4042), and resolves as `status: 0`.
|
|
326
384
|
const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
|
|
@@ -331,9 +389,9 @@ export async function captureLeadAction(
|
|
|
331
389
|
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
332
390
|
return { kind: "error", message: GENERIC_ERROR };
|
|
333
391
|
}
|
|
334
|
-
// #
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
392
|
+
// #9171 — the form navigates: the buy route refuses a Server Action redirect.
|
|
393
|
+
const checkoutHref = await checkoutHrefOf(res.body.next);
|
|
394
|
+
if (checkoutHref !== null) return { kind: "success", name, redirect: checkoutHref };
|
|
337
395
|
// #8847 D7 — the parsed name, which the magnet's ending fills `{name}` with.
|
|
338
396
|
return { ...(await successState(res.body.next, email)), name };
|
|
339
397
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Course detail page. Reads the Course MDX for the given slug, renders via
|
|
2
2
|
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
|
|
3
3
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getMdxSource as loadMdxSource,
|
|
6
|
+
renderPage,
|
|
7
|
+
renderPageMetadata,
|
|
8
|
+
} from "@working-theory/page-renderer";
|
|
5
9
|
import type { Metadata } from "next";
|
|
6
10
|
import type { ReactElement } from "react";
|
|
7
11
|
|
|
@@ -22,12 +26,25 @@ export default async function Page({
|
|
|
22
26
|
getMdxSource,
|
|
23
27
|
}: Props): Promise<ReactElement> {
|
|
24
28
|
const { locale, slug } = await params;
|
|
29
|
+
// A page that composes `sections[]` renders them (JSON-LD is built from
|
|
30
|
+
// the same sections); a prose page renders its MDX body — the rule
|
|
31
|
+
// /vs/[slug] applies (#10122).
|
|
32
|
+
const source = await (getMdxSource ?? loadMdxSource)("Course", slug, locale);
|
|
33
|
+
const composed =
|
|
34
|
+
source !== null &&
|
|
35
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
36
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
37
|
+
0;
|
|
25
38
|
return renderPage({
|
|
26
39
|
type: "Course",
|
|
27
40
|
slug,
|
|
28
41
|
locale,
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
...(composed
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
bodyMode: "mdx-body" as const,
|
|
46
|
+
populatedDescriptionTestId: "course-description",
|
|
47
|
+
}),
|
|
31
48
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
49
|
});
|
|
33
50
|
}
|