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
@@ -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
@@ -48,8 +49,11 @@ import type { ReactElement, ReactNode } from "react";
48
49
 
49
50
  import { auth } from "../../auth";
50
51
  import { GoogleAnalytics } from "../../components/google-analytics";
51
- import { SignOutTrigger } from "../../components/sign-out-trigger";
52
- import { isAuthPath } from "../../lib/auth-routes";
52
+ import {
53
+ SignedInNav,
54
+ type SignedInNavProps,
55
+ } from "../../components/signed-in-nav";
56
+ import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
53
57
  import { assertEnabledLocale } from "../../lib/locale-guard";
54
58
  import { footerColumns, navItems } from "../../lib/nav-config";
55
59
  import { getOgImageUrl } from "../../lib/og";
@@ -83,10 +87,11 @@ type ThemeMode = "light" | "dark";
83
87
  * and `flow` (open a StepFlow lightbox). The `flow` kind + Nav's
84
88
  * `renderFlowTrigger` composer is Nav's ONE caller-composed seam (#4341): the
85
89
  * caller receives the rendered trigger `<Button>` and decides what wraps it.
86
- * The layout uses that seam WITHOUT a lightbox — `renderFlowTrigger` below
87
- * replaces both signed-in triggers (Account → a non-navigating name label,
88
- * Sign out → `SignOutTrigger`), so this flow config is NEVER rendered. It only
89
- * has to satisfy the type: one minimal fields-step and a dead `action`.
90
+ * The layout uses that seam WITHOUT a lightbox — `SignedInNav`'s
91
+ * `renderFlowTrigger` replaces both signed-in triggers (Account → a
92
+ * non-navigating name label, Sign out → `SignOutTrigger`), so this flow config
93
+ * is NEVER rendered. It only has to satisfy the type: one minimal fields-step
94
+ * and a dead `action`.
90
95
  */
91
96
  const NAV_CLIENT_ACTION_FLOW: NavActionFlow["flow"] = {
92
97
  steps: [
@@ -314,7 +319,8 @@ export default async function LocaleLayout({
314
319
  // the service shapes now roster both). Signed in → the user's name (a
315
320
  // non-navigating label) + an outline-strong "Sign out"; both authored as
316
321
  // `kind: "flow"` so Nav hands their triggers back through
317
- // `renderFlowTrigger` (see NAV_CLIENT_ACTION_FLOW above).
322
+ // `renderFlowTrigger`, which `SignedInNav` owns (see NAV_CLIENT_ACTION_FLOW
323
+ // above).
318
324
  const navActions: NavAction[] = session
319
325
  ? [
320
326
  { labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
@@ -331,15 +337,17 @@ export default async function LocaleLayout({
331
337
  { labelKey: "signUp", path: "/sign-up", variant: "primary" },
332
338
  ];
333
339
 
334
- // Only compose the flow seam when there is a flow action to compose. This
335
- // layout is a Server Component and `renderFlowTrigger` is a function, so
336
- // passing it is only legal when Nav actually consumes it here rather than
337
- // forwarding it across the RSC boundary into NavMobileMenu ("use client").
338
- // Passing it unconditionally crashed EVERY signed-out render — the whole of a
339
- // fresh scaffold, which configures no auth provider — with "Functions cannot
340
- // be passed directly to Client Components". Nav itself already guards its own
341
- // forward the same way (chrome/Nav.tsx: `{...(renderFlowTrigger ? … : {})}`);
342
- // this is the caller half of that contract.
340
+ // A flow action needs `renderFlowTrigger`, a function — and this layout is a
341
+ // Server Component. Nav forwards that function into NavMobileMenu ("use
342
+ // client"), and React cannot serialize a function across the RSC boundary:
343
+ // passing it from here crashed EVERY signed-in page with "Functions cannot be
344
+ // passed directly to Client Components" (#7402). Nav's own
345
+ // `{...(renderFlowTrigger ? … : {})}` guard only skips the forward when the
346
+ // function is undefined; it never makes passing one legal. So a flow action
347
+ // routes the whole Nav through `SignedInNav` ("use client"), which defines
348
+ // `renderFlowTrigger` and imports `navItems` (icon components are functions
349
+ // too) on the client side. Without one, Nav stays a Server Component and
350
+ // nothing callable crosses.
343
351
  const hasFlowAction = navActions.some((action) => action.kind === "flow");
344
352
 
345
353
  // Shared brand mark injected into both Nav and Footer chrome slots. The
@@ -388,6 +396,41 @@ export default async function LocaleLayout({
388
396
  // `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
389
397
  const isAuthRoute = isAuthPath(pathname, enabledLocales);
390
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
+
407
+ // Every Nav prop that is safe to serialize — shared verbatim by the
408
+ // server-rendered Nav (signed out) and `SignedInNav` (signed in), which adds
409
+ // `navItems` and `renderFlowTrigger` on its own side of the RSC boundary.
410
+ const navProps: SignedInNavProps = {
411
+ locale,
412
+ labels: navLabels,
413
+ // Deck home (#5697): the two forwards that join #5426 D4 to a real deck.
414
+ // `sticky` pins the bar — MarketingShell renders {nav} as a plain child of
415
+ // the scroll container, so under `scroll-snap-type: y mandatory` the
416
+ // browser snaps slide 1 to the top on load and the unpinned bar is gone
417
+ // before the reader touches anything. `deckMorph` is what puts BrandLogo's
418
+ // `.ailk-deck-brand-mark` / `.ailk-deck-brand-wordmark` hooks in the
419
+ // document for the D4 CSS to key off; it requires both `logo` and
420
+ // `wordmark` (Nav.tsx:344), which are passed below. Scoped to the SAME
421
+ // `isDeckHome` gate MarketingShell's snap/deckHero use, so a non-deck
422
+ // route's render is byte-identical to before this branch existed.
423
+ ...(isDeckHome ? { sticky: true, deckMorph: true } : {}),
424
+ siteName: SITE_NAME,
425
+ frame: "contained",
426
+ navAlignment: "right",
427
+ showThemeToggle: false,
428
+ logo: brandLogo,
429
+ wordmark: brandWordmark,
430
+ overflowLabel: navOverflowLabel,
431
+ actions: navActions,
432
+ };
433
+
391
434
  // Placeholder social handles (#5469 D5): `{{COMPANY_HANDLE}}` is a
392
435
  // create-ailk marker substituted at scaffold time
393
436
  // (packages/create-ailk/src/programmatic.ts), so a customer's site never
@@ -519,14 +562,16 @@ export default async function LocaleLayout({
519
562
  * emitted unconditionally in OSS forks; paying customers install
520
563
  * @working-theory/branding and gate it in their own layout.
521
564
  */}
522
- {isAuthRoute ? (
565
+ {isAuthRoute || isAccountRoute ? (
523
566
  /*
524
567
  * The (auth) branch (#5963): the five auth paths render their own
525
568
  * `(auth)/layout.tsx` — AuthShell — with no Nav and no marketing
526
569
  * Footer above or below it. Everything outside this ternary (the
527
570
  * document, the flash-prevention script, the theme vars, the
528
571
  * favicons, NextIntlClientProvider, the consent Banner and
529
- * 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.
530
575
  */
531
576
  children
532
577
  ) : (
@@ -582,47 +627,14 @@ export default async function LocaleLayout({
582
627
  ? { snap: "mandatory" as const, deckHero: true }
583
628
  : {})}
584
629
  nav={
585
- <Nav
586
- locale={locale}
587
- labels={navLabels}
588
- navItems={siteChrome.nav === "mark-only" ? [] : navItems}
589
- // Deck home (#5697): the two forwards that join #5426 D4 to a
590
- // real deck. `sticky` pins the bar — MarketingShell renders
591
- // {nav} as a plain child of the scroll container, so under
592
- // `scroll-snap-type: y mandatory` the browser snaps slide 1 to
593
- // the top on load and the unpinned bar is gone before the
594
- // reader touches anything. `deckMorph` is what puts BrandLogo's
595
- // `.ailk-deck-brand-mark` / `.ailk-deck-brand-wordmark` hooks in
596
- // the document for the D4 CSS to key off; it requires both
597
- // `logo` and `wordmark` (Nav.tsx:344), which the layout already
598
- // passes above. Scoped to the SAME `isDeckHome` gate
599
- // MarketingShell's snap/deckHero use, so a non-deck route's
600
- // render is byte-identical to before this branch existed.
601
- {...(isDeckHome ? { sticky: true, deckMorph: true } : {})}
602
- siteName={SITE_NAME}
603
- frame="contained"
604
- navAlignment="right"
605
- showThemeToggle={false}
606
- logo={brandLogo}
607
- wordmark={brandWordmark}
608
- overflowLabel={navOverflowLabel}
609
- actions={navActions}
610
- {...(hasFlowAction
611
- ? {
612
- renderFlowTrigger: (action, trigger) =>
613
- action.labelKey === "signOut" ? (
614
- <SignOutTrigger>{trigger}</SignOutTrigger>
615
- ) : (
616
- <span
617
- className="text-body-sm text-(--color-text-muted)"
618
- data-testid="nav-account-name"
619
- >
620
- {navLabels.items.account}
621
- </span>
622
- ),
623
- }
624
- : {})}
625
- />
630
+ hasFlowAction ? (
631
+ <SignedInNav {...navProps} />
632
+ ) : (
633
+ <Nav
634
+ {...navProps}
635
+ navItems={siteChrome.nav === "mark-only" ? [] : navItems}
636
+ />
637
+ )
626
638
  }
627
639
  footer={
628
640
  <Footer
@@ -672,31 +684,33 @@ export default async function LocaleLayout({
672
684
  * reserves its own space (D1) and carries a close-without-choosing
673
685
  * control (D2) instead of a hard server-side hide.
674
686
  */}
675
- <Banner
676
- variant="info"
677
- placement="bottom"
678
- consentCookieName={CONSENT_COOKIE_NAME}
679
- consentDecided={consentDecided}
680
- title={tConsent("title")}
681
- message={
682
- <>
683
- {tConsent("body")}{" "}
684
- <Link
685
- href={localeHref(locale, "/privacy")}
686
- variant="unstyled"
687
- className="font-semibold text-current underline underline-offset-4"
688
- >
689
- {tConsent("policyLinkLabel")}
690
- </Link>
691
- </>
692
- }
693
- dismissible={false}
694
- dismissLabel={tConsent("dismissLabel")}
695
- acceptLabel={tConsent("acceptLabel")}
696
- secondaryAction={{ label: tConsent("rejectLabel") }}
697
- rejectLabel={tConsent("rejectLabel")}
698
- locale={locale}
699
- />
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
+ )}
700
714
  <Telemetry />
701
715
  </NextIntlClientProvider>
702
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
+ });
@@ -12,11 +12,17 @@
12
12
  * `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
13
13
  * `unavailable`; any other non-2xx or thrown error → the generic `error`;
14
14
  * only a 2xx is `success`.
15
+ *
16
+ * Visitor forwarding (#7394): forwards the visitor exactly as
17
+ * `captureLeadAction` does, through `visitorForwardHeaders` and the
18
+ * server-only `AILK_VISITOR_FORWARD_KEY`; without them the call is made
19
+ * with one argument, as before.
15
20
  */
16
21
 
17
- import { leads } from "@working-theory/api-client";
22
+ import { leads, visitorForwardHeaders } from "@working-theory/api-client";
18
23
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
19
24
  import { subscribeNewsletterInputSchema } from "@working-theory/validation";
25
+ import { headers } from "next/headers";
20
26
 
21
27
  const GENERIC_ERROR = "Something went wrong, please try again.";
22
28
 
@@ -40,12 +46,22 @@ export async function subscribeNewsletterAction(
40
46
  }
41
47
 
42
48
  const siteId = process.env.AILK_SITE_ID ?? "default";
49
+ const body = { siteId, source: "newsletter", email: parsed.data.email };
50
+ let init: { headers: Record<string, string> } | undefined;
51
+ try {
52
+ const forwarded = visitorForwardHeaders(
53
+ await headers(),
54
+ process.env.AILK_VISITOR_FORWARD_KEY,
55
+ );
56
+ if (forwarded) init = { headers: forwarded };
57
+ } catch {
58
+ // No request scope to read — subscribe without forwarding.
59
+ }
60
+
43
61
  try {
44
- const res = await leads.subscribe({
45
- siteId,
46
- source: "newsletter",
47
- email: parsed.data.email,
48
- });
62
+ const res = init
63
+ ? await leads.subscribe(body, init)
64
+ : await leads.subscribe(body);
49
65
  if (!res.ok) {
50
66
  const error =
51
67
  typeof res.body === "object" && res.body !== null
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * Route handler tests: GET /openapi.json (#7396)
7
+ *
8
+ * `/.well-known/ai-plugin.json` advertises `${baseUrl}/openapi.json` as its
9
+ * `api.url`. This route serves that document on the web host by proxying the
10
+ * API's own `/v1/openapi.json`; when the API cannot answer it, the route
11
+ * answers 502 — never the 404 an agent following the manifest used to get.
12
+ * A relative `servers[]` URL is resolved against the API origin, so the
13
+ * document never sends an agent to this (web) host for an API call.
14
+ */
15
+
16
+ import { GET } from "../route";
17
+
18
+ const SPEC = {
19
+ openapi: "3.1.0",
20
+ info: { title: "Example API", version: "1.0.0" },
21
+ paths: { "/v1/content/search": {} },
22
+ };
23
+
24
+ const API_ENV = ["AILK_API_URL", "NEXT_PUBLIC_AILK_API_URL"] as const;
25
+ const saved: Record<string, string | undefined> = {};
26
+ let fetchSpy: jest.SpyInstance;
27
+
28
+ beforeEach(() => {
29
+ for (const name of API_ENV) {
30
+ saved[name] = process.env[name];
31
+ delete process.env[name];
32
+ }
33
+ fetchSpy = jest.spyOn(globalThis, "fetch");
34
+ });
35
+
36
+ afterEach(() => {
37
+ for (const name of API_ENV) {
38
+ if (saved[name] === undefined) delete process.env[name];
39
+ else process.env[name] = saved[name];
40
+ }
41
+ fetchSpy.mockRestore();
42
+ });
43
+
44
+ function apiAnswers(status: number, body: string): void {
45
+ fetchSpy.mockResolvedValue(
46
+ new Response(body, {
47
+ status,
48
+ headers: { "content-type": "application/json" },
49
+ }),
50
+ );
51
+ }
52
+
53
+ /** The URL the route asked the API for. */
54
+ function requestedUrl(): string {
55
+ return String(fetchSpy.mock.calls[0]?.[0]);
56
+ }
57
+
58
+ describe("GET /openapi.json", () => {
59
+ it("answers 200 with the API's OpenAPI document, unchanged", async () => {
60
+ apiAnswers(200, JSON.stringify(SPEC));
61
+
62
+ const response = await GET();
63
+
64
+ expect(response.status).toBe(200);
65
+ expect(response.headers.get("content-type")).toMatch(/application\/json/);
66
+ expect(await response.json()).toEqual(SPEC);
67
+ });
68
+
69
+ it("reads the API's /v1/openapi.json at AILK_API_URL", async () => {
70
+ process.env["AILK_API_URL"] = "http://api.internal:4000";
71
+ apiAnswers(200, JSON.stringify(SPEC));
72
+
73
+ await GET();
74
+
75
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
76
+ expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
77
+ });
78
+
79
+ it("falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin", async () => {
80
+ apiAnswers(200, JSON.stringify(SPEC));
81
+ await GET();
82
+ expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
83
+
84
+ fetchSpy.mockClear();
85
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
86
+ apiAnswers(200, JSON.stringify(SPEC));
87
+ await GET();
88
+ expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
89
+ });
90
+
91
+ it("answers 502, not 404, when the API cannot be reached", async () => {
92
+ fetchSpy.mockRejectedValue(new TypeError("fetch failed"));
93
+
94
+ const response = await GET();
95
+
96
+ expect(response.status).toBe(502);
97
+ expect(response.headers.get("content-type")).toMatch(/application\/json/);
98
+ expect(response.headers.get("cache-control")).toBe("no-store");
99
+ const body = (await response.json()) as { error?: { code?: string } };
100
+ expect(body.error?.code).toBe("upstream_unavailable");
101
+ });
102
+
103
+ it("answers 502 when the API answers anything but 200", async () => {
104
+ apiAnswers(404, JSON.stringify({ error: { code: "not_found" } }));
105
+
106
+ const response = await GET();
107
+
108
+ expect(response.status).toBe(502);
109
+ expect(response.headers.get("cache-control")).toBe("no-store");
110
+ });
111
+
112
+ it("resolves a relative servers[] URL against the public API origin", async () => {
113
+ process.env["AILK_API_URL"] = "http://api.internal:4000";
114
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
115
+ apiAnswers(
116
+ 200,
117
+ JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
118
+ );
119
+
120
+ const body = (await (await GET()).json()) as typeof SPEC & {
121
+ servers: Array<{ url: string }>;
122
+ };
123
+
124
+ expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
125
+ expect(body.servers).toEqual([
126
+ { url: "https://api.example.com", description: "This deployment's API" },
127
+ ]);
128
+ });
129
+
130
+ it("resolves a relative servers[] URL against AILK_API_URL when no public origin is set", async () => {
131
+ process.env["AILK_API_URL"] = "http://localhost:3700";
132
+ apiAnswers(200, JSON.stringify({ ...SPEC, servers: [{ url: "/" }] }));
133
+
134
+ const body = (await (await GET()).json()) as {
135
+ servers: Array<{ url: string }>;
136
+ };
137
+
138
+ expect(body.servers[0]?.url).toBe("http://localhost:3700");
139
+ });
140
+
141
+ it("leaves an absolute servers[] URL as the API declared it", async () => {
142
+ const servers = [
143
+ { url: "https://api.example.com", description: "This deployment" },
144
+ ];
145
+ apiAnswers(200, JSON.stringify({ ...SPEC, servers }));
146
+
147
+ const body = (await (await GET()).json()) as { servers: unknown };
148
+
149
+ expect(body.servers).toEqual(servers);
150
+ });
151
+
152
+ it("answers 502 when the API answers JSON that is not an OpenAPI document", async () => {
153
+ apiAnswers(200, JSON.stringify({ status: "ok" }));
154
+
155
+ const response = await GET();
156
+
157
+ expect(response.status).toBe(502);
158
+ });
159
+
160
+ it("answers 502 when the API's 200 body is not JSON", async () => {
161
+ apiAnswers(200, "<html>not a spec</html>");
162
+
163
+ const response = await GET();
164
+
165
+ expect(response.status).toBe(502);
166
+ });
167
+
168
+ it("never names the API's internal origin in its error body", async () => {
169
+ process.env["AILK_API_URL"] = "http://api.internal:4000";
170
+ fetchSpy.mockRejectedValue(new TypeError("fetch failed"));
171
+
172
+ const response = await GET();
173
+
174
+ expect(await response.text()).not.toContain("api.internal");
175
+ });
176
+ });