create-ailk 0.27.0 → 0.28.1
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/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -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/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- 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 +224 -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 +237 -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 +279 -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/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /[locale]/account/connect — the hub's consent page (#9966 D2;
|
|
3
|
+
* AC-4).
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. A ref that opens and a client the registry answers for renders the
|
|
7
|
+
* spoke's registry name, its origin and the signed-in email, and one
|
|
8
|
+
* form posting `ref` alone to `/api/auth/hub/authorize`.
|
|
9
|
+
* 2. A forged ref, a missing ref, a client the registry does not answer for
|
|
10
|
+
* and a ref whose `redirect_uri` is on another origin each render
|
|
11
|
+
* `hubConnect.unavailable` and no form.
|
|
12
|
+
* 3. `generateMetadata` answers `referrer: "same-origin"` and `noindex`.
|
|
13
|
+
* 4. A spoke build is a 404.
|
|
14
|
+
*/
|
|
15
|
+
import { render, screen } from "@testing-library/react";
|
|
16
|
+
|
|
17
|
+
jest.mock("next-intl/server", () => {
|
|
18
|
+
const msgs: unknown = require("../../../../../../messages/en.json");
|
|
19
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
20
|
+
path.split(".").reduce<unknown>(
|
|
21
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
22
|
+
obj,
|
|
23
|
+
);
|
|
24
|
+
return {
|
|
25
|
+
__esModule: true,
|
|
26
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
27
|
+
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
28
|
+
const messages = dig(msgs, ns);
|
|
29
|
+
return (key: string, values?: Record<string, unknown>): string => {
|
|
30
|
+
const value = dig(messages, key);
|
|
31
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
32
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
33
|
+
values && name in values ? String(values[name]) : m,
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
jest.mock("next/headers", () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
43
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const mockSecret = "k".repeat(43);
|
|
47
|
+
const mockGetSession = jest.fn();
|
|
48
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
|
|
49
|
+
jest.mock("../../../../../../auth", () => ({
|
|
50
|
+
__esModule: true,
|
|
51
|
+
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
52
|
+
resolveSecret: () => mockSecret,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const mockClientInfo = jest.fn();
|
|
56
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
57
|
+
__esModule: true,
|
|
58
|
+
hubSignIn: { clientInfo: (...args: unknown[]) => mockClientInfo(...args) },
|
|
59
|
+
subscription: { me: jest.fn(), portalSession: jest.fn() },
|
|
60
|
+
visitorForwardHeaders: () => undefined,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
class NotFound extends Error {}
|
|
64
|
+
jest.mock("next/navigation", () => ({
|
|
65
|
+
__esModule: true,
|
|
66
|
+
notFound: () => {
|
|
67
|
+
throw new NotFound();
|
|
68
|
+
},
|
|
69
|
+
redirect: jest.fn(),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
|
|
73
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
74
|
+
jest.mock("../../../../../../lib/topology.generated", () => {
|
|
75
|
+
const real = jest.requireActual("../../../../../../lib/topology.generated") as Record<string, unknown>;
|
|
76
|
+
return {
|
|
77
|
+
...real,
|
|
78
|
+
__esModule: true,
|
|
79
|
+
get TOPOLOGY() {
|
|
80
|
+
return mockTopology.value;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
import { mintContinuation } from "../../../../../api/auth/hub/continuation";
|
|
86
|
+
import Page, { generateMetadata } from "../page";
|
|
87
|
+
|
|
88
|
+
const SPOKE_ORIGIN = "https://spoke.example.com";
|
|
89
|
+
const SPOKE_CALLBACK = `${SPOKE_ORIGIN}/api/hub-session/callback`;
|
|
90
|
+
|
|
91
|
+
function query(overrides: Record<string, string> = {}): string {
|
|
92
|
+
return new URLSearchParams({
|
|
93
|
+
response_type: "code",
|
|
94
|
+
client_id: "spoke_abc",
|
|
95
|
+
redirect_uri: SPOKE_CALLBACK,
|
|
96
|
+
state: "s".repeat(43),
|
|
97
|
+
code_challenge: "c".repeat(43),
|
|
98
|
+
code_challenge_method: "S256",
|
|
99
|
+
...overrides,
|
|
100
|
+
}).toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SESSION = {
|
|
104
|
+
user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
mockTopology.value = "hub";
|
|
109
|
+
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
110
|
+
mockClientInfo.mockReset().mockResolvedValue({
|
|
111
|
+
ok: true,
|
|
112
|
+
body: { clientId: "spoke_abc", name: "Acme Studio", origin: SPOKE_ORIGIN },
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
async function renderPage(ref?: string | string[]): Promise<void> {
|
|
117
|
+
const element = await Page({
|
|
118
|
+
params: Promise.resolve({ locale: "en" }),
|
|
119
|
+
searchParams: Promise.resolve(ref === undefined ? {} : { ref }),
|
|
120
|
+
});
|
|
121
|
+
render(element);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function expectUnavailable(): void {
|
|
125
|
+
expect(screen.getByTestId("hub-connect-unavailable")).toBeTruthy();
|
|
126
|
+
expect(screen.getByText("This sign-in can't continue")).toBeTruthy();
|
|
127
|
+
expect(screen.queryByTestId("hub-connect-form")).toBeNull();
|
|
128
|
+
expect(document.querySelector("form")).toBeNull();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("the consent page (#9966 D2, AC-4)", () => {
|
|
132
|
+
it("names the spoke from the registry and posts the ref alone to the authorize route", async () => {
|
|
133
|
+
const ref = mintContinuation(query(), mockSecret);
|
|
134
|
+
|
|
135
|
+
await renderPage(ref);
|
|
136
|
+
|
|
137
|
+
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Sign in to Acme Studio?");
|
|
138
|
+
expect(screen.getByText(/https:\/\/spoke\.example\.com will see your name/).textContent).toContain(
|
|
139
|
+
"ada@example.com",
|
|
140
|
+
);
|
|
141
|
+
expect(mockClientInfo).toHaveBeenCalledWith("spoke_abc");
|
|
142
|
+
const form = screen.getByTestId("hub-connect-form") as HTMLFormElement;
|
|
143
|
+
expect(form.getAttribute("method")).toBe("post");
|
|
144
|
+
expect(form.getAttribute("action")).toBe("/api/auth/hub/authorize");
|
|
145
|
+
const fields = Array.from(form.querySelectorAll("input"));
|
|
146
|
+
expect(fields.map((f) => [f.type, f.name, f.value])).toEqual([["hidden", "ref", ref]]);
|
|
147
|
+
expect(screen.getByTestId("hub-connect-continue").textContent).toBe("Continue");
|
|
148
|
+
expect(screen.getByTestId("hub-connect-cancel").getAttribute("href")).toBe("/account");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it.each([
|
|
152
|
+
["a forged ref", () => "x".repeat(80)],
|
|
153
|
+
["no ref", () => undefined],
|
|
154
|
+
["two refs", () => [mintContinuation(query(), mockSecret), mintContinuation(query(), mockSecret)]],
|
|
155
|
+
["a ref sealing a query that fails the schema", () => mintContinuation(query({ code_challenge_method: "plain" }), mockSecret)],
|
|
156
|
+
])("%s renders unavailable and no form", async (_name, ref) => {
|
|
157
|
+
await renderPage(ref());
|
|
158
|
+
expectUnavailable();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("a client the registry does not answer for renders unavailable and no form", async () => {
|
|
162
|
+
mockClientInfo.mockResolvedValue({ ok: false, status: 404, body: null });
|
|
163
|
+
await renderPage(mintContinuation(query(), mockSecret));
|
|
164
|
+
expectUnavailable();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("a registry read that throws renders unavailable and no form", async () => {
|
|
168
|
+
mockClientInfo.mockRejectedValue(new Error("network"));
|
|
169
|
+
await renderPage(mintContinuation(query(), mockSecret));
|
|
170
|
+
expectUnavailable();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("a ref whose redirect_uri is on another origin than the registry names renders unavailable (SEC-9966-10)", async () => {
|
|
174
|
+
await renderPage(
|
|
175
|
+
mintContinuation(query({ redirect_uri: "https://other.example.com/api/hub-session/callback" }), mockSecret),
|
|
176
|
+
);
|
|
177
|
+
expectUnavailable();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("generateMetadata keeps the ref out of a cross-site Referer and the page out of the index", async () => {
|
|
181
|
+
const metadata = await generateMetadata({
|
|
182
|
+
params: Promise.resolve({ locale: "en" }),
|
|
183
|
+
searchParams: Promise.resolve({}),
|
|
184
|
+
});
|
|
185
|
+
expect(metadata).toEqual({
|
|
186
|
+
title: "Sign in to a site",
|
|
187
|
+
robots: { index: false, follow: false },
|
|
188
|
+
referrer: "same-origin",
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("a spoke build is a 404", async () => {
|
|
193
|
+
mockTopology.value = "spoke";
|
|
194
|
+
await expect(renderPage(mintContinuation(query(), mockSecret))).rejects.toBeInstanceOf(NotFound);
|
|
195
|
+
expect(mockClientInfo).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/connect — the hub asks a person once per spoke before it
|
|
3
|
+
* tells that spoke who they are (#9966 D2, #7978).
|
|
4
|
+
*
|
|
5
|
+
* The authorize step (`app/api/auth/hub/authorize/route.ts`) sends a signed-in
|
|
6
|
+
* person here when the API answers `consent_required`, with the six validated
|
|
7
|
+
* authorize keys sealed as `ref`. This page opens the ref, names the spoke
|
|
8
|
+
* from the registry (`hubSignIn.clientInfo`) — never from the query — and
|
|
9
|
+
* offers one Continue: a same-origin form POST of `ref` back to the authorize
|
|
10
|
+
* route, which alone sends `consent: true`. Cancel goes to `/account`.
|
|
11
|
+
*
|
|
12
|
+
* The form renders only when the ref's `redirect_uri` is on the origin the
|
|
13
|
+
* registry names, so the page never names one site while the code goes to
|
|
14
|
+
* another. Anything that does not hold — a ref that does not open, a client
|
|
15
|
+
* the registry does not answer for — renders `hubConnect.unavailable` and no
|
|
16
|
+
* form. A spoke carries no hub routes, so a spoke build is a 404. `noindex`,
|
|
17
|
+
* and `referrer: same-origin`, which keeps the ref out of any cross-site
|
|
18
|
+
* Referer while the browser still sends the real `Origin` on Continue.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
22
|
+
import { localeHref } from "@working-theory/ui";
|
|
23
|
+
import { Button, Heading, Input, Link, Text } from "@working-theory/ui/primitives";
|
|
24
|
+
import { hubAuthorizeQuerySchema, type HubAuthorizeQuery } from "@working-theory/validation";
|
|
25
|
+
import type { Metadata } from "next";
|
|
26
|
+
import { notFound } from "next/navigation";
|
|
27
|
+
import { getTranslations } from "next-intl/server";
|
|
28
|
+
import type { ReactElement } from "react";
|
|
29
|
+
|
|
30
|
+
import { resolveSecret } from "../../../../../auth";
|
|
31
|
+
import { TOPOLOGY } from "../../../../../lib/topology.generated";
|
|
32
|
+
import { openContinuation } from "../../../../api/auth/hub/continuation";
|
|
33
|
+
import { accountSession } from "../account-data";
|
|
34
|
+
|
|
35
|
+
/** The authorize route's POST — the one request that says yes. */
|
|
36
|
+
const AUTHORIZE_PATH = "/api/auth/hub/authorize";
|
|
37
|
+
|
|
38
|
+
const QUERY_KEYS = [
|
|
39
|
+
"response_type",
|
|
40
|
+
"client_id",
|
|
41
|
+
"redirect_uri",
|
|
42
|
+
"state",
|
|
43
|
+
"code_challenge",
|
|
44
|
+
"code_challenge_method",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
interface Props {
|
|
48
|
+
params: Promise<{ locale: string }>;
|
|
49
|
+
searchParams: Promise<{ ref?: string | string[] }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
53
|
+
const { locale } = await params;
|
|
54
|
+
const t = await getTranslations({ locale, namespace: "hubConnect" });
|
|
55
|
+
return {
|
|
56
|
+
title: t("metaTitle"),
|
|
57
|
+
robots: { index: false, follow: false },
|
|
58
|
+
referrer: "same-origin",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The sealed authorize query, or `null` when the ref does not open or parse. */
|
|
63
|
+
function openQuery(ref: string | string[] | undefined): HubAuthorizeQuery | null {
|
|
64
|
+
if (typeof ref !== "string") return null;
|
|
65
|
+
const search = openContinuation(ref, resolveSecret());
|
|
66
|
+
if (search === null) return null;
|
|
67
|
+
const params = new URLSearchParams(search);
|
|
68
|
+
const raw: Record<string, string | undefined> = {};
|
|
69
|
+
for (const key of QUERY_KEYS) raw[key] = params.get(key) ?? undefined;
|
|
70
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
71
|
+
return parsed.success ? parsed.data : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function originOf(url: string): string | null {
|
|
75
|
+
try {
|
|
76
|
+
return new URL(url).origin;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The spoke's registry name and origin, or `null` when it cannot be read. */
|
|
83
|
+
async function readClient(clientId: string): Promise<{ name: string; origin: string } | null> {
|
|
84
|
+
try {
|
|
85
|
+
const res = await hubSignIn.clientInfo(clientId);
|
|
86
|
+
return res.ok ? { name: res.body.name, origin: res.body.origin } : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default async function HubConnectPage({ params, searchParams }: Props): Promise<ReactElement> {
|
|
93
|
+
if (TOPOLOGY === "spoke") notFound();
|
|
94
|
+
const { locale } = await params;
|
|
95
|
+
const { ref } = await searchParams;
|
|
96
|
+
const t = await getTranslations({ locale, namespace: "hubConnect" });
|
|
97
|
+
|
|
98
|
+
const query = openQuery(ref);
|
|
99
|
+
const client = query ? await readClient(query.client_id) : null;
|
|
100
|
+
const session = await accountSession(locale);
|
|
101
|
+
const ready =
|
|
102
|
+
typeof ref === "string" &&
|
|
103
|
+
query !== null &&
|
|
104
|
+
client !== null &&
|
|
105
|
+
session !== null &&
|
|
106
|
+
originOf(query.redirect_uri) === client.origin;
|
|
107
|
+
|
|
108
|
+
if (!ready) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex max-w-3xl flex-col gap-6 py-8" data-testid="hub-connect-unavailable">
|
|
111
|
+
<div>
|
|
112
|
+
<Heading level={1} visual={2}>
|
|
113
|
+
{t("unavailable.title")}
|
|
114
|
+
</Heading>
|
|
115
|
+
<Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
|
|
116
|
+
{t("unavailable.body")}
|
|
117
|
+
</Text>
|
|
118
|
+
</div>
|
|
119
|
+
<Link href={localeHref(locale, "/account")} data-testid="hub-connect-back">
|
|
120
|
+
{t("unavailable.back")}
|
|
121
|
+
</Link>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="hub-connect">
|
|
128
|
+
<div>
|
|
129
|
+
<Heading level={1} visual={2}>
|
|
130
|
+
{t("title", { name: client.name })}
|
|
131
|
+
</Heading>
|
|
132
|
+
<Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
|
|
133
|
+
{t("lead", { origin: client.origin, email: session.ctx.user.email })}
|
|
134
|
+
</Text>
|
|
135
|
+
</div>
|
|
136
|
+
<form
|
|
137
|
+
method="post"
|
|
138
|
+
action={AUTHORIZE_PATH}
|
|
139
|
+
className="flex flex-wrap items-center gap-4"
|
|
140
|
+
data-testid="hub-connect-form"
|
|
141
|
+
>
|
|
142
|
+
<Input type="hidden" name="ref" value={ref} />
|
|
143
|
+
<Button type="submit" size="big" data-testid="hub-connect-continue">
|
|
144
|
+
{t("continue")}
|
|
145
|
+
</Button>
|
|
146
|
+
<Link href={localeHref(locale, "/account")} data-testid="hub-connect-cancel">
|
|
147
|
+
{t("cancel")}
|
|
148
|
+
</Link>
|
|
149
|
+
</form>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -329,7 +329,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
|
|
|
329
329
|
|
|
330
330
|
it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
|
|
331
331
|
const stubAdapter = makeStubAdapter();
|
|
332
|
-
const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter });
|
|
332
|
+
const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
|
|
333
333
|
const response = await GET();
|
|
334
334
|
const text = await response.text();
|
|
335
335
|
const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
|
|
@@ -341,7 +341,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
|
|
|
341
341
|
|
|
342
342
|
it("llms.txt: names a URL ending in /about/sam-henry", async () => {
|
|
343
343
|
const stubAdapter = makeStubAdapter();
|
|
344
|
-
const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter });
|
|
344
|
+
const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
|
|
345
345
|
const response = await GET();
|
|
346
346
|
const text = await response.text();
|
|
347
347
|
expect(text).toContain("URL: https://site.example/about/sam-henry");
|
|
@@ -21,6 +21,16 @@
|
|
|
21
21
|
* are shared across requests for 15 seconds per server instance, as
|
|
22
22
|
* /contact's are (#8731). Full-screen (`isMagnetWalkPath` matches the path),
|
|
23
23
|
* so it draws its own back arrow, and `noindex, follow` like the walk.
|
|
24
|
+
*
|
|
25
|
+
* #10054 D4 — a posting may name its magnet (`applicationFlow`). The page
|
|
26
|
+
* reads it through the same shared read, keyed per flow (the keys are only
|
|
27
|
+
* slugs content names: the posting must exist before any read), and renders
|
|
28
|
+
* its step 1 when it maps; the submission then records the flow beside the
|
|
29
|
+
* posting (`apply.actions.ts` D6). A flow the hub answers absent — any non-ok
|
|
30
|
+
* answer below 500, as the walk's `readMagnet` reads it — or whose step 1
|
|
31
|
+
* does not map falls back to the Job application and is logged with the two
|
|
32
|
+
* slugs. A failed read (5xx, a throw) falls back silently, as a down hub
|
|
33
|
+
* always has.
|
|
24
34
|
*/
|
|
25
35
|
import { flows, leads } from "@working-theory/api-client";
|
|
26
36
|
import { flowRouteMetadata } from "@working-theory/metadata";
|
|
@@ -33,6 +43,7 @@ import {
|
|
|
33
43
|
} from "@working-theory/ui";
|
|
34
44
|
import { NavBackLink } from "@working-theory/ui/chrome";
|
|
35
45
|
import {
|
|
46
|
+
flowSlugSchema,
|
|
36
47
|
JOB_APPLICATION_FLOW_SLUG,
|
|
37
48
|
type FlowConfig,
|
|
38
49
|
} from "@working-theory/validation";
|
|
@@ -41,7 +52,7 @@ import { notFound } from "next/navigation";
|
|
|
41
52
|
import { getTranslations } from "next-intl/server";
|
|
42
53
|
import type { ReactElement } from "react";
|
|
43
54
|
|
|
44
|
-
import {
|
|
55
|
+
import { postingForApply, type ApplyPosting } from "../../../../../lib/posting-title";
|
|
45
56
|
import { contactFormText } from "../../../contact/contact-form-text";
|
|
46
57
|
import { readSiteContactEmail } from "../../../contact/site-contact-email";
|
|
47
58
|
import { captureLeadAction } from "../apply.actions";
|
|
@@ -94,51 +105,91 @@ function readAccepting(): Promise<boolean> {
|
|
|
94
105
|
});
|
|
95
106
|
}
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
type MagnetConfigRead =
|
|
109
|
+
| { kind: "ok"; config: FlowConfig }
|
|
110
|
+
| { kind: "absent" }
|
|
111
|
+
| { kind: "failed" };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A served magnet's config: `absent` for a non-ok answer below 500, `failed`
|
|
115
|
+
* for a 5xx or a throw. Never called with a slug content did not name.
|
|
116
|
+
*/
|
|
117
|
+
function readMagnetConfig(flowSlug: string): Promise<MagnetConfigRead> {
|
|
118
|
+
return sharedHubRead(`magnet:${flowSlug}`, async () => {
|
|
100
119
|
try {
|
|
101
|
-
const res = await flows.get(
|
|
102
|
-
|
|
120
|
+
const res = await flows.get(flowSlug);
|
|
121
|
+
if (res.ok) return { kind: "ok", config: res.body };
|
|
122
|
+
return res.status >= 500 ? { kind: "failed" } : { kind: "absent" };
|
|
103
123
|
} catch {
|
|
104
|
-
return
|
|
124
|
+
return { kind: "failed" };
|
|
105
125
|
}
|
|
106
126
|
});
|
|
107
127
|
}
|
|
108
128
|
|
|
109
|
-
|
|
110
|
-
|
|
129
|
+
type ApplicationForm = ReturnType<typeof jobApplicationFormFallback>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The form a posting's page draws (D4), and the flow it records when that is
|
|
133
|
+
* the posting's own; with none named, exactly the Job application path.
|
|
134
|
+
*/
|
|
135
|
+
async function applicationForm(
|
|
136
|
+
slug: string,
|
|
137
|
+
{ applicationFlow: named }: ApplyPosting,
|
|
138
|
+
locale: string,
|
|
139
|
+
): Promise<{ view: ApplicationForm; applicationFlow?: string }> {
|
|
140
|
+
if (named !== undefined && named !== JOB_APPLICATION_FLOW_SLUG) {
|
|
141
|
+
const read: MagnetConfigRead = flowSlugSchema.safeParse(named).success
|
|
142
|
+
? await readMagnetConfig(named)
|
|
143
|
+
: { kind: "absent" };
|
|
144
|
+
const view = read.kind === "ok" ? jobApplicationFormFromConfig(read.config, locale) : null;
|
|
145
|
+
if (view) return { view, applicationFlow: named };
|
|
146
|
+
if (read.kind !== "failed") {
|
|
147
|
+
console.warn("[careers] application flow unavailable — falling back to job-application", {
|
|
148
|
+
posting: slug,
|
|
149
|
+
flow: named,
|
|
150
|
+
reason: read.kind === "ok" ? "unmappable" : "absent",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const read = await readMagnetConfig(JOB_APPLICATION_FLOW_SLUG);
|
|
155
|
+
return {
|
|
156
|
+
view:
|
|
157
|
+
(read.kind === "ok" ? jobApplicationFormFromConfig(read.config, locale) : null) ??
|
|
158
|
+
jobApplicationFormFallback(locale),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The posting, or `null` for a segment pair this page does not serve. */
|
|
163
|
+
async function postingFor(
|
|
111
164
|
slug: string,
|
|
112
165
|
action: string,
|
|
113
166
|
locale: string,
|
|
114
|
-
): Promise<
|
|
167
|
+
): Promise<ApplyPosting | null> {
|
|
115
168
|
if (action !== "apply" || !POSTING_SLUG_RE.test(slug)) return null;
|
|
116
|
-
return
|
|
169
|
+
return postingForApply(slug, locale);
|
|
117
170
|
}
|
|
118
171
|
|
|
119
172
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
120
173
|
const { locale, slug, action } = await params;
|
|
121
|
-
const
|
|
122
|
-
return
|
|
174
|
+
const posting = await postingFor(slug, action, locale);
|
|
175
|
+
return posting === null ? flowRouteMetadata() : flowRouteMetadata({ title: posting.title });
|
|
123
176
|
}
|
|
124
177
|
|
|
125
178
|
export default async function Page({ params }: Props): Promise<ReactElement> {
|
|
126
179
|
const { locale, slug, action } = await params;
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
180
|
+
const posting = await postingFor(slug, action, locale);
|
|
181
|
+
if (posting === null) notFound();
|
|
182
|
+
const { title } = posting;
|
|
129
183
|
|
|
130
|
-
const [accepting, t, tCareers, tFlow,
|
|
184
|
+
const [accepting, t, tCareers, tFlow, { view, applicationFlow }] = await Promise.all([
|
|
131
185
|
readAccepting(),
|
|
132
186
|
getTranslations({ locale, namespace: "contact" }),
|
|
133
187
|
getTranslations({ locale, namespace: "careers" }),
|
|
134
188
|
getTranslations({ locale, namespace: "flow" }),
|
|
135
|
-
|
|
189
|
+
applicationForm(slug, posting, locale),
|
|
136
190
|
]);
|
|
137
191
|
const text = contactFormText(t);
|
|
138
192
|
const contactEmail = readSiteContactEmail();
|
|
139
|
-
const view =
|
|
140
|
-
(magnet ? jobApplicationFormFromConfig(magnet, locale) : null) ??
|
|
141
|
-
jobApplicationFormFallback(locale);
|
|
142
193
|
|
|
143
194
|
return (
|
|
144
195
|
<main id="main" data-testid="job-apply">
|
|
@@ -155,7 +206,11 @@ export default async function Page({ params }: Props): Promise<ReactElement> {
|
|
|
155
206
|
</Heading>
|
|
156
207
|
{accepting ? (
|
|
157
208
|
<LeadCaptureForm
|
|
158
|
-
action={
|
|
209
|
+
action={
|
|
210
|
+
applicationFlow === undefined
|
|
211
|
+
? captureLeadAction.bind(null, slug)
|
|
212
|
+
: captureLeadAction.bind(null, { job: slug, flow: applicationFlow })
|
|
213
|
+
}
|
|
159
214
|
text={
|
|
160
215
|
view.consentStatement
|
|
161
216
|
? { ...text, consentStatement: view.consentStatement }
|
|
@@ -25,13 +25,19 @@
|
|
|
25
25
|
* the API so (`consent: true`, #9130): the hub requires it of a
|
|
26
26
|
* `flow:job-application` lead and records `payload.consent` itself.
|
|
27
27
|
*
|
|
28
|
+
* #10054 D6 — a posting that names its own magnet binds `{ job, flow }`
|
|
29
|
+
* instead: the flow, slug-checked, is written as `payload.applicationFlow`
|
|
30
|
+
* beside `job`, and no form field overrides it. The source stays
|
|
31
|
+
* `flow:job-application`, so the hub still records consent.
|
|
32
|
+
*
|
|
28
33
|
* Checks, before any write: the honeypot (a filled one answers success and
|
|
29
34
|
* sends nothing); first name, last name and email required; a LinkedIn URL,
|
|
30
35
|
* when given, an http(s) URL — a bare domain gains `https://`, the flow
|
|
31
36
|
* engine's `url` idiom; the terms box ticked, as the form block requires; and
|
|
32
37
|
* the per-visitor limit /contact runs, in /contact's own bucket so a visitor's
|
|
33
|
-
* lead posts stay at 30 per 10 minutes however they are split (
|
|
34
|
-
*
|
|
38
|
+
* lead posts stay at 30 per 10 minutes however they are split (with no
|
|
39
|
+
* readable client address, /contact's shared, wider bucket — never skipped,
|
|
40
|
+
* #9967). Outcomes map as /contact's do: the API's
|
|
35
41
|
* `lead_capture_not_configured` or an unreachable API is `unavailable`, any
|
|
36
42
|
* other refusal the generic error.
|
|
37
43
|
*/
|
|
@@ -44,6 +50,7 @@ import {
|
|
|
44
50
|
import {
|
|
45
51
|
accountEmailHintSchema,
|
|
46
52
|
flowLeadSource,
|
|
53
|
+
flowSlugSchema,
|
|
47
54
|
JOB_APPLICATION_FLOW_SLUG,
|
|
48
55
|
leadSubmitNextSchema,
|
|
49
56
|
} from "@working-theory/validation";
|
|
@@ -52,15 +59,13 @@ import { getLocale, getTranslations } from "next-intl/server";
|
|
|
52
59
|
import { z } from "zod";
|
|
53
60
|
|
|
54
61
|
import { accountInviteHref } from "../../../../lib/account-invite";
|
|
62
|
+
import { runBotCheck } from "../../../../lib/bot-check";
|
|
55
63
|
import {
|
|
56
64
|
consentVisitorHeader,
|
|
57
65
|
readConsentVisitor,
|
|
58
66
|
} from "../../../../lib/consent-visitor";
|
|
59
67
|
import { writeLead } from "../../../../src/lib/lead-write";
|
|
60
|
-
import {
|
|
61
|
-
checkRateLimit,
|
|
62
|
-
clientIpFromHeaders,
|
|
63
|
-
} from "../../../../src/lib/rate-limit";
|
|
68
|
+
import { takeVisitorLimit } from "../../../../src/lib/rate-limit";
|
|
64
69
|
|
|
65
70
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
66
71
|
const RATE_LIMITED =
|
|
@@ -69,6 +74,8 @@ const CONSENT_REQUIRED = "Please accept the terms to send your application.";
|
|
|
69
74
|
|
|
70
75
|
const RATE_LIMIT_MAX_CALLS = 30;
|
|
71
76
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
77
|
+
/** Outside a request scope there is no address: the shared bucket. */
|
|
78
|
+
const NO_HEADERS = { get: () => null };
|
|
72
79
|
|
|
73
80
|
const SOURCE = flowLeadSource(JOB_APPLICATION_FLOW_SLUG);
|
|
74
81
|
const JOB_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
@@ -84,6 +91,7 @@ const RESERVED_FIELDS = new Set([
|
|
|
84
91
|
"consent",
|
|
85
92
|
"name",
|
|
86
93
|
"job",
|
|
94
|
+
"applicationFlow",
|
|
87
95
|
"source",
|
|
88
96
|
LEAD_CAPTURE_HONEYPOT,
|
|
89
97
|
]);
|
|
@@ -191,8 +199,22 @@ async function successState(
|
|
|
191
199
|
}
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The bound posting: today's string `job`, or `{ job, flow }` (D6). `null`
|
|
204
|
+
* for anything else — a bound argument comes back from the browser, so its
|
|
205
|
+
* type is checked, not only its text.
|
|
206
|
+
*/
|
|
207
|
+
function readPosting(posting: unknown): { job: string; applicationFlow?: string } | null {
|
|
208
|
+
if (typeof posting === "string") return JOB_SLUG_RE.test(posting) ? { job: posting } : null;
|
|
209
|
+
if (typeof posting !== "object" || posting === null) return null;
|
|
210
|
+
const { job, flow } = posting as { job?: unknown; flow?: unknown };
|
|
211
|
+
if (typeof job !== "string" || !JOB_SLUG_RE.test(job)) return null;
|
|
212
|
+
if (!flowSlugSchema.safeParse(flow).success) return null;
|
|
213
|
+
return { job, applicationFlow: flow as string };
|
|
214
|
+
}
|
|
215
|
+
|
|
194
216
|
export async function captureLeadAction(
|
|
195
|
-
job: string,
|
|
217
|
+
posting: string | { job: string; flow: string },
|
|
196
218
|
_prevState: LeadCaptureFormState,
|
|
197
219
|
formData: FormData,
|
|
198
220
|
): Promise<LeadCaptureFormState> {
|
|
@@ -200,10 +222,11 @@ export async function captureLeadAction(
|
|
|
200
222
|
if (typeof honeypot === "string" && honeypot !== "")
|
|
201
223
|
return { kind: "success" };
|
|
202
224
|
|
|
203
|
-
|
|
204
|
-
if (
|
|
225
|
+
const attribution = readPosting(posting);
|
|
226
|
+
if (attribution === null) {
|
|
205
227
|
return { kind: "error", message: GENERIC_ERROR };
|
|
206
228
|
}
|
|
229
|
+
const { job, applicationFlow } = attribution;
|
|
207
230
|
|
|
208
231
|
const fieldErrors: Record<string, string> = {};
|
|
209
232
|
if (formData.get("consent") !== "true")
|
|
@@ -238,6 +261,7 @@ export async function captureLeadAction(
|
|
|
238
261
|
...(phone !== undefined && { phone }),
|
|
239
262
|
...(linkedin_url !== undefined && { linkedin_url }),
|
|
240
263
|
job,
|
|
264
|
+
...(applicationFlow !== undefined && { applicationFlow }),
|
|
241
265
|
};
|
|
242
266
|
|
|
243
267
|
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
@@ -247,16 +271,13 @@ export async function captureLeadAction(
|
|
|
247
271
|
// No request scope — no forwarding, and the visitor is "unknown".
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
const visitor = requestHeaders
|
|
251
|
-
? clientIpFromHeaders(requestHeaders)
|
|
252
|
-
: "unknown";
|
|
253
274
|
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
275
|
+
!takeVisitorLimit(
|
|
276
|
+
"contact",
|
|
277
|
+
requestHeaders ?? NO_HEADERS,
|
|
257
278
|
RATE_LIMIT_MAX_CALLS,
|
|
258
279
|
RATE_LIMIT_WINDOW_MS,
|
|
259
|
-
)
|
|
280
|
+
).allowed
|
|
260
281
|
) {
|
|
261
282
|
return { kind: "error", message: RATE_LIMITED };
|
|
262
283
|
}
|
|
@@ -282,6 +303,15 @@ export async function captureLeadAction(
|
|
|
282
303
|
// No request scope — submit with no visitor-link header.
|
|
283
304
|
}
|
|
284
305
|
|
|
306
|
+
// #9255 D4 — the spoke edge's bot check, immediately before the write.
|
|
307
|
+
const botCheck = await runBotCheck({
|
|
308
|
+
source: "bot-check",
|
|
309
|
+
target: "captureLeadAction",
|
|
310
|
+
scope: "write",
|
|
311
|
+
});
|
|
312
|
+
if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
|
|
313
|
+
if (botCheck === "unavailable") return { kind: "unavailable" };
|
|
314
|
+
|
|
285
315
|
const res = await writeLead(
|
|
286
316
|
{ source: SOURCE, email, page: "careers", consent: true, payload },
|
|
287
317
|
init,
|