create-ailk 0.11.2 → 0.11.4

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 (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +148 -21
  73. package/templates/database/CHANGELOG.md +14 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. 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 };
@@ -5,6 +5,7 @@
5
5
  // Shell/Chrome composition — Nav + Footer wrap via @working-theory/templates MarketingShell (#2820)
6
6
  // Session-aware Nav actions — Sign in / Account + Sign out from the server-read session (#4984)
7
7
  // Auth route group — the marketing chrome is dropped on the five (auth) paths (#5963)
8
+ // Account area — the marketing chrome AND the consent Banner are dropped on /account paths (#7430)
8
9
  // Site-declared theme — `_site.mdx` `theme:` block → token overlay → CSS vars
9
10
  //
10
11
  // Sits under app/[locale]/ and sets the <html lang> attribute to the
@@ -52,7 +53,7 @@ import {
52
53
  SignedInNav,
53
54
  type SignedInNavProps,
54
55
  } from "../../components/signed-in-nav";
55
- import { isAuthPath } from "../../lib/auth-routes";
56
+ import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
56
57
  import { assertEnabledLocale } from "../../lib/locale-guard";
57
58
  import { footerColumns, navItems } from "../../lib/nav-config";
58
59
  import { getOgImageUrl } from "../../lib/og";
@@ -395,6 +396,14 @@ export default async function LocaleLayout({
395
396
  // `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
396
397
  const isAuthRoute = isAuthPath(pathname, enabledLocales);
397
398
 
399
+ // The account branch (#7430, spec 7423 D4) — the same mechanism for the
400
+ // signed-in account area, which renders inside its own SignedInShell: no
401
+ // Nav, no Footer, no announcement Banner, and no cookie consent Banner
402
+ // either (the auth branch keeps that one). Cookie settings stay reachable
403
+ // from User settings › Privacy (`/privacy#cookie-settings`). The same
404
+ // fail-safe direction: an absent `x-pathname` keeps the full frame.
405
+ const isAccountRoute = isAccountPath(pathname, enabledLocales);
406
+
398
407
  // Every Nav prop that is safe to serialize — shared verbatim by the
399
408
  // server-rendered Nav (signed out) and `SignedInNav` (signed in), which adds
400
409
  // `navItems` and `renderFlowTrigger` on its own side of the RSC boundary.
@@ -553,14 +562,16 @@ export default async function LocaleLayout({
553
562
  * emitted unconditionally in OSS forks; paying customers install
554
563
  * @working-theory/branding and gate it in their own layout.
555
564
  */}
556
- {isAuthRoute ? (
565
+ {isAuthRoute || isAccountRoute ? (
557
566
  /*
558
567
  * The (auth) branch (#5963): the five auth paths render their own
559
568
  * `(auth)/layout.tsx` — AuthShell — with no Nav and no marketing
560
569
  * Footer above or below it. Everything outside this ternary (the
561
570
  * document, the flash-prevention script, the theme vars, the
562
571
  * favicons, NextIntlClientProvider, the consent Banner and
563
- * Telemetry) is shared by both branches and written once.
572
+ * Telemetry) is shared by both branches and written once. The
573
+ * account paths (#7430) take this branch too, and also skip the
574
+ * consent Banner below.
564
575
  */
565
576
  children
566
577
  ) : (
@@ -673,31 +684,33 @@ export default async function LocaleLayout({
673
684
  * reserves its own space (D1) and carries a close-without-choosing
674
685
  * control (D2) instead of a hard server-side hide.
675
686
  */}
676
- <Banner
677
- variant="info"
678
- placement="bottom"
679
- consentCookieName={CONSENT_COOKIE_NAME}
680
- consentDecided={consentDecided}
681
- title={tConsent("title")}
682
- message={
683
- <>
684
- {tConsent("body")}{" "}
685
- <Link
686
- href={localeHref(locale, "/privacy")}
687
- variant="unstyled"
688
- className="font-semibold text-current underline underline-offset-4"
689
- >
690
- {tConsent("policyLinkLabel")}
691
- </Link>
692
- </>
693
- }
694
- dismissible={false}
695
- dismissLabel={tConsent("dismissLabel")}
696
- acceptLabel={tConsent("acceptLabel")}
697
- secondaryAction={{ label: tConsent("rejectLabel") }}
698
- rejectLabel={tConsent("rejectLabel")}
699
- locale={locale}
700
- />
687
+ {isAccountRoute ? null : (
688
+ <Banner
689
+ variant="info"
690
+ placement="bottom"
691
+ consentCookieName={CONSENT_COOKIE_NAME}
692
+ consentDecided={consentDecided}
693
+ title={tConsent("title")}
694
+ message={
695
+ <>
696
+ {tConsent("body")}{" "}
697
+ <Link
698
+ href={localeHref(locale, "/privacy")}
699
+ variant="unstyled"
700
+ className="font-semibold text-current underline underline-offset-4"
701
+ >
702
+ {tConsent("policyLinkLabel")}
703
+ </Link>
704
+ </>
705
+ }
706
+ dismissible={false}
707
+ dismissLabel={tConsent("dismissLabel")}
708
+ acceptLabel={tConsent("acceptLabel")}
709
+ secondaryAction={{ label: tConsent("rejectLabel") }}
710
+ rejectLabel={tConsent("rejectLabel")}
711
+ locale={locale}
712
+ />
713
+ )}
701
714
  <Telemetry />
702
715
  </NextIntlClientProvider>
703
716
  </body>
@@ -5,12 +5,25 @@
5
5
 
6
6
  import { subscribeNewsletterAction } from "../newsletter.actions.js";
7
7
 
8
+ // `visitorForwardHeaders` stays real (#7394) so AC-9 compares the action's
9
+ // `init` with the helper's own output.
8
10
  jest.mock("@working-theory/api-client", () => ({
9
11
  leads: { subscribe: jest.fn() },
12
+ visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
13
+ .visitorForwardHeaders,
10
14
  }));
11
15
 
12
- import { leads } from "@working-theory/api-client";
16
+ // next/headers' `headers()` is request-scoped in Next; outside it, it is
17
+ // mocked here the way flow.actions.test.ts does (#7394 AC-9).
18
+ jest.mock("next/headers", () => ({
19
+ __esModule: true,
20
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
21
+ }));
22
+
23
+ import { leads, visitorForwardHeaders } from "@working-theory/api-client";
24
+ import { headers } from "next/headers";
13
25
  const mockSubscribe = leads.subscribe as jest.Mock;
26
+ const mockHeaders = headers as jest.Mock;
14
27
 
15
28
  function formWith(email: string): FormData {
16
29
  const fd = new FormData();
@@ -19,7 +32,10 @@ function formWith(email: string): FormData {
19
32
  return fd;
20
33
  }
21
34
 
22
- afterEach(() => jest.clearAllMocks());
35
+ afterEach(() => {
36
+ jest.clearAllMocks();
37
+ mockHeaders.mockResolvedValue({ get: () => null });
38
+ });
23
39
 
24
40
  describe("subscribeNewsletterAction", () => {
25
41
  it("calls leads.subscribe with source newsletter and the address", async () => {
@@ -73,3 +89,51 @@ describe("subscribeNewsletterAction", () => {
73
89
  expect(mockSubscribe).not.toHaveBeenCalled();
74
90
  });
75
91
  });
92
+
93
+ // #7394 AC-9 — a spoke forwards its visitor to the hub's leads rate limit.
94
+ describe("subscribeNewsletterAction — visitor forwarding (#7394)", () => {
95
+ const FORWARD_KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
96
+ const ENV_KEYS = ["AILK_VISITOR_FORWARD_KEY", "AILK_API_URL"] as const;
97
+ const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
98
+
99
+ beforeEach(() => {
100
+ process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
101
+ process.env.AILK_API_URL = "https://hub.example";
102
+ });
103
+
104
+ afterAll(() => {
105
+ for (const [name, value] of priorEnv) {
106
+ if (value === undefined) delete process.env[name];
107
+ else process.env[name] = value;
108
+ }
109
+ });
110
+
111
+ it("passes the helper's two headers as init", async () => {
112
+ const incoming = {
113
+ get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.5" : null),
114
+ };
115
+ mockHeaders.mockResolvedValue(incoming);
116
+ mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
117
+
118
+ const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
119
+
120
+ expect(result).toEqual({ kind: "success" });
121
+ const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
122
+ expect(expected).toEqual({
123
+ "x-ailk-visitor-ip": "203.0.113.5",
124
+ "x-ailk-visitor-forward-key": FORWARD_KEY,
125
+ });
126
+ expect(mockSubscribe).toHaveBeenCalledWith(expect.any(Object), { headers: expected });
127
+ });
128
+
129
+ it("calls leads.subscribe with one argument when headers() throws, and still succeeds", async () => {
130
+ mockHeaders.mockRejectedValue(new Error("outside a request scope"));
131
+ mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
132
+
133
+ const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
134
+
135
+ expect(result).toEqual({ kind: "success" });
136
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
137
+ expect(mockSubscribe.mock.calls[0]).toHaveLength(1);
138
+ });
139
+ });