create-ailk 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -0,0 +1,98 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/support-actions.ts — the account area's Support request action.
5
+ *
6
+ * `submitSupportRequest` (#7430, spec 7423 D11) — files a Support request
7
+ * (and, from User settings › Privacy, an account-deletion request) as a
8
+ * contact lead: `POST /v1/leads/submit` with `source: "web"`, the session's
9
+ * email and the payload `{ kind, subject, message }`. No `siteId` — the
10
+ * route resolves the tenant server-side and ignores a posted one (#6203).
11
+ * The request lands in the admin Inbox as a message. Not a `*Action`-suffixed
12
+ * export in a `*.actions.ts` file: lead capture already has its MCP twin
13
+ * (`captureLeadAction` ↔ `ailkCaptureLead`), so this is not a second
14
+ * Server-Action/MCP-tool pair (apps/mcp's parity test).
15
+ *
16
+ * Its own module, apart from `actions.ts`, so `create-ailk sync-routes` can
17
+ * bring it along with the Support and User settings routes as a new file: a
18
+ * scaffold's existing `actions.ts` is never overwritten.
19
+ */
20
+
21
+ import { leads, visitorForwardHeaders } from "@working-theory/api-client";
22
+ import { headers } from "next/headers";
23
+
24
+ import { auth } from "../../../../auth";
25
+
26
+ /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
27
+ function errorName(err: unknown): string {
28
+ return err instanceof Error ? err.name : "unknown";
29
+ }
30
+
31
+ /** What the request forms render after a submit. */
32
+ export type SupportRequestState =
33
+ | { kind: "idle" }
34
+ | { kind: "success" }
35
+ | { kind: "error"; reason: "required" | "failed" | "unavailable" };
36
+
37
+ const MAX_SUBJECT = 200;
38
+ const MAX_MESSAGE = 5000;
39
+
40
+ function field(formData: FormData, name: string): string {
41
+ const value = formData.get(name);
42
+ return typeof value === "string" ? value.trim() : "";
43
+ }
44
+
45
+ /** A 503 whose body names `lead_capture_not_configured` (contact.actions.ts's reading). */
46
+ function isNotConfigured(res: { status: number; body: unknown }): boolean {
47
+ return (
48
+ res.status === 503 &&
49
+ typeof res.body === "object" &&
50
+ res.body !== null &&
51
+ (res.body as { error?: unknown }).error === "lead_capture_not_configured"
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Files the signed-in member's Support (or deletion) request. The email is
57
+ * the session's own — never a form field — so a request always names the
58
+ * person who sent it.
59
+ */
60
+ export async function submitSupportRequest(
61
+ _prev: SupportRequestState,
62
+ formData: FormData,
63
+ ): Promise<SupportRequestState> {
64
+ const kind = field(formData, "kind") === "deletion" ? "deletion" : "support";
65
+ const subject = field(formData, "subject");
66
+ const message = field(formData, "message");
67
+ if (!subject || !message || subject.length > MAX_SUBJECT || message.length > MAX_MESSAGE) {
68
+ return { kind: "error", reason: "required" };
69
+ }
70
+
71
+ try {
72
+ const requestHeaders = await headers();
73
+ const session = await auth.api.getSession({ headers: requestHeaders });
74
+ if (!session) return { kind: "error", reason: "failed" };
75
+
76
+ const body = {
77
+ source: "web",
78
+ email: session.user.email,
79
+ payload: { kind, subject, message },
80
+ };
81
+ // #7394 — a spoke counts the request against the visitor's own budget.
82
+ const forwarded = visitorForwardHeaders(
83
+ requestHeaders,
84
+ process.env.AILK_VISITOR_FORWARD_KEY,
85
+ );
86
+ const res = forwarded
87
+ ? await leads.submit(body, { headers: forwarded })
88
+ : await leads.submit(body);
89
+ if (!res.ok) {
90
+ console.error(`[submitSupportRequest] status=${res.status}`);
91
+ return { kind: "error", reason: isNotConfigured(res) ? "unavailable" : "failed" };
92
+ }
93
+ return { kind: "success" };
94
+ } catch (err) {
95
+ console.error(`[submitSupportRequest] errName=${errorName(err)}`);
96
+ return { kind: "error", reason: "failed" };
97
+ }
98
+ }
@@ -10,17 +10,46 @@
10
10
  * surface — #4984). This guard reads no public-route auth-requirement flag
11
11
  * (spec Clarifications) — that flag governs whether PUBLIC marketing routes
12
12
  * redirect; tenant signup data is never public, so this gate always applies.
13
+ *
14
+ * The gate's providers are resolved HERE, on the server, and passed down as a
15
+ * prop (#7419): `SignInGate` is a client component, and the provider env
16
+ * `getActiveProviderIds()` reads is server-only — read in the browser it is
17
+ * always empty, which made every gated page claim sign-in was not configured.
18
+ *
19
+ * When the email sign-in link is on (#7435 § D9 — the same
20
+ * `getActiveProviderIds()` predicate `auth.ts` registers the plugin on), the
21
+ * layout resolves `/sign-in`'s card copy here too and passes it as the gate's
22
+ * `emailLink` prop; otherwise it passes nothing and the gate renders as before.
23
+ *
24
+ * On an account path (#7430, `isAccountPath` on the forwarded `x-pathname`)
25
+ * the visitor is a member, not an operator: the gate reads the member-facing
26
+ * `account.gate` copy instead of `waitlistDashboard.gate` (which asks for
27
+ * provider env vars "to enable operator access"). When no sign-in method is
28
+ * on, the layout renders that member gate itself so its headline is a real
29
+ * `<h1>` — `SignInGate`'s not-configured branch renders `EmptyState`, whose
30
+ * headline is a `<p>` — and its one action leads back to the site rather than
31
+ * to a sign-in page that has nothing to offer. An absent `x-pathname` keeps
32
+ * the operator copy, the same fail-safe direction as the locale layout's
33
+ * account branch.
13
34
  */
14
35
 
15
36
  import { localeHref } from "@working-theory/ui";
37
+ import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
38
+ import type { AuthPanelProvider } from "@working-theory/ui/sections";
39
+ import { readLocales } from "@working-theory/web-i18n";
16
40
  import { headers } from "next/headers";
17
41
  import { getTranslations } from "next-intl/server";
18
42
  import type { ReactElement, ReactNode } from "react";
19
43
 
44
+ import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
20
45
  import { auth } from "../../../auth";
46
+ import { getActiveProviderIds } from "../../../lib/auth-env";
47
+ import { isAccountPath } from "../../../lib/auth-routes";
21
48
 
22
49
  import { SignInGate } from "./sign-in-gate";
23
50
 
51
+ const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
52
+
24
53
  interface Props {
25
54
  children: ReactNode;
26
55
  params: Promise<{ locale: string }>;
@@ -31,13 +60,56 @@ export default async function AuthedLayout({
31
60
  params,
32
61
  }: Props): Promise<ReactElement> {
33
62
  const { locale } = await params;
34
- const session = await auth.api.getSession({ headers: await headers() });
63
+ const requestHeaders = await headers();
64
+ const session = await auth.api.getSession({ headers: requestHeaders });
35
65
 
36
66
  if (!session) {
67
+ // No `x-pathname`, no locale read: a render outside the middleware keeps
68
+ // the operator gate without touching the locale config.
69
+ const pathname = requestHeaders.get("x-pathname");
70
+ const isAccount =
71
+ pathname !== null && isAccountPath(pathname, readLocales());
37
72
  const t = await getTranslations({
38
73
  locale,
39
- namespace: "waitlistDashboard.gate",
74
+ namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
40
75
  });
76
+ const active = getActiveProviderIds();
77
+ const emailLink = active.includes("magic-link")
78
+ ? { text: await resolveSignInCardText(locale) }
79
+ : undefined;
80
+ const providers = active.filter((id): id is AuthPanelProvider =>
81
+ (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
82
+ );
83
+ const signInPath = localeHref(locale, "/sign-in");
84
+
85
+ if (isAccount && providers.length === 0 && !emailLink) {
86
+ return (
87
+ <div
88
+ className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
89
+ data-testid="account-sign-in-gate"
90
+ >
91
+ <Heading level={1} visual={4}>
92
+ {t("emptyHeadline")}
93
+ </Heading>
94
+ <Text variant="body-sm" tone="muted" className="max-w-xs">
95
+ {t("emptyDescription")}
96
+ </Text>
97
+ {/* With no sign-in method on, /sign-in has nothing for a member
98
+ either, so the one action goes back to the site. Outline
99
+ action, 44px on a phone and 36px from md up. */}
100
+ <Button asChild variant="secondary" className="max-md:min-h-11">
101
+ <Link
102
+ href={localeHref(locale, "/")}
103
+ variant="bare"
104
+ data-testid="account-sign-in-gate-home"
105
+ >
106
+ {t("homeLink")}
107
+ </Link>
108
+ </Button>
109
+ </div>
110
+ );
111
+ }
112
+
41
113
  return (
42
114
  <SignInGate
43
115
  labels={{
@@ -45,7 +117,9 @@ export default async function AuthedLayout({
45
117
  emptyDescription: t("emptyDescription"),
46
118
  signInLinkLabel: t("signInLink"),
47
119
  }}
48
- signInPath={localeHref(locale, "/sign-in")}
120
+ signInPath={signInPath}
121
+ providers={providers}
122
+ {...(emailLink ? { emailLink } : {})}
49
123
  />
50
124
  );
51
125
  }
@@ -9,11 +9,22 @@
9
9
  * (#4984) carrying `?next=<pathname>`, so a visitor who prefers the full
10
10
  * sign-in surface lands back on the exact page they requested.
11
11
  *
12
+ * `providers` arrives as a prop, resolved by the `(authed)` layout (an RSC)
13
+ * from the server's provider env (#7419). This module must never read that
14
+ * env itself: it is bundled for the browser, where `GOOGLE_CLIENT_ID` and the
15
+ * like are always unset, so a client-side read always came back empty.
16
+ *
12
17
  * Zero active providers → an `EmptyState` naming the missing configuration
13
18
  * (the `AuthPanel` UI-contract forbids fabricating a session — spec
14
19
  * Clarifications), still followed by the sign-in link. `callbackURL` is the
15
20
  * current pathname (via `usePathname()`) so a successful social sign-in
16
21
  * lands back on the exact dashboard page the visitor requested.
22
+ *
23
+ * Email sign-in link (#7435 § D9): when the layout passes `emailLink`, the
24
+ * gate renders `/sign-in`'s own `SignInCard` — the same providers plus "Email
25
+ * me a sign-in link" — in place of `AuthPanel`, whose email + password form
26
+ * has no submit wiring and would otherwise sit inert beside the new field.
27
+ * Without the prop every render is what it was before.
17
28
  */
18
29
 
19
30
  import { EmptyState, Link } from "@working-theory/ui/primitives";
@@ -22,12 +33,11 @@ import { createAuthClient } from "better-auth/react";
22
33
  import { usePathname } from "next/navigation";
23
34
  import type { ReactElement } from "react";
24
35
 
25
- import { getActiveProviderIds } from "../../../lib/auth-env";
36
+ import { SignInCard } from "../(auth)/sign-in/sign-in-card";
37
+ import type { SignInCardText } from "../(auth)/sign-in/sign-in-text";
26
38
 
27
39
  const authClient = createAuthClient();
28
40
 
29
- const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
30
-
31
41
  export interface SignInGateLabels {
32
42
  emptyHeadline: string;
33
43
  emptyDescription: string;
@@ -39,18 +49,27 @@ export interface SignInGateProps {
39
49
  labels: SignInGateLabels;
40
50
  /** Locale-prefixed sign-in page path — `localeHref(locale, "/sign-in")`. */
41
51
  signInPath: string;
52
+ /** The active social providers, resolved server-side by the layout (#7419). */
53
+ providers: AuthPanelProvider[];
54
+ /**
55
+ * Present iff the email sign-in link is on (#7435) — the sign-in card's
56
+ * copy, resolved server-side by the layout.
57
+ */
58
+ emailLink?: { text: SignInCardText };
42
59
  }
43
60
 
44
- export function SignInGate({ labels, signInPath }: SignInGateProps): ReactElement {
61
+ export function SignInGate({
62
+ labels,
63
+ signInPath,
64
+ providers,
65
+ emailLink,
66
+ }: SignInGateProps): ReactElement {
45
67
  const pathname = usePathname();
46
- const providers = getActiveProviderIds().filter(
47
- (id): id is AuthPanelProvider =>
48
- (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
49
- );
68
+ const signInHref = `${signInPath}?next=${encodeURIComponent(pathname)}`;
50
69
 
51
70
  const signInLink = (
52
71
  <Link
53
- href={`${signInPath}?next=${encodeURIComponent(pathname)}`}
72
+ href={signInHref}
54
73
  data-testid="sign-in-gate-link"
55
74
  // 24px hit-area floor (Rule 14) — a bare inline link's line-height box
56
75
  // falls short on its own.
@@ -60,6 +79,30 @@ export function SignInGate({ labels, signInPath }: SignInGateProps): ReactElemen
60
79
  </Link>
61
80
  );
62
81
 
82
+ if (emailLink) {
83
+ const { text } = emailLink;
84
+ return (
85
+ <div
86
+ className="flex min-h-screen flex-col items-center justify-center gap-4 py-section"
87
+ data-testid="sign-in-gate"
88
+ >
89
+ <SignInCard
90
+ providers={providers}
91
+ method="sso"
92
+ next={pathname}
93
+ labels={text.labels}
94
+ notConfiguredSubhead={text.notConfiguredSubhead}
95
+ errorLabels={text.errorLabels}
96
+ emailLink
97
+ errorCallbackURL={signInHref}
98
+ signInHref={signInHref}
99
+ emailLinkLabels={text.emailLinkLabels}
100
+ />
101
+ {signInLink}
102
+ </div>
103
+ );
104
+ }
105
+
63
106
  if (providers.length === 0) {
64
107
  return (
65
108
  <div
@@ -12,18 +12,31 @@
12
12
 
13
13
  import { captureLeadAction } from "../contact.actions.js";
14
14
 
15
- // Mock @working-theory/api-client
15
+ // Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
16
+ // so AC-9 compares the action's `init` with the helper's own output.
16
17
  jest.mock("@working-theory/api-client", () => ({
17
18
  leads: {
18
19
  submit: jest.fn(),
19
20
  },
21
+ visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
22
+ .visitorForwardHeaders,
20
23
  }));
21
24
 
22
- import { leads } from "@working-theory/api-client";
25
+ // next/headers' `headers()` is request-scoped in Next; outside it, it is
26
+ // mocked here the way flow.actions.test.ts does (#7394 AC-9).
27
+ jest.mock("next/headers", () => ({
28
+ __esModule: true,
29
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
30
+ }));
31
+
32
+ import { leads, visitorForwardHeaders } from "@working-theory/api-client";
33
+ import { headers } from "next/headers";
23
34
  const mockSubmit = leads.submit as jest.Mock;
35
+ const mockHeaders = headers as jest.Mock;
24
36
 
25
37
  afterEach(() => {
26
38
  jest.clearAllMocks();
39
+ mockHeaders.mockResolvedValue({ get: () => null });
27
40
  });
28
41
 
29
42
  function makeFormData(fields: Record<string, string>): FormData {
@@ -308,3 +321,51 @@ describe("captureLeadAction — honeypot (#6730)", () => {
308
321
  expect(body.payload.website).toBe("https://acme.example");
309
322
  });
310
323
  });
324
+
325
+ // #7394 AC-9 — a spoke forwards its visitor to the hub's leads rate limit.
326
+ describe("captureLeadAction — visitor forwarding (#7394)", () => {
327
+ const FORWARD_KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
328
+ const ENV_KEYS = ["AILK_VISITOR_FORWARD_KEY", "AILK_API_URL"] as const;
329
+ const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
330
+
331
+ beforeEach(() => {
332
+ process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
333
+ process.env.AILK_API_URL = "https://hub.example";
334
+ });
335
+
336
+ afterAll(() => {
337
+ for (const [name, value] of priorEnv) {
338
+ if (value === undefined) delete process.env[name];
339
+ else process.env[name] = value;
340
+ }
341
+ });
342
+
343
+ it("passes the helper's two headers as init", async () => {
344
+ const incoming = {
345
+ get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.5" : null),
346
+ };
347
+ mockHeaders.mockResolvedValue(incoming);
348
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
349
+
350
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
351
+
352
+ expect(result).toEqual({ kind: "success" });
353
+ const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
354
+ expect(expected).toEqual({
355
+ "x-ailk-visitor-ip": "203.0.113.5",
356
+ "x-ailk-visitor-forward-key": FORWARD_KEY,
357
+ });
358
+ expect(mockSubmit).toHaveBeenCalledWith(expect.any(Object), { headers: expected });
359
+ });
360
+
361
+ it("calls leads.submit with one argument when headers() throws, and still succeeds", async () => {
362
+ mockHeaders.mockRejectedValue(new Error("outside a request scope"));
363
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
364
+
365
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
366
+
367
+ expect(result).toEqual({ kind: "success" });
368
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
369
+ expect(mockSubmit.mock.calls[0]).toHaveLength(1);
370
+ });
371
+ });
@@ -35,11 +35,20 @@
35
35
  * a capability (lead capture) this repo already has an MCP twin for
36
36
  * (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → defaults
37
37
  * to `"web"`, unchanged from before.
38
+ *
39
+ * Visitor forwarding (#7394): on a spoke whose API is a hub, the call
40
+ * carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
41
+ * (server-only, never `NEXT_PUBLIC_`) via `visitorForwardHeaders`, so the
42
+ * hub counts this lead against the visitor's own rate-limit budget instead
43
+ * of the spoke host's. No key, no client address, or headers that cannot be
44
+ * read → the call is made with one argument, exactly as before; a failure to
45
+ * read headers never blocks a submission.
38
46
  */
39
47
 
40
- import { leads } from "@working-theory/api-client";
48
+ import { leads, visitorForwardHeaders } from "@working-theory/api-client";
41
49
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
42
50
  import { submitLeadInputSchema } from "@working-theory/validation";
51
+ import { headers } from "next/headers";
43
52
 
44
53
  const GENERIC_ERROR = "Something went wrong, please try again.";
45
54
 
@@ -131,8 +140,19 @@ export async function captureLeadAction(
131
140
  },
132
141
  };
133
142
 
143
+ let init: { headers: Record<string, string> } | undefined;
144
+ try {
145
+ const forwarded = visitorForwardHeaders(
146
+ await headers(),
147
+ process.env.AILK_VISITOR_FORWARD_KEY,
148
+ );
149
+ if (forwarded) init = { headers: forwarded };
150
+ } catch {
151
+ // No request scope to read — submit without forwarding.
152
+ }
153
+
134
154
  try {
135
- const res = await leads.submit(body);
155
+ const res = init ? await leads.submit(body, init) : await leads.submit(body);
136
156
  if (!res.ok) {
137
157
  if (isNotConfigured(res)) return { kind: "unavailable" };
138
158
  return { kind: "error", message: GENERIC_ERROR };