create-ailk 0.26.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 +86 -9
- 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 +25 -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 +112 -11
- 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 +90 -6
- 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 +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- 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/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- 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]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -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 +161 -128
- 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 +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- 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 +484 -4
- 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/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- 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 +44 -4
- 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/sign-up-agreements.ts +17 -6
- 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 +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- 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
|
@@ -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,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// JobPosting detail page. Reads the JobPosting MDX for the given slug, renders via
|
|
2
|
-
//
|
|
2
|
+
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
|
|
3
3
|
// Careers list at /careers links here; each job posting lives at /careers/<slug>.
|
|
4
4
|
//
|
|
5
5
|
// Under the posting, inside <main>, an Apply button (#8849 D8) opens
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
// form this page used to draw under `#apply`. The posting's JSON-LD
|
|
10
10
|
// ApplyAction targets the same URL (#8849 D3). The page makes no hub read.
|
|
11
11
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getMdxSource as loadMdxSource,
|
|
14
|
+
renderPage,
|
|
15
|
+
renderPageMetadata,
|
|
16
|
+
} from "@working-theory/page-renderer";
|
|
13
17
|
import { Button, Container, Link, localeHref } from "@working-theory/ui";
|
|
14
18
|
import type { Metadata } from "next";
|
|
15
19
|
import { getTranslations } from "next-intl/server";
|
|
@@ -32,12 +36,25 @@ export default async function Page({
|
|
|
32
36
|
getMdxSource,
|
|
33
37
|
}: Props): Promise<ReactElement> {
|
|
34
38
|
const { locale, slug } = await params;
|
|
39
|
+
// A page that composes `sections[]` renders them (JSON-LD is built from
|
|
40
|
+
// the same sections); a prose page renders its MDX body — the rule
|
|
41
|
+
// /vs/[slug] applies (#10122).
|
|
42
|
+
const source = await (getMdxSource ?? loadMdxSource)(
|
|
43
|
+
"JobPosting",
|
|
44
|
+
slug,
|
|
45
|
+
locale,
|
|
46
|
+
);
|
|
47
|
+
const composed =
|
|
48
|
+
source !== null &&
|
|
49
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
50
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
51
|
+
0;
|
|
35
52
|
const t = await getTranslations({ locale, namespace: "careers" });
|
|
36
53
|
return renderPage({
|
|
37
54
|
type: "JobPosting",
|
|
38
55
|
slug,
|
|
39
56
|
locale,
|
|
40
|
-
bodyMode: "mdx-body",
|
|
57
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
41
58
|
trailing: (
|
|
42
59
|
<Container className="pb-section">
|
|
43
60
|
<Button asChild size="big">
|