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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- 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 {
|
|
52
|
-
|
|
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 — `
|
|
87
|
-
* replaces both signed-in triggers (Account → a
|
|
88
|
-
* Sign out → `SignOutTrigger`), so this flow config
|
|
89
|
-
* has to satisfy the type: one minimal fields-step
|
|
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
|
|
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
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
});
|